feat :init

This commit is contained in:
2025-11-02 19:34:16 +08:00
commit b767041311
617 changed files with 124099 additions and 0 deletions

17
web/src/App.vue Normal file
View File

@@ -0,0 +1,17 @@
<template>
<a-config-provider :locale="locale.zh_CN">
<div id="app">
<router-view></router-view>
</div>
</a-config-provider>
</template>
<script>
export default {
data () {
return {
locale: window.antd.locales
}
}
}
</script>

View File

@@ -0,0 +1,184 @@
<template>
<div class="audio-player-container">
<div class="player-controls">
<a-button
type="primary"
shape="circle"
size="small"
@click="togglePlay"
:loading="loading"
>
<a-icon :type="isPlaying ? 'pause' : 'caret-right'" />
</a-button>
</div>
<div class="waveform-container" ref="waveform"></div>
</div>
</template>
<script>
import WaveSurfer from "wavesurfer.js";
import EventBus from "@/utils/eventBus";
import { getResourceUrl } from "@/services/axios";
export default {
name: "AudioPlayer",
props: {
audioUrl: {
type: String,
required: true,
},
autoPlay: {
type: Boolean,
default: false,
},
},
data() {
return {
wavesurfer: null,
isPlaying: false,
loading: true,
playerId: null, // 添加一个唯一标识符
};
},
mounted() {
this.$nextTick(() => {
// 生成唯一ID
this.playerId = `player_${Date.now()}_${Math.floor(
Math.random() * 1000
)}`;
this.initWaveSurfer();
// 监听其他播放器的播放事件
EventBus.$on("audio-play", (playerId) => {
// 如果不是当前播放器触发的事件,则暂停当前播放
if (playerId !== this.playerId && this.isPlaying) {
this.wavesurfer.pause();
}
});
// 监听全局停止事件
EventBus.$on("stop-all-audio", () => {
if (this.wavesurfer && this.isPlaying) {
this.wavesurfer.pause();
}
});
});
},
beforeDestroy() {
if (this.wavesurfer) {
// 在销毁前确保先暂停音频播放
if (this.isPlaying) {
this.wavesurfer.pause();
}
this.wavesurfer.destroy();
}
// 移除事件监听
EventBus.$off("audio-play");
EventBus.$off("stop-all-audio");
},
watch: {
audioUrl: {
handler(newUrl) {
if (this.wavesurfer && newUrl) {
this.loading = true;
this.loadAudio(newUrl);
}
},
immediate: false,
},
},
methods: {
initWaveSurfer() {
// 创建wavesurfer实例使用WebAudio后端
this.wavesurfer = WaveSurfer.create({
container: this.$refs.waveform,
waveColor: "#ddd",
progressColor: "#1890ff",
cursorColor: "transparent",
barWidth: 2,
barRadius: 2,
barGap: 1,
height: 40,
responsive: true,
normalize: true,
backend: "WebAudio", // 使用WebAudio后端而不是MediaElement
});
// 事件监听
this.wavesurfer.on("ready", () => {
this.loading = false;
// 如果设置了自动播放,则在音频加载完成后自动播放
if (this.autoPlay) {
this.wavesurfer.play();
}
});
this.wavesurfer.on("play", () => {
this.isPlaying = true;
// 通知其他播放器,当前播放器正在播放
EventBus.$emit("audio-play", this.playerId);
});
this.wavesurfer.on("pause", () => {
this.isPlaying = false;
});
this.wavesurfer.on("finish", () => {
this.isPlaying = false;
// 播放结束后将游标重置到开始位置
this.wavesurfer.seekTo(0);
});
this.wavesurfer.on("error", (err) => {
console.error("音频加载失败:", err);
this.$message.error({ content: "音频加载失败", key: "audioError" });
this.loading = false;
});
// 加载音频
if (this.audioUrl) {
this.loadAudio(this.audioUrl);
}
},
loadAudio(url) {
if (!url) return;
// 检查是否为Blob URL或者Data URL
if (url.startsWith('blob:') || url.startsWith('data:')) {
// 对于Blob URL和Data URL直接使用不需要处理
this.wavesurfer.load(url);
} else {
// 使用统一的资源URL处理函数处理普通URL
const audioUrl = getResourceUrl(url);
this.wavesurfer.load(audioUrl);
}
},
togglePlay() {
if (this.loading) return;
if (this.wavesurfer) {
this.wavesurfer.playPause();
}
},
},
};
</script>
<style scoped>
.audio-player-container {
display: flex;
align-items: center;
width: 100%;
padding: 5px;
}
.player-controls {
margin-right: 10px;
}
.waveform-container {
flex: 1;
height: 40px;
}
</style>

View File

@@ -0,0 +1,332 @@
<template>
<div class="audio-visualizer-container">
<canvas ref="canvas" class="audio-visualizer-canvas"></canvas>
</div>
</template>
<script>
import { getAudioState } from '@/services/audioService';
export default {
name: 'AudioVisualizer',
props: {
width: {
type: Number,
default: 300
},
height: {
type: Number,
default: 100
},
barColor: {
type: String,
default: 'gradient' // 'gradient' 或特定颜色如 '#1890ff'
}
},
data() {
return {
canvas: null,
ctx: null,
analyser: null,
dataArray: null,
animationId: null,
isActive: false,
audioStateCheckInterval: null
};
},
mounted() {
this.initCanvas();
this.setupEventListeners();
this.startAudioStateMonitor();
},
beforeDestroy() {
this.stopVisualization();
this.removeEventListeners();
if (this.audioStateCheckInterval) {
clearInterval(this.audioStateCheckInterval);
}
},
methods: {
initCanvas() {
this.canvas = this.$refs.canvas;
if (!this.canvas) return;
this.canvas.width = this.width;
this.canvas.height = this.height;
this.ctx = this.canvas.getContext('2d');
if (!this.ctx) return;
// 绘制初始静态波形
this.drawStaticWaveform();
},
setupEventListeners() {
// 监听音频数据接收事件
window.addEventListener('audio-data-received', this.onAudioDataReceived);
// 监听音频播放结束事件
window.addEventListener('audio-playback-ended', this.onAudioPlaybackEnded);
// 监听音频播放停止事件
window.addEventListener('audio-playback-stopped', this.onAudioPlaybackStopped);
// 监听窗口大小变化
window.addEventListener('resize', this.handleResize);
},
removeEventListeners() {
window.removeEventListener('audio-data-received', this.onAudioDataReceived);
window.removeEventListener('audio-playback-ended', this.onAudioPlaybackEnded);
window.removeEventListener('audio-playback-stopped', this.onAudioPlaybackStopped);
window.removeEventListener('resize', this.handleResize);
},
handleResize() {
if (this.canvas && this.$el) {
// 获取容器宽度
const containerWidth = this.$el.clientWidth;
const containerHeight = this.height;
// 更新canvas尺寸
this.canvas.width = containerWidth;
this.canvas.height = containerHeight;
// 如果不是活跃状态,重绘静态波形
if (!this.isActive) {
this.drawStaticWaveform();
}
}
},
startAudioStateMonitor() {
// 每200ms检查一次音频状态
this.audioStateCheckInterval = setInterval(() => {
const audioState = getAudioState();
// 如果音频正在播放但可视化未激活,则启动可视化
if (audioState.isAudioPlaying && !this.isActive) {
this.startVisualization();
}
// 如果音频已停止播放但可视化仍在运行,则停止可视化
else if (!audioState.isAudioPlaying && this.isActive) {
this.stopVisualization();
}
}, 200);
},
onAudioDataReceived(event) {
// 当收到音频数据时,检查是否需要启动可视化
const audioState = getAudioState();
if (audioState.isAudioPlaying && !this.isActive) {
this.startVisualization();
}
},
onAudioPlaybackEnded() {
// 音频播放结束时停止可视化
this.stopVisualization();
},
onAudioPlaybackStopped() {
// 音频播放停止时停止可视化
this.stopVisualization();
},
startVisualization() {
if (this.isActive) return;
// 获取音频状态
const audioState = getAudioState();
// 检查是否有分析器
const analyser = audioState.analyser ||
(window.streamingContext && window.streamingContext.analyser);
if (!analyser) {
console.log('没有可用的音频分析器,使用模拟数据');
this.startSimulatedVisualization();
return;
}
// 设置分析器
this.analyser = analyser;
// 创建数据数组
const bufferLength = analyser.frequencyBinCount;
this.dataArray = new Uint8Array(bufferLength);
// 标记为活跃
this.isActive = true;
// 开始动画循环
this.visualize();
console.log('音频可视化开始');
},
startSimulatedVisualization() {
// 如果没有真实的音频分析器,使用模拟数据
this.isActive = true;
// 创建模拟数据数组
const bufferLength = 128;
this.dataArray = new Uint8Array(bufferLength);
// 模拟数据生成函数
const generateSimulatedData = () => {
for (let i = 0; i < bufferLength; i++) {
// 生成随机值,中间频率较高
const centerFactor = 1 - Math.abs((i / bufferLength) - 0.5) * 2;
this.dataArray[i] = Math.floor(Math.random() * 100 * centerFactor) + 50;
}
};
// 初始生成数据
generateSimulatedData();
// 设置定时器定期更新模拟数据
this.simulationInterval = setInterval(generateSimulatedData, 100);
// 开始动画循环
this.visualize();
console.log('模拟音频可视化开始');
},
stopVisualization() {
if (!this.isActive) return;
// 取消动画
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
// 清除模拟数据定时器
if (this.simulationInterval) {
clearInterval(this.simulationInterval);
this.simulationInterval = null;
}
// 标记为非活跃
this.isActive = false;
// 重置为静态波形
this.drawStaticWaveform();
console.log('音频可视化停止');
},
visualize() {
if (!this.isActive || !this.ctx) {
return;
}
// 设置动画帧
this.animationId = requestAnimationFrame(this.visualize.bind(this));
// 获取频率数据
if (this.analyser && this.dataArray) {
this.analyser.getByteFrequencyData(this.dataArray);
}
// 清除画布
this.ctx.fillStyle = '#fafafa';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// 绘制频谱
this.drawSpectrum();
},
drawSpectrum() {
if (!this.ctx || !this.dataArray) return;
const width = this.canvas.width;
const height = this.canvas.height;
const bufferLength = this.dataArray.length;
const barWidth = (width / bufferLength) * 2.5;
let x = 0;
// 创建渐变
let gradient = null;
if (this.barColor === 'gradient') {
gradient = this.ctx.createLinearGradient(0, height, 0, 0);
gradient.addColorStop(0, '#1890ff'); // 蓝色底部
gradient.addColorStop(0.5, '#52c41a'); // 绿色中部
gradient.addColorStop(1, '#faad14'); // 橙色顶部
}
// 绘制频谱条
for (let i = 0; i < bufferLength; i++) {
const barHeight = (this.dataArray[i] / 255) * height * 0.8;
// 设置填充样式
if (this.barColor === 'gradient') {
this.ctx.fillStyle = gradient;
} else if (this.barColor === 'dynamic') {
// 根据频率创建动态颜色
const hue = (i / bufferLength) * 180 + 180; // 从青色到蓝色的渐变
this.ctx.fillStyle = `hsl(${hue}, 70%, 60%)`;
} else {
// 使用指定的颜色
this.ctx.fillStyle = this.barColor;
}
// 绘制条形
const y = height - barHeight;
this.ctx.fillRect(x, y, barWidth - 1, barHeight);
x += barWidth;
}
},
drawStaticWaveform() {
if (!this.ctx || !this.canvas) return;
const width = this.canvas.width;
const height = this.canvas.height;
// 清除画布
this.ctx.fillStyle = '#fafafa';
this.ctx.fillRect(0, 0, width, height);
// 绘制一个静态的正弦波
this.ctx.beginPath();
this.ctx.strokeStyle = '#d9d9d9';
this.ctx.lineWidth = 2;
const amplitude = height * 0.2; // 波形振幅
const frequency = 0.05; // 频率
this.ctx.moveTo(0, height / 2);
for (let x = 0; x < width; x++) {
// 正弦波 + 一点随机性
const y = height / 2 + Math.sin(x * frequency) * amplitude;
this.ctx.lineTo(x, y);
}
this.ctx.stroke();
}
}
};
</script>
<style scoped>
.audio-visualizer-container {
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 4px;
background-color: #fafafa;
box-shadow: inset 0 0 3px rgba(0, 0, 0, 0.1);
}
.audio-visualizer-canvas {
width: 100%;
height: 100%;
display: block;
}
</style>

View File

@@ -0,0 +1,22 @@
<template>
<div :style="{ padding: '0' }">
<ve-line :data="data" ref="charts" :judge-width="true"></ve-line>
</div>
</template>
<script>
export default {
name: "Bar",
props: {
data: {
type: Object,
default: () => {
return {};
},
},
},
data() {
return {};
},
};
</script>

View File

@@ -0,0 +1,593 @@
<template>
<div class="chat-component" :style="{ height: height }">
<!-- 聊天内容区域 -->
<div class="chat-content" ref="chatContentRef" :style="{ maxHeight: contentMaxHeight }">
<div v-if="messages.length === 0" class="empty-chat">
<a-empty :description="emptyText" />
</div>
<div v-else class="chat-messages">
<div v-for="(message, index) in messages" :key="index">
<!-- 时间戳 -->
<div v-if="showTimestamp(message, index)" class="message-timestamp">
{{ formatTimestamp(message.timestamp) }}
</div>
<!-- 消息内容 -->
<div class="message-wrapper" :class="message.isUser ? 'user-message' : 'ai-message'">
<!-- 头像 -->
<div class="avatar">
<a-avatar :src="message.isUser ? userAvatar : aiAvatar" :size="avatarSize" />
</div>
<!-- 消息气泡 -->
<div class="message-content">
<div class="message-bubble" :class="{ 'clickable': messageClickable }" @click="onMessageClick(message)">
<div v-if="message.type === 'text'" class="message-text">
{{ message.content }}
</div>
<div v-else-if="message.type === 'audio'" class="audio-message">
<a-icon type="sound" />
<span class="audio-duration">{{ message.duration || '0:00' }}</span>
<div class="audio-wave">
<div v-for="i in 4" :key="i" class="wave-bar" :class="{ active: message.isPlaying }"></div>
</div>
</div>
<div v-else-if="message.type === 'stt'" class="message-text stt-message">
<a-icon type="audio" /> {{ message.content }}
</div>
<div v-else-if="message.type === 'tts'" class="message-text tts-message">
<a-icon type="sound" /> {{ message.content }}
</div>
<div v-else-if="message.type === 'system'" class="message-text system-message">
{{ message.content }}
</div>
</div>
<!-- 加载指示器 -->
<div v-if="message.isLoading" class="loading-indicator">
<a-spin size="small" />
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 可选的输入区域 -->
<div v-if="showInput" class="chat-input-area">
<!-- 文本输入 -->
<div class="text-input-wrapper">
<!-- 切换按钮移到左侧 -->
<div class="input-left-actions" v-if="showVoiceToggle">
<a-tooltip title="切换语音/文字输入">
<a-button
shape="circle"
class="mode-toggle-button"
:type="isVoiceMode ? 'primary' : 'default'"
@click="toggleInputMode"
:disabled="!isConnectedProp"
>
<a-icon :type="isVoiceMode ? 'audio' : 'message'" />
</a-button>
</a-tooltip>
</div>
<a-textarea
v-if="!isVoiceMode"
v-model="inputMessage"
:placeholder="inputPlaceholder"
:auto-size="{ minRows: 1, maxRows: 4 }"
:disabled="!isConnectedProp"
@keypress.enter="handleEnterKey"
/>
<!-- 语音输入按钮 -->
<a-button
v-else
class="record-button"
:class="{ recording: isRecording }"
type="primary"
:disabled="!isConnectedProp"
@touchstart="startRecording"
@touchend="stopRecording"
@mousedown="startRecording"
@mouseup="stopRecording"
@mouseleave="isRecording && stopRecording"
>
{{ isRecording ? '松开结束录音' : '按住说话' }}
</a-button>
<!-- 发送按钮 -->
<div class="input-right-actions">
<a-button
v-if="!isVoiceMode"
type="primary"
class="send-button"
shape="circle"
:disabled="!isConnectedProp || !inputMessage.trim()"
@click="sendTextMessage"
>
<a-icon type="right" style="font-size: 16px;" />
</a-button>
</div>
</div>
</div>
</div>
</template>
<script>
import {
// WebSocket相关
connectionStatus,
isConnectedProp,
sendTextMessage as wsSendTextMessage,
startDirectRecording,
stopDirectRecording,
} from '@/services/websocketService';
// 引入音频处理服务
import {
handleBinaryMessage,
getAudioState
} from '@/services/audioService';
export default {
name: 'ChatComponent',
props: {
// 是否显示输入框
showInput: {
type: Boolean,
default: false
},
// 是否显示语音切换按钮
showVoiceToggle: {
type: Boolean,
default: true
},
// 传入的消息列表
messageList: {
type: Array,
default: () => []
},
// 用户头像
userAvatar: {
type: String,
default: '/assets/user-avatar.png'
},
// AI头像
aiAvatar: {
type: String,
default: '/assets/ai-avatar.png'
},
// 组件高度
height: {
type: String,
default: '100%'
},
// 空消息提示文本
emptyText: {
type: String,
default: '暂无对话记录'
},
// 输入框占位符
inputPlaceholder: {
type: String,
default: '输入消息...'
},
// 头像大小
avatarSize: {
type: Number,
default: 40
},
// 消息是否可点击
messageClickable: {
type: Boolean,
default: false
},
isConnectedProp: {
type: Boolean,
default: false
},
// 内容区域最大高度
contentMaxHeight: {
type: String,
default: 'none'
}
},
data() {
return {
inputMessage: '',
messages: [],
isVoiceMode: false,
isRecording: false
};
},
watch: {
// 监听外部消息列表变化
messageList: {
handler(newVal) {
this.messages = newVal;
this.scrollToBottom();
},
immediate: true,
deep: true
}
},
mounted() {
this.scrollToBottom();
},
methods: {
// 处理回车键按下事件
handleEnterKey(e) {
// 阻止默认行为(换行)
e.preventDefault();
// 检查是否按下了修饰键
if (e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) {
// 如果按下了修饰键,不发送消息
return;
}
// 发送消息
this.sendTextMessage();
},
// 滚动到底部
scrollToBottom() {
this.$nextTick(() => {
if (this.$refs.chatContentRef) {
this.$refs.chatContentRef.scrollTop = this.$refs.chatContentRef.scrollHeight;
}
});
},
// 发送文本消息
sendTextMessage() {
const text = this.inputMessage.trim();
if (!text || !this.isConnectedProp) return;
// 发送到服务器
wsSendTextMessage(text);
// 清空输入框
this.inputMessage = '';
// 滚动到底部
this.scrollToBottom();
},
// 消息点击事件
onMessageClick(message) {
if (this.messageClickable) {
this.$emit('message-click', message);
}
},
// 开始录音
async startRecording() {
if (this.isRecording || !this.isConnectedProp) return;
try {
this.isRecording = true;
await startDirectRecording();
this.$emit('recording-start');
} catch (error) {
this.isRecording = false;
this.$message.error('无法启动录音,请检查麦克风权限');
this.$emit('recording-error', error);
}
},
// 停止录音
async stopRecording() {
if (!this.isRecording) return;
try {
this.isRecording = false;
await stopDirectRecording();
this.$emit('recording-stop');
} catch (error) {
this.$message.error('停止录音失败');
this.$emit('recording-error', error);
}
},
// 切换输入模式
toggleInputMode() {
this.isVoiceMode = !this.isVoiceMode;
this.$emit('mode-change', this.isVoiceMode);
},
// 显示时间戳
showTimestamp(message, index) {
if (index === 0) return true;
const prevMsg = this.messages[index - 1];
if (!prevMsg || !prevMsg.timestamp || !message.timestamp) return true;
// 如果与上一条消息时间间隔超过5分钟显示时间戳
const prevTime = prevMsg.timestamp instanceof Date ? prevMsg.timestamp.getTime() : new Date(prevMsg.timestamp).getTime();
const currTime = message.timestamp instanceof Date ? message.timestamp.getTime() : new Date(message.timestamp).getTime();
return currTime - prevTime > 5 * 60 * 1000;
},
// 格式化时间戳
formatTimestamp(timestamp) {
if (!timestamp) return '';
const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
return date.toLocaleString();
},
// 处理二进制音频消息 - 这是一个代理方法调用audioService中的处理函数
async handleBinaryAudioMessage(data) {
return await handleBinaryMessage(data);
}
}
};
</script>
<style scoped>
.chat-component {
display: flex;
flex-direction: column;
background-color: #f5f5f5;
border-radius: 4px;
overflow: hidden;
}
.chat-content {
flex: 1;
overflow-y: auto;
padding: 16px;
scroll-behavior: smooth;
}
.empty-chat {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
min-height: 200px;
}
.chat-messages {
display: flex;
flex-direction: column;
gap: 16px;
}
.message-timestamp {
text-align: center;
margin: 8px 0;
color: #999;
font-size: 12px;
}
.message-timestamp::before,
.message-timestamp::after {
content: '';
display: inline-block;
width: 60px;
height: 1px;
background-color: #e8e8e8;
margin: 0 10px;
vertical-align: middle;
}
.message-wrapper {
display: flex;
margin-bottom: 8px;
width: 100%;
}
.user-message {
flex-direction: row-reverse;
}
.avatar {
margin: 0 8px;
flex-shrink: 0;
}
.message-content {
max-width: 70%;
display: flex;
flex-direction: column;
min-width: 60px;
}
.message-bubble {
padding: 10px 14px;
border-radius: 4px;
position: relative;
word-break: break-word;
width: auto;
display: inline-block;
/* 修复消息气泡高度问题 */
min-height: 0;
height: auto;
line-height: 0;
box-sizing: content-box;
}
.message-bubble.clickable {
cursor: pointer;
transition: background-color 0.2s;
}
.user-message .message-bubble {
background-color: #95ec69;
color: white;
border-bottom-right-radius: 4px;
}
.user-message .message-bubble.clickable:hover {
background-color: #71d970;
}
.ai-message .message-bubble {
background-color: #fff;
color: #333;
border-bottom-left-radius: 4px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.ai-message .message-bubble.clickable:hover {
background-color: #f9f9f9;
}
.message-text {
white-space: pre-wrap;
word-break: break-word;
min-width: 20px;
/* 修复文字行高问题 */
min-height: 0;
height: auto;
padding: 0;
margin: 0;
}
.stt-message {
color: #232323;
}
.tts-message {
color: #13c2c2;
}
.system-message {
color: #fa8c16;
}
.audio-message {
display: flex;
align-items: center;
gap: 8px;
}
.audio-duration {
font-size: 12px;
}
.audio-wave {
display: flex;
align-items: center;
gap: 2px;
}
.wave-bar {
width: 3px;
height: 12px;
background-color: currentColor;
opacity: 0.5;
border-radius: 1px;
}
.wave-bar.active {
animation: sound-wave 1s infinite ease-in-out;
}
@keyframes sound-wave {
0%, 100% { height: 4px; }
50% { height: 16px; }
}
.wave-bar:nth-child(2).active {
animation-delay: 0.2s;
}
.wave-bar:nth-child(3).active {
animation-delay: 0.4s;
}
.wave-bar:nth-child(4).active {
animation-delay: 0.6s;
}
.loading-indicator {
margin-top: 4px;
align-self: flex-start;
}
.user-message .loading-indicator {
align-self: flex-end;
}
.chat-input-area {
padding: 12px 16px;
background-color: #f9f9f9;
border-top: 1px solid #e8e8e8;
}
.text-input-wrapper {
display: flex;
width: 100%;
align-items: center;
}
/* 输入框左侧操作区 */
.input-left-actions {
margin-right: 8px;
}
/* 输入框右侧操作区 */
.input-right-actions {
margin-left: 8px;
}
.text-input-wrapper :deep(.ant-input) {
flex: 1;
border-radius: 20px;
padding: 8px 16px;
background-color: #fff;
}
.mode-toggle-button {
width: 36px;
height: 36px;
display: flex;
justify-content: center;
align-items: center;
}
.mode-toggle-button :deep(.anticon) {
font-size: 16px;
}
.send-button {
width: 36px;
height: 36px;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
}
/* 确保图标正确显示 */
.send-button :deep(.anticon) {
display: inline-flex !important;
align-items: center;
justify-content: center;
}
.record-button {
flex: 1;
height: 48px;
border-radius: 24px;
transition: all 0.3s;
}
.record-button.recording {
background-color: #ff4d4f;
border-color: #ff4d4f;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(255, 77, 79, 0.4); }
70% { box-shadow: 0 0 0 10px rgba(255, 77, 79, 0); }
100% { box-shadow: 0 0 0 0 rgba(255, 77, 79, 0); }
}
</style>

View File

@@ -0,0 +1,656 @@
<template>
<a-layout>
<a-layout-content>
<div class="layout-content-margin">
<!-- 查询框 -->
<div class="table-search">
<a-form layout="horizontal" :colon="false" :labelCol="{ span: 6 }" :wrapperCol="{ span: 16 }">
<a-row class="filter-flex">
<a-col :xxl="8" :xl="8" :lg="12" :xs="24">
<a-form-item :label="`类别`">
<a-select v-model="query.provider" @change="getData()">
<a-select-option key="" value="">
<span>全部</span>
</a-select-option>
<a-select-option v-for="item in typeOptions" :key="item.value">
<span>{{ item.label }}</span>
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xl="8" :lg="12" :xs="24" v-for="item in queryFilter" :key="item.index">
<a-form-item :label="item.label">
<a-input-search v-model="query[item.index]" placeholder="请输入" allow-clear @search="getData()" />
</a-form-item>
</a-col>
<!-- 添加模型类型筛选仅在LLM配置时显示 -->
<a-col :xxl="8" :xl="8" :lg="12" :xs="24" v-if="configType === 'llm'">
<a-form-item label="模型类型">
<a-select v-model="query.modelType" @change="getData()">
<a-select-option value="">
<span>全部</span>
</a-select-option>
<a-select-option value="chat">对话模型</a-select-option>
<a-select-option value="vision">视觉模型</a-select-option>
<a-select-option value="intent">意图模型</a-select-option>
<a-select-option value="embedding">向量模型</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
<!-- 表格数据 -->
<a-card :bodyStyle="{ padding: 0 }" :bordered="false">
<a-tabs defaultActiveKey="1" :activeKey="activeTabKey" @change="handleTabChange"
tabBarStyle="margin: 0 0 0 15px">
<a-tab-pane key="1" :tab="`${configTypeInfo.label}列表`">
<a-table :columns="getColumns" :dataSource="configItems" :loading="loading" :pagination="pagination"
rowKey="configId" :scroll="{ x: 800 }" size="middle">
<template slot="configDesc" slot-scope="text">
<a-tooltip :title="text" :mouseEnterDelay="0.5" placement="leftTop">
<span v-if="text">{{ text }}</span>
<span v-else style="padding: 0 50px">&nbsp;&nbsp;&nbsp;</span>
</a-tooltip>
</template>
<!-- 添加默认标识列的自定义渲染 -->
<template slot="isDefault" slot-scope="text">
<a-tag v-if="text == 1" color="green">默认</a-tag>
<span v-else>-</span>
</template>
<template slot="operation" slot-scope="text, record">
<a-space>
<a href="javascript:" @click="edit(record)">编辑</a>
<!-- 添加设为默认按钮但在TTS中不显示 -->
<a v-if="configType !== 'tts' && record.isDefault != 1" href="javascript:"
:disabled="record.isDefault == 1" @click="setAsDefault(record)">设为默认</a>
<a-popconfirm :title="`确定要删除这个${configTypeInfo.label}配置吗?`"
@confirm="deleteConfig(record.configId)">
<a v-if="record.isDefault != 1" href="javascript:" style="color: #ff4d4f">删除</a>
</a-popconfirm>
</a-space>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="2" :tab="`创建${configTypeInfo.label}`">
<a-form layout="horizontal" :form="configForm" :colon="false" @submit="handleSubmit"
style="padding: 10px 24px">
<a-row :gutter="20">
<a-col :xl="8" :lg="12" :xs="24">
<a-form-item :label="`${configTypeInfo.label}类别`">
<a-select v-decorator="[
'provider',
{ rules: [{ required: true, message: `请选择${configTypeInfo.label}类别` }] }
]" :placeholder="`请选择${configTypeInfo.label}类别`" @change="handleTypeChange">
<a-select-option v-for="item in typeOptions" :key="item.value" :value="item.value">
{{ item.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xl="8" :lg="12" :xs="24">
<a-form-item :label="`${configTypeInfo.label}名称`">
<!-- 如果是 llm 且有 currentType变为可输入的下拉框 -->
<a-select v-if="configType === 'llm' && currentType"
v-decorator="[
'configName',
{ rules: [{ required: true, message: `请输入${configTypeInfo.label}名称` }] }
]"
showSearch
allowClear
:placeholder="`请输入${configTypeInfo.label}名称`"
:options="modelOptions"
:filterOption="modelFilterOption"
@search="handleModelInputChange"
@change="handleModelChange"
@blur="handleModelBlur">
</a-select>
<!-- 如果不是 llm 或没有 currentType保留原来的输入框 -->
<a-input v-else
v-decorator="[
'configName',
{ rules: [{ required: true, message: `请输入${configTypeInfo.label}名称` }] }
]"
autocomplete="off"
:placeholder="`请输入${configTypeInfo.label}名称`" />
</a-form-item>
</a-col>
<!-- 添加模型类型选择仅在LLM配置时显示 -->
<a-col :xl="8" :lg="12" :xs="24" v-if="configType === 'llm'">
<a-form-item label="模型类型">
<a-select v-decorator="[
'modelType',
{ initialValue: 'chat', rules: [{ required: true, message: '请选择模型类型' }] }
]" placeholder="请选择模型类型">
<a-select-option value="chat">对话模型</a-select-option>
<a-select-option value="vision">视觉模型</a-select-option>
<a-select-option value="intent">意图模型</a-select-option>
<a-select-option value="embedding">向量模型</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item :label="`${configTypeInfo.label}描述`">
<a-textarea v-decorator="['configDesc']" :placeholder="`请输入${configTypeInfo.label}描述`" :rows="4" />
</a-form-item>
<!-- 添加是否默认的开关但在TTS中不显示 -->
<a-form-item v-if="configType !== 'tts'" :label="`设为默认${configTypeInfo.label}`">
<a-switch v-decorator="[
'isDefault',
{ valuePropName: 'checked', initialValue: false }
]" />
<span style="margin-left: 8px; color: #999;">设为默认后将优先使用此配置</span>
</a-form-item>
<a-divider>参数配置</a-divider>
<a-space direction="vertical" style="width: 100%">
<a-card v-if="currentType" size="small" :bodyStyle="{ 'background-color': '#fafafa' }"
:bordered="false">
<a-row :gutter="20">
<!-- 根据选择的模型类别动态显示参数配置 -->
<template v-for="field in currentTypeFields">
<a-col :key="field.name" :xl="field.span || 12" :lg="12" :xs="24">
<a-form-item :label="field.label" style="margin-bottom: 24px">
<a-input v-decorator="[
field.name,
{ rules: [{ required: field.required, message: `请输入${field.label}` }] }
]" :placeholder="`请输入${field.label}`" :type="field.inputType || 'text'"
@change="getModelList()">
<template v-if="field.suffix" slot="suffix">
<span style="color: #999">{{ field.suffix }}</span>
</template>
</a-input>
</a-form-item>
</a-col>
</template>
</a-row>
</a-card>
<a-card v-else :bodyStyle="{ 'background-color': '#fafafa' }" :bordered="false">
<a-empty :description="`请先选择${configTypeInfo.label}类别`" />
</a-card>
<a-form-item>
<a-button type="primary" html-type="submit">
{{ editingConfigId ? `更新${configTypeInfo.label}` : `创建${configTypeInfo.label}` }}
</a-button>
<a-button style="margin-left: 8px" @click="resetForm">
取消
</a-button>
</a-form-item>
</a-space>
</a-form>
</a-tab-pane>
</a-tabs>
</a-card>
</div>
</a-layout-content>
</a-layout>
</template>
<script>
import axios from '@/services/axios'
import api from '@/services/api'
import mixin from '@/mixins/index'
import { configTypeMap } from '@/config/providerConfig'
export default {
name: 'ConfigManager',
mixins: [mixin],
props: {
// 配置类型llm, stt, tts
configType: {
type: String,
required: true,
validator: value => ['llm', 'stt', 'tts'].includes(value)
}
},
data() {
return {
// 查询框
query: {
provider: "",
modelType: ""
},
queryFilter: [
{
label: "名称",
value: "",
index: "configName",
},
],
activeTabKey: '1', // 当前激活的标签页
configForm: null,
configItems: [],
editingConfigId: null,
currentType: '',
loading: false,
modelOptions: [], // 存储模型下拉框选项
columns: [
{
title: '类别',
dataIndex: 'provider',
key: 'provider',
width: 200,
align: 'center',
customRender: (text) => {
const provider = this.typeOptions.find(item => item.value === text);
return provider ? provider.label : text;
},
ellipsis: true,
},
{
title: '名称',
dataIndex: 'configName',
key: 'configName',
width: 200,
align: 'center',
},
{
title: '描述',
dataIndex: 'configDesc',
scopedSlots: { customRender: 'configDesc' },
key: 'configDesc',
align: 'center',
ellipsis: true,
},
// 添加默认标识列
{
title: '默认',
dataIndex: 'isDefault',
key: 'isDefault',
width: 80,
align: 'center',
scopedSlots: { customRender: 'isDefault' }
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
width: 180,
align: 'center'
},
{
title: '操作',
dataIndex: 'operation',
key: 'operation',
width: 180,
align: 'center',
fixed: 'right',
scopedSlots: { customRender: 'operation' }
}
]
}
},
computed: {
// 当前配置类型的信息
configTypeInfo() {
return configTypeMap[this.configType] || {};
},
// 当前配置类型的选项
typeOptions() {
return this.configTypeInfo.typeOptions || [];
},
// 当前选择的类别对应的参数字段
currentTypeFields() {
const typeFieldsMap = this.configTypeInfo.typeFields || {};
return typeFieldsMap[this.currentType] || [];
},
// 根据配置类型获取适当的列
getColumns() {
if (this.configType === 'tts') {
// 对于TTS过滤掉isDefault列
return this.columns.filter(col => col.key !== 'isDefault');
}
return this.columns;
}
},
created() {
// 创建表单实例
this.configForm = this.$form.createForm(this, {
onValuesChange: (props, values) => {
if (values.provider && values.provider !== this.currentType) {
this.currentType = values.provider;
}
}
});
},
mounted() {
this.getData()
},
methods: {
getModelList() {
const formValues = this.configForm.getFieldsValue();
const apiKey = formValues.apiKey;
const apiUrl = formValues.apiUrl;
// 检查是否输入了必要的参数
if (!apiKey || !apiUrl) {
return;
}
axios
.post({
url: api.config.getModels,
data: {
...formValues
}
})
.then(res => {
if (res.code === 200) {
this.modelOptions = res.data.map((item) => ({
value: item.id,
label: item.id,
}));
}
})
.catch(() => {
this.showError();
})
},
filterOption(input, option) {
return option.label.toLowerCase().includes(input.toLowerCase());
},
// 处理输入变化
handleModelInputChange(value) {
this.$nextTick(() => {
// 手动绑定输入的值到表单字段
setTimeout(() => {
this.configForm.setFieldsValue({
configName: value
});
}, 0);
});
},
// 处理选项变化
handleModelChange(value) {
// 如果用户选择了一个选项,直接更新表单字段
this.$nextTick(() => {
// 手动绑定输入的值到表单字段
setTimeout(() => {
this.configForm.setFieldsValue({
configName: value
});
}, 0);
});
},
// 处理失去焦点时的逻辑
handleModelBlur() {
const value = this.configForm.getFieldValue('configName');
// 如果输入的值不在选项列表中,保留用户输入的值
this.$nextTick(() => {
// 手动绑定输入的值到表单字段
setTimeout(() => {
this.configForm.setFieldsValue({
configName: value
});
}, 0);
});
},
// 处理标签页切换
handleTabChange(key) {
this.activeTabKey = key;
this.resetForm();
},
// 处理类别变化
handleTypeChange(value) {
console.log('选择的类别:', value);
this.currentType = value;
// 由于使用了v-decorator不需要手动设置表单值
// 但需要清除之前的参数值
const { configForm } = this;
const formValues = configForm.getFieldsValue();
// 创建一个新的表单值对象,只保留基本信息
const newValues = {
provider: value,
configName: formValues.configName,
configDesc: formValues.configDesc
};
// 如果不是TTS添加isDefault字段
if (this.configType !== 'tts') {
newValues.isDefault = formValues.isDefault;
}
// 清除所有可能的参数字段
const allParamFields = ['apiKey', 'apiUrl', 'apiSecret', 'appId', 'secretKey', 'region'];
allParamFields.forEach(field => {
newValues[field] = undefined;
});
//填写llm默认url
if (this.configType === 'llm') {
newValues.modelType = formValues.modelType || 'chat';
const apiUrlField = configTypeMap.llm.typeFields[value].find(item => item.name === 'apiUrl');
if (apiUrlField && apiUrlField.defaultUrl) {
newValues.apiUrl = apiUrlField.defaultUrl;
}
}
// 重置表单
this.$nextTick(() => {
// 设置新的表单值
setTimeout(() => {
configForm.setFieldsValue(newValues);
}, 0);
});
},
// 获取配置列表
getData() {
this.loading = true;
axios
.get({
url: api.config.query,
data: {
page: this.pagination.page,
pageSize: this.pagination.pageSize,
configType: this.configType,
...this.query
}
})
.then(res => {
if (res.code === 200) {
this.configItems = res.data.list
this.pagination.total = res.data.total
} else {
this.$message.error(res.message)
}
})
.catch(() => {
this.showError();
})
.finally(() => {
this.loading = false
})
},
// 提交表单
handleSubmit(e) {
e.preventDefault()
this.configForm.validateFields((err, values) => {
if (!err) {
if (this.configType === 'llm') {
// 校验 configName 是否为英文、数字或者它们的组合
const configName = values.configName;
const containsChineseRegex = /[\u4e00-\u9fa5]/; // 检测是否包含中文字符
if (containsChineseRegex.test(configName)) {
this.$message.error('模型名称不能随意输入请输入正确的模型名称例如deepseek-chat、qwen-plus官方名称');
return;
}
}
// 处理可能的URL后缀重复问题
const currentType = values.provider;
const typeFields = this.configTypeInfo.typeFields || {};
const apiUrlField = (typeFields[currentType] || []).find(field => field.name === 'apiUrl');
if (apiUrlField && apiUrlField.suffix) {
const suffix = apiUrlField.suffix;
// 检查URL是否已经以后缀结尾如果是则不再添加
if (values.apiUrl.endsWith(suffix)) {
// 移除URL末尾的后缀部分
values.apiUrl = values.apiUrl.substring(0, values.apiUrl.length - suffix.length);
}
}
// 将开关值转换为数字但TTS不需要处理isDefault
const formData = {
configId: this.editingConfigId,
configType: this.configType,
...values
};
// 只有非TTS类型才处理isDefault
if (this.configType !== 'tts') {
formData.isDefault = values.isDefault ? 1 : 0;
}
this.loading = true
const url = this.editingConfigId
? api.config.update
: api.config.add
axios
.post({
url,
data: formData
})
.then(res => {
if (res.code === 200) {
this.$message.success(
this.editingConfigId ? '更新成功' : '创建成功'
)
this.resetForm()
this.getData()
// 成功后切换到列表页
this.activeTabKey = '1'
} else {
this.$message.error(res.message)
}
})
.catch(() => {
this.showError();
})
.finally(() => {
this.loading = false
})
}
})
},
// 编辑配置
edit(record) {
this.editingConfigId = record.configId
this.currentType = record.provider || '';
// 切换到创建配置标签页
this.activeTabKey = '2'
this.$nextTick(() => {
const { configForm } = this
// 设置表单值使用setTimeout确保表单已渲染
setTimeout(() => {
const formValues = { ...record };
// 只有非TTS类型才设置isDefault
if (this.configType !== 'tts') {
formValues.isDefault = record.isDefault == 1;
}
configForm.setFieldsValue(formValues);
this.getModelList();
}, 0);
})
},
// 设置为默认配置
setAsDefault(record) {
// TTS不应该有这个功能但为了安全起见再次检查
if (this.configType === 'tts') return;
this.$confirm({
title: `确定要将此${this.configTypeInfo.label}设为默认吗?`,
content: `设为默认后,系统将优先使用此${this.configTypeInfo.label}配置,原默认${this.configTypeInfo.label}将被取消默认状态。`,
okText: '确定',
cancelText: '取消',
onOk: () => {
this.loading = true;
axios
.post({
url: api.config.update,
data: {
configId: record.configId,
configType: this.configType,
modelType: this.configType === 'llm' ? record.modelType : null,
isDefault: 1
}
})
.then(res => {
if (res.code === 200) {
this.$message.success(`已将"${record.configName}"设为默认${this.configTypeInfo.label}`);
this.getData();
} else {
this.$message.error(res.message)
}
})
.catch(() => {
this.showError();
})
.finally(() => {
this.loading = false;
});
}
});
},
// 删除配置
deleteConfig(configId) {
this.loading = true
axios
.post({
url: api.config.update,
data: {
configId: configId,
configType: this.configType,
state: 0
}
})
.then(res => {
if (res.code === 200) {
this.$message.success(`删除${this.configTypeInfo.label}配置成功`)
this.getData()
} else {
this.$message.error(res.message)
}
})
.catch(() => {
this.showError();
})
.finally(() => {
this.loading = false
})
},
// 重置表单
resetForm() {
this.configForm.resetFields()
this.currentType = ''
this.modelOptions = []
this.editingConfigId = null
}
}
}
</script>

View File

@@ -0,0 +1,89 @@
<template>
<a-modal v-model="visible" title="设备详情" @ok="handleOk" @cancel="handleClose" width="650px">
<a-form :form="form" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="设备名称">
<a-input v-model="form.deviceName"/>
</a-form-item>
<a-form-item label="绑定角色">
<a-select v-model="form.roleId">
<a-select-option v-for="i in roleItems" :key="i.roleId" :value="i.roleId">{{ i.roleName }}</a-select-option>
</a-select>
</a-form-item>
</a-form>
<template slot="footer">
<a-popconfirm
title="确定要清除该设备的所有对话记忆吗?此操作不可恢复。"
ok-text="确定"
cancel-text="取消"
@confirm="handleClearMemory"
>
<a-button key="clear" type="danger" :loading="clearMemoryLoading">
清除记忆
</a-button>
</a-popconfirm>
<a-button key="back" @click="handleClose">
取消
</a-button>
<a-button key="submit" type="primary" @click="handleOk">
确定
</a-button>
</template>
</a-modal>
</template>
<script>
export default {
name: "DeviceEditDialog",
props:{
visible: Boolean,
roleItems: Array,
current: Object,
clearMemoryLoading: Boolean
},
data(){
return {
form: this.$form.createForm(this, {
deviceId: "",
deviceName: "",
roleId: null
})
}
},
methods:{
handleClose(){
this.$emit("close");
},
handleOk(){
this.$emit("submit", this.form)
},
handleClearMemory() {
this.$emit("clear-memory", this.form);
}
},
watch:{
visible(val){
if(val){
// 复制当前设备数据到表单
this.form = Object.assign({}, this.$props.current);
}
}
}
}
</script>
<style scoped>
/* 确保所有下拉框中的文本居中 */
>>> .ant-select-selection__rendered .ant-select-selection-selected-value {
text-align: center !important;
width: 100% !important;
}
/* 确保输入框内容居中 */
>>> .ant-input {
text-align: center !important;
}
</style>

View File

@@ -0,0 +1,134 @@
/**
* 系统中各类服务提供商配置
* 统一管理各类服务的提供商信息,便于维护和扩展
*/
// 配置类型信息映射
export const configTypeMap = {
llm: {
label: '模型',
// 模型类别选项
typeOptions: [
{ label: 'OpenAI', value: 'openai', key: '0' },
{ label: 'Ollama', value: 'ollama', key: '1' },
{ label: 'Spark', value: 'spark', key: '2' },
{ label: 'Zhipu', value: 'zhipu', key: '3' },
{ label: 'AliYun', value: 'aliyun', key: '4' },
{ label: 'Qwen', value: 'qwen', key: '5' },
{ label: 'Doubao', value: 'doubao', key: '6' },
{ label: 'DeepSeek', value: 'deepseek', key: '7' },
{ label: 'ChatGLM', value: 'chatglm', key: '8' },
{ label: 'Gemini', value: 'gemini', key: '9' },
{ label: 'LMStudio', value: 'lmstudio', key: '10' },
{ label: 'Fastgpt', value: 'fastgpt', key: '11' },
{ label: 'Xinference', value: 'xinference', key: '12' },
],
// 各类别对应的参数字段定义
typeFields: {
openai: [
{ name: 'apiKey', label: 'API Key', required: true, span: 12 },
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions' },
],
ollama: [
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/api/chat', defaultUrl: "http://localhost:11434" }
],
spark: [
{ name: 'apiKey', label: 'API Key', required: true, span: 8 },
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl: "https://spark-api-open.xf-yun.com/v1" }
],
zhipu: [
{ name: 'apiKey', label: 'API Secret', required: true, span: 8 },
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: 'v4/chat/completions', defaultUrl: "https://open.bigmodel.cn/api/paas" }
],
aliyun: [
{ name: 'apiKey', label: 'API Secret', required: true, span: 8 },
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1" }
],
qwen: [
{ name: 'apiKey', label: 'API Secret', required: true, span: 8 },
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1" }
],
doubao: [
{ name: 'apiKey', label: 'API Secret', required: true, span: 8 },
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl: "https://ark.cn-beijing.volces.com/api/v3" }
],
deepseek: [
{ name: 'apiKey', label: 'API Secret', required: true, span: 8 },
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl: "https://api.deepseek.com" }
],
chatglm: [
{ name: 'apiKey', label: 'API Secret', required: true, span: 8 },
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl: "https://open.bigmodel.cn/api/paas/v4/" }
],
gemini: [
{ name: 'apiKey', label: 'API Secret', required: true, span: 8 },
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl: "https://generativelanguage.googleapis.com/v1beta/" }
],
lmstudio: [
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl: "http://localhost:1234/v1" }
],
fastgpt: [
{ name: 'apiKey', label: 'API Secret', required: true, span: 8 },
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl: "http://localhost:3000/api/v1" }
],
xinference: [
{ name: 'apiKey', label: 'API Secret', required: true, span: 8 },
{ name: 'apiUrl', label: 'API URL', required: true, span: 12, suffix: '/chat/completions', defaultUrl: "http://localhost:9997/v1" }
]
}
},
stt: {
label: '语音识别',
typeOptions: [
{ label: 'Tencent', value: 'tencent', key: '0' },
{ label: 'Aliyun', value: 'aliyun', key: '1' },
{ label: 'Xfyun', value: 'xfyun', key: '2' },
{ label: 'FunASR', value: 'funasr', key: '3' }
],
typeFields: {
tencent: [
{ name: 'appId', label: 'App Id', required: true, span: 12 },
{ name: 'apiKey', label: 'Secret Id', required: true, span: 12 },
{ name: 'apiSecret', label: 'Secret Key', required: true, span: 12 },
],
aliyun: [
{ name: 'apiKey', label: 'App Key', required: true, span: 12 }
],
xfyun: [
{ name: 'appId', label: 'App Id', required: true, span: 12 },
{ name: 'apiSecret', label: 'Api Secret', required: true, span: 12 },
{ name: 'apiKey', label: 'Api Key', required: true, span: 12 }
],
funasr: [
{ name: 'apiUrl', label: 'Websocket URL', required: true, span: 12, defaultUrl: "ws://127.0.0.1:10095" }
]
}
},
tts: {
label: '语音合成',
typeOptions: [
{ label: 'Aliyun', value: 'aliyun', key: '0' },
{ label: 'Volcengine(doubao)', value: 'volcengine', key: '1' },
{ label: 'Xfyun', value: 'xfyun', key: '2' },
{ label: 'Minimax', value: 'minimax', key: '3' }
],
typeFields: {
aliyun: [
{ name: 'apiKey', label: 'API Key', required: true, span: 12 }
],
volcengine: [
{ name: 'appId', label: 'App Id', required: true, span: 12 },
{ name: 'apiKey', label: 'Access Token', required: true, span: 12 }
],
xfyun: [
{ name: 'appId', label: 'App Id', required: true, span: 12 },
{ name: 'apiSecret', label: 'Api Secret', required: true, span: 12 },
{ name: 'apiKey', label: 'Api Key', required: true, span: 12 }
],
minimax: [
{ name: 'appId', label: 'Group Id', required: true, span: 12 },
{ name: 'apiKey', label: 'API Key', required: true, span: 12 }
],
}
}
};

35
web/src/main.js Normal file
View File

@@ -0,0 +1,35 @@
// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from "vue";
import App from "./App";
import router from "./router";
import store from "./store";
import axios from "axios";
import Moment from "moment";
import Antd from "ant-design-vue";
// import 'ant-design-vue/dist/antd.css'
import NProgress from "nprogress";
// import 'nprogress/nprogress.css'
// import VueCropper from 'vue-cropper'
// import VCharts from 'v-charts'
import "static/css/main.css";
Moment.locale("zh_CN");
Vue.use(NProgress);
router.beforeEach((to, from, next) => {
NProgress.start();
next();
});
NProgress.configure({ easing: "ease", speed: 500, showSpinner: false });
router.afterEach(() => {
NProgress.done();
});
Vue.use(Antd);
Vue.use(window["vue-cropper"]);
Vue.prototype.moment = Moment;
new Vue({
router,
store,
render: h => h(App)
}).$mount("#app");

116
web/src/mixins/index.js Normal file
View File

@@ -0,0 +1,116 @@
import axios from "@/services/axios";
import api from "@/services/api";
const mixin = {
data() {
return {
// 遮罩层
loading: true,
exportLoading: false,
timeRange: [moment().startOf("month"), moment().endOf("month")],
time: moment(),
// 分页
pagination: {
total: 0,
page: 1,
pageSize: 10,
showTotal: total => `${total}`,
hideOnSinglePage: false,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ["10", "30", "50", "100", "1000"],
onChange: (page, pageSize) => {
this.pageChange(page, pageSize);
},
onShowSizeChange: (page, pageSize) => {
this.pageChange(page, pageSize);
},
style: "padding: 0 24px"
}
};
},
computed: {
userInfo() {
return this.$store.getters.USER_INFO;
}
},
mounted() {
},
methods: {
/* 换页 */
pageChange(page, pageSize) {
this.pagination.page = page;
this.pagination.pageSize = pageSize;
this.getData();
},
/* 编辑单元格操作 */
inputEdit(value, key, column) {
const data = this.editLine(key);
data.target[column] = value;
this.data = data.newData;
},
/* 点击编辑操作 */
edit(key) {
// 先取消所有行的编辑状态
this.data.forEach(item => {
if (item.editable) {
delete item.editable;
}
});
const data = this.editLine(key);
this.editingKey = key;
data.target.editable = true;
this.data = data.newData;
},
/* 取消按钮 */
cancel(key) {
const data = this.editLine(key);
this.updateFailed(data, key);
delete data.target.editable;
},
/* 更新成功 */
updateSuccess(data) {
Object.assign(data.targetCache, data.target);
this.data = data.newData;
this.cacheData = data.newCacheData;
this.editingKey = "";
},
/* 更新失败 */
updateFailed(data, key) {
Object.assign(
data.target,
this.cacheData.filter(
item => key === item.deviceId || key === item.userId
)[0]
);
this.data = data.newData;
this.editingKey = "";
},
/* 获取点击行修改前后数据 */
editLine(key) {
let data = [];
data.newData = [...this.data];
data.newCacheData = [...this.cacheData];
data.target = data.newData.filter(
item => key === item.deviceId || key === item.userId
)[0];
data.targetCache = data.newCacheData.filter(
item => key === item.deviceId || key === item.userId
)[0];
return data;
},
/**
* 显示服务器错误消息
* 使用全局key确保只显示一个错误消息
* @param {string} errorMsg 错误消息
*/
showError(errorMsg = "服务器维护/重启中,请稍后再试") {
// 使用全局key显示错误消息如果已存在则会更新而不是创建新的
this.$message.error({
content: errorMsg,
key: "error",
duration: 3
});
}
}
};
export default mixin;

161
web/src/router/index.js Normal file
View File

@@ -0,0 +1,161 @@
import Vue from "vue";
import Router from "vue-router";
Vue.use(Router);
export default new Router({
mode: "history",
routes: [
{
path: "/",
redirect: "login"
},
{
path: "/",
component: resolve => require(["@/views/common/Home"], resolve),
meta: { title: "首页" },
children: [
{
path: "/dashboard",
component: resolve => require(["@/views/page/Dashboard"], resolve),
name: "Dashboard",
meta: { title: "Dashboard", icon: "dashboard" }
},
{
path: "/user",
component: resolve => require(["@/views/page/User"], resolve),
name: "User",
meta: {
title: "用户管理",
icon: "team",
breadcrumb: [{ breadcrumbName: "用户管理" }],
isAdmin: 1
}
},
{
path: "/device",
component: resolve => require(["@/views/page/Device"], resolve),
name: "Device",
meta: {
title: "设备管理",
icon: "robot",
breadcrumb: [{ breadcrumbName: "设备管理" }]
}
},
{
path: "/message",
component: resolve => require(["@/views/page/Message"], resolve),
name: "message",
meta: { title: "对话管理", icon: "message" }
},
{
path: "/role",
component: resolve => require(["@/views/page/Role"], resolve),
name: "role",
meta: { title: "角色配置", icon: "user-add" }
},
{
path: '/prompt-template',
name: 'PromptTemplate',
component: resolve => require(['@/views/page/PromptTemplate'], resolve),
meta: {
title: '提示词模板管理',
icon: 'snippets',
isAdmin: true,
parent: '角色管理',
hideInMenu: true
}
},
{
path: "/config",
component: resolve => require(["@/views/common/PageView"], resolve),
name: "Config",
redirect: "/config/model",
meta: { title: "配置管理", icon: "setting", isAdmin: 1 },
children: [
{
path: "/config/model",
component: resolve => require(["@/views/page/config/ModelConfig"], resolve),
meta: { title: "模型配置", parent: "配置管理", isAdmin: 1 },
},
{
path: "/config/agent",
component: resolve => require(["@/views/page/config/Agent"], resolve),
name: "Agent",
meta: {
title: "智能体管理",
parent: "配置管理",
isAdmin: 1
},
},
{
path: "/config/stt",
component: resolve => require(["@/views/page/config/SttConfig"], resolve),
meta: { title: "语音识别配置", parent: "配置管理", isAdmin: 1 },
},
{
path: "/config/tts",
component: resolve => require(["@/views/page/config/TtsConfig"], resolve),
meta: { title: "语音合成配置", parent: "配置管理", isAdmin: 1 },
},
]
},
{
path: "/setting",
component: resolve => require(["@/views/common/PageView"], resolve),
name: "Setting",
redirect: "/setting/account",
meta: { title: "设置", icon: "setting" },
children: [
{
path: "/setting/account",
component: resolve =>
require(["@/views/page/setting/Account"], resolve),
meta: { title: "个人中心", parent: "设置" }
},
{
path: "/setting/config",
component: resolve =>
require(["@/views/page/setting/Config"], resolve),
meta: { title: "个人设置", parent: "设置" }
}
]
},
{
path: "/chat",
component: resolve => require(["@/views/page/Chat"], resolve),
name: "Chat",
meta: {
title: "聊天",
icon: "message",
breadcrumb: [{ breadcrumbName: "聊天" }]
}
},
]
},
{
path: "/login",
component: resolve => require(["@/views/page/Login"], resolve)
},
{
path: "/forget",
component: resolve => require(["@/views/page/Forget"], resolve)
},
{
path: "/register",
component: resolve => require(["@/views/page/Register"], resolve)
},
{
path: "/404",
component: resolve => require(["@/views/exception/404.vue"], resolve)
},
{
path: "/403",
component: resolve => require(["@/views/exception/403.vue"], resolve)
},
{
path: "/*",
redirect: "/404"
}
]
});

49
web/src/services/api.js Normal file
View File

@@ -0,0 +1,49 @@
export default {
user: {
add: "/api/user/add",
login: "/api/user/login",
query: "/api/user/query",
queryUsers: "/api/user/queryUsers",
update: "/api/user/update",
sendEmailCaptcha: "/api/user/sendEmailCaptcha",
checkCaptcha: "/api/user/checkCaptcha",
checkUser: "/api/user/checkUser",
},
device: {
add: "/api/device/add",
query: "/api/device/query",
update: "/api/device/update",
delete: "/api/device/delete",
export: "/api/device/export"
},
agent: {
add: "/api/agent/add",
query: "/api/agent/query",
update: "/api/agent/update",
delete: "/api/agent/delete"
},
role: {
add: "/api/role/add",
query: "/api/role/query",
update: "/api/role/update",
testVoice: "/api/role/testVoice"
},
template: {
query: "/api/template/query",
add: "/api/template/add",
update: "/api/template/update",
},
message: {
query: "/api/message/query",
update: "/api/message/update",
delete: "/api/message/delete",
export: "/api/message/export"
},
config: {
add: "/api/config/add",
query: "/api/config/query",
update: "/api/config/update",
getModels: "/api/config/getModels"
},
upload: "/api/file/upload"
};

File diff suppressed because it is too large Load Diff

136
web/src/services/axios.js Normal file
View File

@@ -0,0 +1,136 @@
import axios from "axios";
const qs = window.Qs;
import { message } from "ant-design-vue";
// 设置axios的基础URL根据环境变量
axios.defaults.baseURL = process.env.BASE_API;
// 设置携带凭证
axios.defaults.withCredentials = true;
// 创建一个工具函数用于处理静态资源URL
export const getResourceUrl = (path) => {
if (!path) return '';
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
// 确保URL以/开头
if (!path.startsWith('/')) {
path = '/' + path;
}
// 开发环境下,需要使用完整的后端地址
if (process.env.NODE_ENV === 'development') {
// 开发环境下,我们需要指定后端地址
// 如果BASE_API为空则使用默认的localhost:8091
const backendUrl = process.env.BACKEND_URL || 'http://localhost:8091';
// 移除开头的斜杠因为我们要将完整的URL传给组件
if (path.startsWith('/')) {
path = path.substring(1);
}
// 构建完整的URL
return `${backendUrl}/${path}`;
}
// 生产环境下直接使用相对路径由Nginx代理处理
return path;
};
function Rest() {}
Rest.prototype = {
jsonPost(opts) {
return new Promise((resolve, reject) => {
axios({
method: "post",
url: opts.url,
headers: {
"Content-Type": "application/json;charset=UTF-8"
},
transformRequest: [
function(data, headers) {
return JSON.stringify(opts.data);
}
]
})
.then(res => {
commonResponse(res.data, resolve);
})
.catch(e => {
rejectResponse(e, reject);
});
});
},
post(opts) {
return new Promise((resolve, reject) => {
axios({
method: "post",
url: opts.url,
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
},
transformRequest: [
function(data, headers) {
return qs.stringify(opts.data);
}
]
})
.then(res => {
commonResponse(res.data, resolve);
})
.catch(e => {
rejectResponse(e, reject);
});
});
},
get(opts) {
return new Promise((resolve, reject) => {
axios(opts.url, {
params: opts.data
})
.then(res => {
commonResponse(res.data, resolve);
})
.catch(e => {
rejectResponse(e, reject);
});
});
}
};
function commonResponse(data, resolve) {
if (data.code === 403) {
const key = "error";
message.error({
content: "登录过期,请重新登录!",
type: "error",
key,
onClose: () => {
// 使用环境变量中的BASE_URL而不是硬编码的URL
window.location.href = process.env.BASE_URL;
}
});
} else {
resolve(data);
}
}
function rejectResponse(e, reject) {
if (e.response && (e.response.status === 401 || e.response.status === 403)) {
const key = "error";
message.error({
content: "登录过期,请重新登录!",
type: "error",
key,
onClose: () => {
// 使用环境变量中的BASE_URL而不是硬编码的URL
window.location.href = process.env.BASE_URL;
}
});
} else {
// 修复这里应该是reject而不是resolve并且data未定义
reject(e);
}
}
export default new Rest();

View File

@@ -0,0 +1,62 @@
// 调试服务模块
// 日志最大条数
const MAX_LOGS = 1000;
// 日志存储
let logs = [];
// 添加日志
function log(message, type = 'info') {
// 创建日志条目
const entry = {
message,
type,
time: new Date()
};
// 添加到日志数组
logs.push(entry);
// 如果日志超过最大条数,删除最旧的
if (logs.length > MAX_LOGS) {
logs = logs.slice(-MAX_LOGS);
}
// 控制台输出
switch (type) {
case 'error':
console.error(`[XiaoZhi] ${message}`);
break;
case 'warning':
console.warn(`[XiaoZhi] ${message}`);
break;
case 'success':
console.log(`%c[XiaoZhi] ${message}`, 'color: green');
break;
case 'debug':
console.debug(`[XiaoZhi] ${message}`);
break;
default:
console.log(`[XiaoZhi] ${message}`);
}
return entry;
}
// 获取所有日志
function getLogs() {
return [...logs];
}
// 清空日志
function clearLogs() {
logs = [];
return [];
}
export {
log,
getLogs,
clearLogs
};

View File

@@ -0,0 +1,545 @@
// websocketService.js - 统一的WebSocket、消息和日志管理服务
// 日志相关
// =============================
const LOG_LEVELS = {
debug: 0,
info: 1,
success: 2,
warning: 3,
error: 4
};
let currentLogLevel = LOG_LEVELS.debug;
let logHistory = [];
const MAX_LOG_HISTORY = 500;
// 记录日志
export function log(message, type = 'info') {
if (LOG_LEVELS[type] < currentLogLevel) return;
const entry = {
message,
type,
time: new Date()
};
logHistory.push(entry);
if (logHistory.length > MAX_LOG_HISTORY) {
logHistory = logHistory.slice(-MAX_LOG_HISTORY);
}
switch (type) {
case 'error': console.error(message); break;
case 'warning': console.warn(message); break;
case 'success': console.log('%c' + message, 'color: green'); break;
case 'debug': console.debug(message); break;
default: console.log(message);
}
return entry;
}
// 获取日志历史
export function getLogs() {
return [...logHistory];
}
// 清除日志历史
export function clearLogs() {
logHistory = [];
return true;
}
// 设置日志级别
export function setLogLevel(level) {
if (LOG_LEVELS[level] !== undefined) {
currentLogLevel = LOG_LEVELS[level];
return true;
}
return false;
}
// 消息管理相关
// =============================
export const messages = [];
// 添加消息
export function addMessage(message) {
if (!message.content) return null;
const newMessage = {
id: message.id || `msg_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
content: message.content,
type: message.type || 'stt',
isUser: !!message.isUser,
timestamp: message.timestamp || new Date(),
isLoading: !!message.isLoading
};
messages.push(newMessage);
log(`添加${newMessage.isUser ? '用户' : 'AI'}消息: ${newMessage.content.substring(0, 50)}${newMessage.content.length > 50 ? '...' : ''}`, 'debug');
return newMessage;
}
// 更新消息
export function updateMessage(id, updates) {
const index = messages.findIndex(msg => msg.id === id);
if (index === -1) {
log(`未找到要更新的消息: ${id}`, 'warning');
return null;
}
const updatedMessage = {
...messages[index],
...updates
};
messages[index] = updatedMessage;
log(`更新消息 ${id}: ${JSON.stringify(updates)}`, 'debug');
return updatedMessage;
}
// 添加语音转文本消息
export function addSTTMessage(content) {
return addMessage({
content,
type: 'stt',
isUser: true
});
}
// 添加文本转语音消息
export function addTTSMessage(content) {
return addMessage({
content,
type: 'tts',
isUser: false
});
}
// 添加音频消息
export function addAudioMessage(options) {
const message = {
content: options.content || '语音消息',
type: 'audio',
isUser: !!options.isUser,
duration: options.duration || '0:00',
audioData: options.audioData
};
return addMessage(message);
}
// 删除消息
export function deleteMessage(id) {
const index = messages.findIndex(msg => msg.id === id);
if (index === -1) return false;
messages.splice(index, 1);
log(`删除消息: ${id}`, 'debug');
return true;
}
// 清空所有消息
export function clearMessages() {
messages.length = 0;
log('清空所有消息', 'info');
return true;
}
// WebSocket连接相关
// =============================
let webSocket = null;
let isConnecting = false;
let messageHandlers = [];
let reconnectTimer = null;
let reconnectAttempts = 0;
let maxReconnectAttempts = 5;
let reconnectDelay = 2000; // 初始重连延迟2秒
// 导出的状态
export let connectionStatus = '未连接';
export let connectionTime = null;
export let sessionId = null;
export let isConnected = false;
// 注册消息处理函数
export function registerMessageHandler(handler) {
if (typeof handler === 'function' && !messageHandlers.includes(handler)) {
messageHandlers.push(handler);
return true;
}
return false;
}
// 移除消息处理函数
export function unregisterMessageHandler(handler) {
const index = messageHandlers.indexOf(handler);
if (index !== -1) {
messageHandlers.splice(index, 1);
return true;
}
return false;
}
// 连接到服务器
export async function connectToServer(config) {
if (webSocket && webSocket.readyState === WebSocket.OPEN) {
log('WebSocket已连接', 'info');
return true;
}
if (isConnecting) {
log('WebSocket正在连接中...', 'info');
return false;
}
try {
isConnecting = true;
connectionStatus = '正在连接...';
// 清除之前的重连计时器
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
// 关闭现有连接
if (webSocket) {
try {
webSocket.close();
} catch (e) {
// 忽略关闭错误
}
}
// 构建连接URL
let url = config.url;
if (!url.endsWith('/')) {
url += '/';
}
// 添加设备ID和名称作为查询参数
const params = new URLSearchParams();
if (config.deviceId) {
params.append('device-id', config.deviceId);
}
if (config.macAddress) {
params.append('mac_address', config.deviceName);
}
if (config.token) {
params.append('token', config.token);
}
const queryString = params.toString();
if (queryString) {
url += '?' + queryString;
}
log(`正在连接到: ${url}`, 'info');
// 创建WebSocket连接
webSocket = new WebSocket(url);
// 设置二进制类型为ArrayBuffer
webSocket.binaryType = 'arraybuffer';
// 连接打开事件
webSocket.onopen = () => {
isConnecting = false;
isConnected = true;
connectionStatus = '已连接';
connectionTime = new Date();
reconnectAttempts = 0;
log('WebSocket连接已建立', 'success');
};
// 接收消息事件
webSocket.onmessage = (event) => {
handleWebSocketMessage(event);
};
// 连接关闭事件
webSocket.onclose = (event) => {
isConnecting = false;
isConnected = false;
if (event.wasClean) {
connectionStatus = '已断开';
log(`WebSocket连接已关闭: 代码=${event.code}, 原因=${event.reason}`, 'info');
} else {
connectionStatus = '连接已断开';
log('WebSocket连接意外断开', 'error');
// 尝试重新连接
scheduleReconnect(config);
}
};
// 连接错误事件
webSocket.onerror = (error) => {
isConnecting = false;
isConnected = false;
connectionStatus = '连接错误';
log('WebSocket连接错误', 'error');
// 错误时不立即重连让onclose处理
};
// 等待连接完成或超时
return new Promise((resolve) => {
// 连接超时处理
const timeoutId = setTimeout(() => {
if (!isConnected) {
log('WebSocket连接超时', 'error');
isConnecting = false;
connectionStatus = '连接超时';
try {
webSocket.close();
} catch (e) {
// 忽略关闭错误
}
resolve(false);
}
}, 5000); // 5秒超时
// 监听连接状态变化
const checkConnected = () => {
if (isConnected) {
clearTimeout(timeoutId);
resolve(true);
} else if (connectionStatus.includes('错误') || connectionStatus.includes('超时')) {
clearTimeout(timeoutId);
resolve(false);
} else {
setTimeout(checkConnected, 100);
}
};
checkConnected();
});
} catch (error) {
isConnecting = false;
isConnected = false;
connectionStatus = '连接失败';
log(`WebSocket连接失败: ${error.message}`, 'error');
return false;
}
}
// 安排重新连接
function scheduleReconnect(config) {
if (reconnectAttempts >= maxReconnectAttempts) {
log(`已达到最大重连次数(${maxReconnectAttempts}),停止重连`, 'warning');
connectionStatus = '重连失败';
return;
}
// 使用指数退避策略
const delay = reconnectDelay * Math.pow(1.5, reconnectAttempts);
log(`计划在${delay / 1000}秒后重新连接(尝试${reconnectAttempts + 1}/${maxReconnectAttempts})`, 'info');
connectionStatus = `${reconnectAttempts + 1}秒后重连...`;
reconnectTimer = setTimeout(() => {
reconnectAttempts++;
connectToServer(config);
}, delay);
}
// 处理WebSocket消息
function handleWebSocketMessage(event) {
try {
// 检查是否是二进制数据
if (event.data instanceof ArrayBuffer || event.data instanceof Blob) {
// 处理二进制音频数据
// 这里我们需要从audioService导入handleBinaryMessage
// 但为了避免循环依赖我们在Chat.vue中处理这个问题
if (typeof window.handleBinaryAudioMessage === 'function') {
window.handleBinaryAudioMessage(event.data);
} else {
log('未找到二进制音频处理函数', 'warning');
}
return;
}
// 处理文本数据
const data = JSON.parse(event.data);
// 记录会话ID
if (data.session_id && !sessionId) {
sessionId = data.session_id;
log(`会话ID: ${sessionId}`, 'info');
}
// 根据消息类型处理
switch (data.type) {
case 'stt':
handleSTTMessage(data);
break;
case 'tts':
handleTTSMessage(data);
break;
default:
log(`收到未知类型的消息: ${data.type}`, 'warning');
}
// 调用所有注册的消息处理函数
for (const handler of messageHandlers) {
try {
handler(data);
} catch (error) {
log(`消息处理函数执行错误: ${error.message}`, 'error');
}
}
} catch (error) {
log(`处理WebSocket消息出错: ${error.message}`, 'error');
}
}
// 处理STT消息
function handleSTTMessage(data) {
// 添加语音转文本消息
addSTTMessage(data.text);
log(`语音识别结果: ${data.text}`, 'info');
}
// 处理TTS消息
function handleTTSMessage(data) {
if (data.state === 'start') {
log('TTS开始', 'info');
} else if (data.state === 'sentence_start' && data.text) {
// 添加TTS消息
addTTSMessage(data.text);
log(`TTS文本: ${data.text}`, 'info');
} else if (data.state === 'stop') {
log('TTS结束', 'info');
}
}
// 发送JSON消息
function sendJsonMessage(data) {
if (!webSocket || webSocket.readyState !== WebSocket.OPEN) {
log('WebSocket未连接无法发送消息', 'error');
return false;
}
try {
const message = JSON.stringify(data);
webSocket.send(message);
return true;
} catch (error) {
log(`发送JSON消息失败: ${error.message}`, 'error');
return false;
}
}
// 发送文本消息
export function sendTextMessage(text) {
if (!text || !webSocket || webSocket.readyState !== WebSocket.OPEN) {
return false;
}
try {
const message = {
type: 'listen',
state: 'text',
text: text
};
return sendJsonMessage(message);
} catch (error) {
log(`发送文本消息失败: ${error.message}`, 'error');
return false;
}
}
// 开始直接录音
export async function startDirectRecording() {
if (!webSocket || webSocket.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket未连接');
}
try {
// 发送开始录音命令
const startMessage = {
type: 'stt',
state: 'start'
};
sendJsonMessage(startMessage);
log('已发送开始录音命令', 'info');
return true;
} catch (error) {
log(`开始录音失败: ${error.message}`, 'error');
throw error;
}
}
// 停止直接录音
export async function stopDirectRecording() {
if (!webSocket || webSocket.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket未连接');
}
try {
// 发送停止录音命令
const stopMessage = {
type: 'stt',
state: 'stop'
};
sendJsonMessage(stopMessage);
log('已发送停止录音命令', 'info');
return true;
} catch (error) {
log(`停止录音失败: ${error.message}`, 'error');
throw error;
}
}
// 断开连接
export function disconnectFromServer() {
// 清除重连计时器
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (!webSocket) {
return true;
}
try {
if (webSocket.readyState === WebSocket.OPEN) {
webSocket.close(1000, '用户主动断开');
}
webSocket = null;
isConnected = false;
connectionStatus = '已断开';
log('WebSocket连接已断开', 'info');
return true;
} catch (error) {
log(`断开WebSocket连接失败: ${error.message}`, 'error');
return false;
}
}
// 检查WebSocket是否已连接
export function isWebSocketConnected() {
return webSocket && webSocket.readyState === WebSocket.OPEN;
}

28
web/src/store/index.js Normal file
View File

@@ -0,0 +1,28 @@
import Vue from 'vue'
import Vuex from 'vuex'
import Cookies from 'js-cookie'
Vue.use(Vuex)
const store = new Vuex.Store({
state: {
info: Cookies.getJSON('userInfo'),
isMobile: true
},
getters: {
USER_INFO: state => {
return state.info
},
MOBILE_TYPE: state => {
return state.isMobile
}
},
mutations: {
USER_INFO: (state, info) => {
state.info = info
},
MOBILE_TYPE: (state, isMobile) => {
state.isMobile = isMobile
}
}
})
export default store

View File

@@ -0,0 +1,3 @@
import Vue from 'vue'
const eventBus = new Vue()
export default eventBus

View File

@@ -0,0 +1,208 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory()
: typeof define === 'function' && define.amd ? define(factory)
: (global.infiniteScroll = factory())
}(this, function () {
'use strict'
var throttle = function throttle (fn, delay) {
var now, lastExec, timer, context, args; //eslint-disable-line
var execute = function execute () {
fn.apply(context, args)
lastExec = now
}
return function () {
context = this
args = arguments
now = Date.now()
if (timer) {
clearTimeout(timer)
timer = null
}
if (lastExec) {
var diff = delay - (now - lastExec)
if (diff < 0) {
execute()
} else {
timer = setTimeout(function () {
execute()
}, diff)
}
} else {
execute()
}
}
}
var getComputedStyle = document.defaultView.getComputedStyle
var getClientHeight = function getClientHeight (element) {
var currentNode = element
// bugfix, see http://w3help.org/zh-cn/causes/SD9013 and http://stackoverflow.com/questions/17016740/onscroll-function-is-not-working-for-chrome
while (currentNode && currentNode.tagName !== 'HTML' && currentNode.tagName !== 'BODY' && currentNode.nodeType === 1) {
var overflowY = getComputedStyle(currentNode).overflowY
if (overflowY === 'scroll' || overflowY === 'auto') {
return currentNode
}
currentNode = currentNode.parentNode
}
return window
}
var getVisibleHeight = function getVisibleHeight (element) {
if (element === window) {
return document.documentElement.clientHeight
}
return element.clientHeight
}
var getElementTop = function getElementTop (element) {
if (element === window) {
return getScrollTop(window)
}
return element + getScrollTop(window)
}
let getScrollTop = function getScrollTop (element) {
if (element === window) {
return Math.max(
window.pageYOffset || 0,
document.documentElement.scrollTop
)
}
return element.scrollTop
}
var isAttached = function isAttached (element) {
var currentNode = element.parentNode
while (currentNode) {
if (currentNode.tagName === 'HTML') {
return true
}
if (currentNode.nodeType === 11) {
return false
}
currentNode = currentNode.parentNode
}
return false
}
var doCheck = function doCheck () {
console.log('doCheck')
var scrollEventTarget = this.scrollEventTarget
var element = this.el
var distance = this.distance
var viewportScrollTop = getScrollTop(scrollEventTarget)
var viewportBottom = viewportScrollTop + getVisibleHeight(scrollEventTarget)
var shouldTrigger = false
if (scrollEventTarget === element) {
shouldTrigger = scrollEventTarget.scrollHeight - viewportBottom <= distance
} else {
var elementBottom =
getElementTop(element) -
getElementTop(scrollEventTarget) +
element.offsetHeight +
viewportScrollTop
shouldTrigger = viewportBottom + distance >= elementBottom
}
if (shouldTrigger && this.expression) {
this.expression()
}
}
var doBind = function doBind () {
console.log('doBind')
if (this.binded) return; // eslint-disable-line
this.binded = true
var directive = this
var element = directive.el
var distanceExpr = element.getAttribute('infinite-scroll-distance')
var distance = 0
if (distanceExpr) {
distance = Number(directive.vm[distanceExpr] || distanceExpr)
if (isNaN(distance)) {
distance = 0
}
}
directive.scrollEventTarget = getClientHeight(element)
directive.scrollListener = throttle(doCheck.bind(directive), directive.throttleDelay)
directive.scrollEventTarget.addEventListener('scroll', directive.scrollListener)
this.vm.$on('hook:beforeDestroy', function () {
directive.scrollEventTarget.removeEventListener('scroll', directive.scrollListener)
})
var disabledExpr = element.getAttribute('infinite-scroll-disabled')
var disabled = false
console.log('disabledExpr:', disabledExpr)
if (disabledExpr) {
this.vm.$watch(disabledExpr, function (value) {
directive.disabled = value
if (!value) {
console.log('doCheck', 'watch')
doCheck.call(directive)
}
})
disabled = Boolean(directive.vm[disabledExpr])
}
directive.disabled = disabled
directive.distance = distance
// doCheck.call(directive)
}
var InfiniteScroll = {
bind: function (el, binding, vNode) {
el['infinite'] = {
el: el,
vm: vNode.context,
expression: binding.value
}
var args = arguments
// el['infinite'].vm.$on('hook:mounted', () => {
el['infinite'].vm.$nextTick(() => {
if (isAttached(el)) {
doBind.call(el['infinite'], args)
}
el['infinite'].bindTryCount = 0
var tryBind = function tryBind () {
if (el['infinite'].bindTryCount > 10) return; //eslint-disable-line
el['infinite'].bindTryCount++
if (isAttached(el)) {
doBind.call(el['infinite'], args)
} else {
setTimeout(tryBind, 50)
}
}
tryBind()
// })
})
}
}
var install = function install (Vue) {
Vue.directive('InfiniteScroll', InfiniteScroll)
}
if (window.Vue) {
window.infiniteScroll = InfiniteScroll
Vue.use(install); // eslint-disable-line
}
InfiniteScroll.install = install
return InfiniteScroll
}))

View File

@@ -0,0 +1,39 @@
import JSEncrypt from "jsencrypt";
// 密钥对生成 http://web.chacuo.net/netrsakeypair
const publicKey =
"MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDQJBHZKLLQKT8tIPwFMMQ8z2gh" +
"bhu6efjO0d/WXo+KdpUxlyzwwQvMo2v62zF4tbjYJykI8ynzwRWGPkoZjFiG7Cxu" +
"MceKE4DKo59bFDNrKhCWrJ3QmyQpk9E7WM0BfogxK8Vfb4DpvvQjn3sZMnSL4O1N" +
"lWzIkZ4MpwVW+5HLyQIDAQAB";
const privateKey =
"MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBANAkEdkostApPy0g" +
"/AUwxDzPaCFuG7p5+M7R39Zej4p2lTGXLPDBC8yja/rbMXi1uNgnKQjzKfPBFYY+" +
"ShmMWIbsLG4xx4oTgMqjn1sUM2sqEJasndCbJCmT0TtYzQF+iDErxV9vgOm+9COf" +
"exkydIvg7U2VbMiRngynBVb7kcvJAgMBAAECgYEAncIJCShwx5fLu5/ZhPGee1zU" +
"1ynGuINEUzX9y1RmxZL1p92mgWBAWj2vVTaX+5742FRuJMtxi8BYWSSTM2QNn/Ar" +
"isXedSqJLwh2OWTb8k+qjY0dEZCovAYjafWpMW7dMMVGDsImPGDZlJ9z44zy606A" +
"qEMt2yU/OBf6wPlabJECQQDn3eYx+/E2hV3P+FH+U8A1Y0Wu1EDlzydz4hiJRl5i" +
"qRrEhTI5YvxdFN+6j1b9aKENiDH412sermSDaNxxjhuNAkEA5c3+M9TBTJlqBmUA" +
"dn5dbjpUuB3KPtocCclRTUU4fm7OPR66mNoDoWL4iq6cIengMQXCp4YvigsidMR2" +
"GzvELQJBAIbMrei/VVPiI1EmR9z5KdSf+0IR6gzw6znm52bffz4SnBpGaZWNY7Rl" +
"z1Axx1wZ+Q/Z71uBOaijsJHpY8es230CQQDT2CyxnTzAn2CFGpDtqxn4Jl+5BwVN" +
"IYXdY6/GOryUmRMYdv5vL/NO0Ezsk4CtJsuchYHnKyUh7ZfK6t0xx8vVAkB7bFzF" +
"Kb5cejiwCL0fTT57KYz/8omSkNkI7qfYfst5VEexLChIE/ZDVRkyBepf466l4RXe" +
"SswvOeKybPJxrNSG";
// 加密
export function encrypt(txt) {
const encryptor = new JSEncrypt();
encryptor.setPublicKey(publicKey); // 设置公钥
return encryptor.encrypt(txt); // 对数据进行加密
}
// 解密
export function decrypt(txt) {
const encryptor = new JSEncrypt();
encryptor.setPrivateKey(privateKey); // 设置私钥
return encryptor.decrypt(txt); // 对数据进行解密
}

67
web/src/utils/util.js Normal file
View File

@@ -0,0 +1,67 @@
export function timeFix () {
const time = new Date()
const hour = time.getHours()
return hour < 9 ? '早上好' : hour <= 11 ? '上午好' : hour <= 13 ? '中午好' : hour < 20 ? '下午好' : '晚上好'
}
export function welcome () {
const arr = ['休息一会儿吧', '准备吃什么呢?', '要不要打一把 LOL', '我猜你可能累了']
const index = Math.floor(Math.random() * arr.length)
return arr[index]
}
/**
* 触发 window.resize
*/
export function triggerWindowResizeEvent () {
const event = document.createEvent('HTMLEvents')
event.initEvent('resize', true, true)
event.eventType = 'message'
window.dispatchEvent(event)
}
export function handleScrollHeader (callback) {
let timer = 0
let beforeScrollTop = window.pageYOffset
callback = callback || function () {}
window.addEventListener(
'scroll',
event => {
clearTimeout(timer)
timer = setTimeout(() => {
let direction = 'up'
const afterScrollTop = window.pageYOffset
const delta = afterScrollTop - beforeScrollTop
if (delta === 0) {
return false
}
direction = delta > 0 ? 'down' : 'up'
callback(direction)
beforeScrollTop = afterScrollTop
}, 50)
},
false
)
}
export function isIE () {
const bw = window.navigator.userAgent
const compare = (s) => bw.indexOf(s) >= 0
const ie11 = (() => 'ActiveXObject' in window)()
return compare('MSIE') || ie11
}
/**
* Remove loading animate
* @param id parent element id or class
* @param timeout
*/
export function removeLoadingAnimate (id = '', timeout = 1500) {
if (id === '') {
return
}
setTimeout(() => {
document.body.removeChild(document.getElementById(id))
}, timeout)
}

View File

@@ -0,0 +1,235 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global.infiniteScroll = factory());
}(this, function () { 'use strict';
var ctx = '@@InfiniteScroll';
var throttle = function throttle(fn, delay) {
var now, lastExec, timer, context, args; //eslint-disable-line
var execute = function execute() {
fn.apply(context, args);
lastExec = now;
};
return function () {
context = this;
args = arguments;
now = Date.now();
if (timer) {
clearTimeout(timer);
timer = null;
}
if (lastExec) {
var diff = delay - (now - lastExec);
if (diff < 0) {
execute();
} else {
timer = setTimeout(function () {
execute();
}, diff);
}
} else {
execute();
}
};
};
var getScrollTop = function getScrollTop(element) {
if (element === window) {
return Math.max(window.pageYOffset || 0, document.documentElement.scrollTop);
}
return element.scrollTop;
};
var getComputedStyle = document.defaultView.getComputedStyle;
var getScrollEventTarget = function getScrollEventTarget(element) {
var currentNode = element;
// bugfix, see http://w3help.org/zh-cn/causes/SD9013 and http://stackoverflow.com/questions/17016740/onscroll-function-is-not-working-for-chrome
while (currentNode && currentNode.tagName !== 'HTML' && currentNode.tagName !== 'BODY' && currentNode.nodeType === 1) {
var overflowY = getComputedStyle(currentNode).overflowY;
if (overflowY === 'scroll' || overflowY === 'auto') {
return currentNode;
}
currentNode = currentNode.parentNode;
}
return window;
};
var getVisibleHeight = function getVisibleHeight(element) {
if (element === window) {
return document.documentElement.clientHeight;
}
return element.clientHeight;
};
var getElementTop = function getElementTop(element) {
if (element === window) {
return getScrollTop(window);
}
return element.getBoundingClientRect().top + getScrollTop(window);
};
var isAttached = function isAttached(element) {
var currentNode = element.parentNode;
while (currentNode) {
if (currentNode.tagName === 'HTML') {
return true;
}
if (currentNode.nodeType === 11) {
return false;
}
currentNode = currentNode.parentNode;
}
return false;
};
var doBind = function doBind() {
if (this.binded) return; // eslint-disable-line
this.binded = true;
var directive = this;
var element = directive.el;
var throttleDelayExpr = element.getAttribute('infinite-scroll-throttle-delay');
var throttleDelay = 200;
if (throttleDelayExpr) {
throttleDelay = Number(directive.vm[throttleDelayExpr] || throttleDelayExpr);
if (isNaN(throttleDelay) || throttleDelay < 0) {
throttleDelay = 200;
}
}
directive.throttleDelay = throttleDelay;
directive.scrollEventTarget = getScrollEventTarget(element);
directive.scrollListener = throttle(doCheck.bind(directive), directive.throttleDelay);
directive.scrollEventTarget.addEventListener('scroll', directive.scrollListener);
this.vm.$on('hook:beforeDestroy', function () {
directive.scrollEventTarget.removeEventListener('scroll', directive.scrollListener);
});
var disabledExpr = element.getAttribute('infinite-scroll-disabled');
var disabled = false;
if (disabledExpr) {
this.vm.$watch(disabledExpr, function (value) {
directive.disabled = value;
if (!value && directive.immediateCheck) {
doCheck.call(directive);
}
});
disabled = Boolean(directive.vm[disabledExpr]);
}
directive.disabled = disabled;
var distanceExpr = element.getAttribute('infinite-scroll-distance');
var distance = 0;
if (distanceExpr) {
distance = Number(directive.vm[distanceExpr] || distanceExpr);
if (isNaN(distance)) {
distance = 0;
}
}
directive.distance = distance;
var immediateCheckExpr = element.getAttribute('infinite-scroll-immediate-check');
var immediateCheck = true;
if (immediateCheckExpr) {
immediateCheck = Boolean(directive.vm[immediateCheckExpr]);
}
directive.immediateCheck = immediateCheck;
if (immediateCheck) {
doCheck.call(directive);
}
var eventName = element.getAttribute('infinite-scroll-listen-for-event');
if (eventName) {
directive.vm.$on(eventName, function () {
doCheck.call(directive);
});
}
};
var doCheck = function doCheck(force) {
var scrollEventTarget = this.scrollEventTarget;
var element = this.el;
var distance = this.distance;
if (force !== true && this.disabled) return; //eslint-disable-line
var viewportScrollTop = getScrollTop(scrollEventTarget);
var viewportBottom = viewportScrollTop + getVisibleHeight(scrollEventTarget);
var shouldTrigger = false;
if (scrollEventTarget === element) {
shouldTrigger = scrollEventTarget.scrollHeight - viewportBottom <= distance;
} else {
var elementBottom = getElementTop(element) - getElementTop(scrollEventTarget) + element.offsetHeight + viewportScrollTop;
shouldTrigger = viewportBottom + distance >= elementBottom;
}
if (shouldTrigger && this.expression) {
this.expression();
}
};
var InfiniteScroll = {
bind: function bind(el, binding, vnode) {
el[ctx] = {
el: el,
vm: vnode.context,
expression: binding.value
};
var args = arguments;
// el[ctx].vm.$on('hook:mounted', function () {
el[ctx].vm.$nextTick(function () {
if (isAttached(el)) {
doBind.call(el[ctx], args);
}
el[ctx].bindTryCount = 0;
var tryBind = function tryBind() {
if (el[ctx].bindTryCount > 10) return; //eslint-disable-line
el[ctx].bindTryCount++;
if (isAttached(el)) {
doBind.call(el[ctx], args);
} else {
setTimeout(tryBind, 50);
}
};
tryBind();
});
// });
},
unbind: function unbind(el) {
if (el && el[ctx] && el[ctx].scrollEventTarget) el[ctx].scrollEventTarget.removeEventListener('scroll', el[ctx].scrollListener);
}
};
var install = function install(Vue) {
Vue.directive('InfiniteScroll', InfiniteScroll);
};
if (window.Vue) {
window.infiniteScroll = InfiniteScroll;
Vue.use(install); // eslint-disable-line
}
InfiniteScroll.install = install;
return InfiniteScroll;
}));

View File

@@ -0,0 +1,30 @@
<template>
<a-layout-header class="breadcrumb-header">
<a-breadcrumb>
<a-breadcrumb-item>
<router-link to="/dashboard">首页</router-link>
</a-breadcrumb-item>
<a-breadcrumb-item v-if="$route.meta.parent">
{{ $route.meta.parent }}
</a-breadcrumb-item>
<a-breadcrumb-item v-if="$route.meta.icon">
{{ $route.meta.title }}
</a-breadcrumb-item>
<a-breadcrumb-item v-else>
{{ $route.meta.title}}
</a-breadcrumb-item>
</a-breadcrumb>
<a-page-header
:title="$route.meta.title"
@back="() => $router.go(-1)"
style="padding: 0"
>
</a-page-header>
</a-layout-header>
</template>
<script>
export default {
}
</script>

View File

@@ -0,0 +1,37 @@
<template>
<div class="footer">
<div class="footer-links">
<a title="github" target="_black" href="https://github.com/joey-zhou" style="margin-right: 40px">
<a-icon type="github" />
</a>
<a title="gitee" target="_black" href="https://gitee.com/joey-zhou" style="margin-right: 40px">
<i class="anticon"><svg t="1597300872526" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="779" width="1em" height="1em"><path d="M512 1024C230.4 1024 0 793.6 0 512S230.4 0 512 0s512 230.4 512 512-230.4 512-512 512z m259.2-569.6H480c-12.8 0-25.6 12.8-25.6 25.6v64c0 12.8 12.8 25.6 25.6 25.6h176c12.8 0 25.6 12.8 25.6 25.6v12.8c0 41.6-35.2 76.8-76.8 76.8h-240c-12.8 0-25.6-12.8-25.6-25.6V416c0-41.6 35.2-76.8 76.8-76.8h355.2c12.8 0 25.6-12.8 25.6-25.6v-64c0-12.8-12.8-25.6-25.6-25.6H416c-105.6 0-188.8 86.4-188.8 188.8V768c0 12.8 12.8 25.6 25.6 25.6h374.4c92.8 0 169.6-76.8 169.6-169.6v-144c0-12.8-12.8-25.6-25.6-25.6z" fill="#888888" p-id="780"></path></svg></i>
</a>
<a title="qq" target="_blank" href="http://wpa.qq.com/msgrd?v=3&uin=1277676045&site=qq&menu=yes" style="margin-right: 40px">
<a-icon type="qq" />
</a>
<a-popover placement="top">
<i class="anticon"><svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 1024 1024" width="1em" height="1em"><path d="M690.1 377.4c5.9 0 11.8.2 17.6.5-24.4-128.7-158.3-227.1-319.9-227.1C209 150.8 64 271.4 64 420.2c0 81.1 43.6 154.2 111.9 203.6a21.5 21.5 0 0 1 9.1 17.6c0 2.4-.5 4.6-1.1 6.9-5.5 20.3-14.2 52.8-14.6 54.3-.7 2.6-1.7 5.2-1.7 7.9 0 5.9 4.8 10.8 10.8 10.8 2.3 0 4.2-.9 6.2-2l70.9-40.9c5.3-3.1 11-5 17.2-5 3.2 0 6.4.5 9.5 1.4 33.1 9.5 68.8 14.8 105.7 14.8 6 0 11.9-.1 17.8-.4-7.1-21-10.9-43.1-10.9-66 0-135.8 132.2-245.8 295.3-245.8zm-194.3-86.5c23.8 0 43.2 19.3 43.2 43.1s-19.3 43.1-43.2 43.1c-23.8 0-43.2-19.3-43.2-43.1s19.4-43.1 43.2-43.1zm-215.9 86.2c-23.8 0-43.2-19.3-43.2-43.1s19.3-43.1 43.2-43.1 43.2 19.3 43.2 43.1-19.4 43.1-43.2 43.1zm586.8 415.6c56.9-41.2 93.2-102 93.2-169.7 0-124-120.8-224.5-269.9-224.5-149 0-269.9 100.5-269.9 224.5S540.9 847.5 690 847.5c30.8 0 60.6-4.4 88.1-12.3 2.6-.8 5.2-1.2 7.9-1.2 5.2 0 9.9 1.6 14.3 4.1l59.1 34c1.7 1 3.3 1.7 5.2 1.7a9 9 0 0 0 6.4-2.6 9 9 0 0 0 2.6-6.4c0-2.2-.9-4.4-1.4-6.6-.3-1.2-7.6-28.3-12.2-45.3-.5-1.9-.9-3.8-.9-5.7.1-5.9 3.1-11.2 7.6-14.5zM600.2 587.2c-19.9 0-36-16.1-36-35.9 0-19.8 16.1-35.9 36-35.9s36 16.1 36 35.9c0 19.8-16.2 35.9-36 35.9zm179.9 0c-19.9 0-36-16.1-36-35.9 0-19.8 16.1-35.9 36-35.9s36 16.1 36 35.9a36.08 36.08 0 0 1-36 35.9z" fill="#888888"/></svg></i>
<template slot="content" >
<img src="../../../static/img/wechat.jpg" alt="wechat" style="max-width: 100px;">
</template>
</a-popover>
<!-- <a title="Ant Design" target="_black" href="https://www.antdv.com/">Ant Design</a> -->
</div>
<div class="footer-copyright">
Copyright
<a-icon type="copyright" />
{{ new Date().getFullYear()}} By JoeyZhou. All rights reserved
</div>
</div>
</template>
<style lang="scss" scoped>
.footer {
text-align: center;
}
.footer a, .footer-copyright, .footer-beian a {
color: rgba(0,0,0,.45);
transition: all 0.3s;
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<div id="header">
<div style="flex: 1 1 0%"></div>
<div class="header-index-right">
<a-dropdown class="header-index-account">
<div>
<a-avatar
:src="getAvatarUrl(user.avatar)"
size="small"
style="margin-right: 8px"
icon="user"
/>
<span>{{ user.name }}</span>
</div>
<a-menu slot="overlay">
<a-menu-item>
<span class="header-index-dropdown">
<a-icon type="user" />
<a @click="$router.push('/setting/account')">个人中心</a>
</span>
</a-menu-item>
<a-menu-item>
<span class="header-index-dropdown">
<a-icon type="setting" />
<a @click="$router.push('/setting/config')">个人设置</a>
</span>
</a-menu-item>
<a-menu-divider />
<a-menu-item>
<span class="header-index-logout header-index-dropdown">
<a-icon type="logout" />
<a href="javascript:;" @click="logout">退出登录</a>
</span>
</a-menu-item>
</a-menu>
</a-dropdown>
</div>
</div>
</template>
<script>
import Cookies from 'js-cookie'
import { getResourceUrl } from '@/services/axios'
export default {
data () {
return {
user: {}
}
},
computed: {
userInfo () {
return this.$store.getters.USER_INFO
}
},
created () {
this.user = this.userInfo
},
methods: {
logout () {
Cookies.remove('userInfo')
this.$router.push('/login')
},
getAvatarUrl(avatar) {
return getResourceUrl(avatar);
}
}
}
</script>
<style scoped lang="scss">
#header {
background: #fff;
box-shadow: 0 2px 8px #f0f1f2;
position: relative;
display: flex;
z-index: 10;
max-width: 100%;
line-height: 48px;
padding: 0 40px;
}
#header > * {
height: 100%;
}
.header-index-right {
cursor: pointer;
display: flex;
float: right;
height: 48px;
margin-left: auto;
overflow: hidden;
.header-index-account {
padding: 0 12px;
}
}
.header-index-dropdown a {
color: rgba(0, 0, 0, 0.45);
}
</style>

View File

@@ -0,0 +1,126 @@
<template>
<div class="wrapper">
<!-- <a-layout v-if="!!userInfo"> -->
<a-layout>
<!-- 占位 -->
<div :style="`width: ${width}px; overflow: hidden; flex: 0 0 ${width}px; max-width: ${width}px; min-width: ${width}px;`"></div>
<!-- 侧边栏 -->
<a-layout-sider
breakpoint="sm"
collapsed-width="0"
@breakpoint="onBreakpoint($event, 'sm')"
style="display: none"
>
</a-layout-sider>
<a-layout-sider
theme="light"
breakpoint="lg"
:collapsed-width="collapseWidth"
@breakpoint="onBreakpoint($event, 'lg')"
class="fixed-sidebar"
:zeroWidthTriggerStyle="{ top: '100px' }"
>
<v-sidebar></v-sidebar>
</a-layout-sider>
<a-layout>
<!-- 页眉 -->
<a-layout-header style="padding: 0; height: auto; line-height: auto">
<v-header></v-header>
</a-layout-header>
<a-layout>
<!-- 主要内容 -->
<a-layout-content>
<v-breadcrumb></v-breadcrumb>
<router-view />
</a-layout-content>
<!-- 页脚 -->
<a-layout-footer>
<v-footer></v-footer>
</a-layout-footer>
</a-layout>
</a-layout>
</a-layout>
</div>
</template>
<script>
import vSidebar from './Sidebar.vue'
import vHeader from './Header.vue'
import vFooter from './Footer.vue'
import vBreadcrumb from './Breadcrumb.vue'
export default {
components: {
vHeader,
vSidebar,
vFooter,
vBreadcrumb
},
data () {
return {
// 占位宽度
width: 0,
// 折叠宽度
collapseWidth: 0,
// 判断是否为手机
clientWidth: document.body.clientWidth
}
},
mounted () {
window.onresize = () => {
return (() => {
this.clientWidth = document.body.clientWidth
})()
}
// 没有登录过或者已退出登录的情况下直接访问页面会跳转到登录页面
if (!this.userInfo) this.$router.push('/login')
},
computed: {
isMobile () {
return this.$store.getters.MOBILE_TYPE
},
userInfo () {
return this.$store.getters.USER_INFO
}
},
watch: {
clientWidth (newVal, oldVal) {
this.$store.commit('MOBILE_TYPE', newVal < 480)
}
},
methods: {
/* 侧边栏切换操作 */
onCollase (collapsed, type) {
this.collapseWidth = 80
this.width = 80
if (type === 'lg' && !collapsed) {
this.collapseWidth = 200
this.width = 200
this.siderCheck = true
} else if ((type === 'sm' && collapsed) || this.isMobile) {
this.collapseWidth = 0
this.width = 0
}
},
onBreakpoint (broken, type) {
this.onCollase(broken, type)
}
}
}
</script>
<style scoped lang="scss">
.wrapper {
display: flex;
flex-direction: column;
width: 100%;
min-height: 100%;
}
.fixed-sidebar {
box-shadow: 0 2px 8px #f0f1f2;
height: 100vh;
z-index: 99;
position: fixed;
left: 0;
}
</style>

View File

@@ -0,0 +1,10 @@
<template>
<router-view />
</template>
<script>
export default {
name: 'PageView'
}
</script>

View File

@@ -0,0 +1,141 @@
<template>
<div class="sidebar-haslogo">
<div class="header-logo">
<a href="/dashboard" id="logo">
<img src="/static/img/logo.png" alt="" />
</a>
</div>
<a-menu
mode="inline"
style="border-right-color: transparent"
:open-keys="openKeys"
@openChange="onOpenChange"
:selectedKeys="[onRoutes]"
>
<template v-for="item in filteredSidebar">
<a-menu-item v-if="!item.children" :key="item.path">
<router-link :to="{ path: item.path }">
<a-icon :type="item.meta.icon"></a-icon>
<span>{{ item.meta.title }}</span>
</router-link>
</a-menu-item>
<sub-menu v-else :key="item.path" :menu-info="item" />
</template>
</a-menu>
</div>
</template>
<script>
import { Menu } from "ant-design-vue";
import router from "@/router/index.js";
import mixin from "@/mixins/index";
import { mapGetters } from 'vuex';
const SubMenu = {
template: `
<a-sub-menu :key="menuInfo.path" v-bind="$props" v-on="$listeners">
<span slot="title">
<a-icon :type="menuInfo.meta.icon" />
<span>{{ menuInfo.meta.title }}</span>
</span>
<template v-for="item in filterAdminRoutes(menuInfo.children)">
<a-menu-item v-if="!item.children" :key="item.path">
<router-link :to="{path: item.path}">
<span>{{ item.meta.title }}</span>
</router-link>
</a-menu-item>
<sub-menu v-else :key="item.path" :menu-info="item" />
</template>
</a-sub-menu>
`,
name: "SubMenu",
isSubMenu: true,
props: {
...Menu.SubMenu.props,
menuInfo: {
type: Object,
default: () => ({}),
},
},
computed: {
...mapGetters(['USER_INFO']),
isAdmin() {
return this.USER_INFO && this.USER_INFO.isAdmin == '1';
}
},
methods: {
filterAdminRoutes(routes) {
if (!routes) return [];
return routes.filter(route => {
return !route.meta.isAdmin || (route.meta.isAdmin && this.isAdmin);
});
}
}
};
export default {
mixins: [mixin],
components: {
"sub-menu": SubMenu,
},
data() {
return {
// 侧边栏
sidebar: router.options.routes[1].children,
rootSubmenuKeys: ["/setting", "/config"],
openKeys: ["/config"],
};
},
computed: {
...mapGetters(['USER_INFO']),
isAdmin() {
return this.USER_INFO && this.USER_INFO.isAdmin == '1';
},
onRoutes() {
console.log(this.$route.path);
// 切换页面时摧毁所有弹框
this.$message.destroy()
return this.$route.path;
},
filteredSidebar() {
return this.sidebar.filter(route => {
// 判断管理员页面
// 判断是否为不显示的子页面
return (!route.meta.isAdmin || (route.meta.isAdmin && this.isAdmin)) && !route.meta.hideInMenu;
});
}
},
methods: {
onOpenChange(openKeys) {
const latestOpenKey = openKeys.find((key) => this.openKeys.indexOf(key) === -1);
if (this.rootSubmenuKeys.indexOf(latestOpenKey) === -1) {
this.openKeys = openKeys;
} else {
this.openKeys = latestOpenKey ? [latestOpenKey] : [];
}
console.log(this.openKeys);
},
},
};
</script>
<style scoped lang="scss">
.sidebar-haslogo {
height: 100%;
overflow: hidden auto;
}
.header-logo {
padding: 8px 16px;
}
#logo {
overflow: hidden;
height: 48px;
line-height: 48px;
text-decoration: none;
white-space: nowrap;
img {
height: 32px;
margin: 0 10px;
}
}
</style>

View File

@@ -0,0 +1,20 @@
<template>
<a-result status="403" title="403" sub-title="Sorry, you don't have access to this page.">
<template #extra>
<a-button type="primary" @click="toHome">
Back Home
</a-button>
</template>
</a-result>
</template>
<script>
export default {
name: 'Exception403',
methods: {
toHome () {
this.$router.push({ path: '/' })
}
}
}
</script>

View File

@@ -0,0 +1,20 @@
<template>
<a-result status="404" title="404" sub-title="Sorry, the page you visited does not exist.">
<template #extra>
<a-button type="primary" @click="toHome">
Back Home
</a-button>
</template>
</a-result>
</template>
<script>
export default {
name: 'Exception404',
methods: {
toHome () {
this.$router.push({ path: '/' })
}
}
}
</script>

View File

@@ -0,0 +1,20 @@
<template>
<a-result status="500" title="500" sub-title="Sorry, the server is reporting an error.">
<template #extra>
<a-button type="primary" @click="toHome">
Back Home
</a-button>
</template>
</a-result>
</template>
<script>
export default {
name: 'Exception500',
methods: {
toHome () {
this.$router.push({ path: '/' })
}
}
}
</script>

708
web/src/views/page/Chat.vue Normal file
View File

@@ -0,0 +1,708 @@
<template>
<div class="chat-container">
<!-- 聊天头部 -->
<div class="chat-header">
<!-- 左侧连接按钮 -->
<div class="header-left">
<a-button
type="text"
class="connection-btn"
@click="toggleConnection"
:disabled="localConnectionStatus === '正在连接...'"
>
<a-icon
:type="localIsConnected ? 'disconnect' : 'link'"
:style="{ color: localIsConnected ? '#52c41a' : '#ff4d4f' }"
/>
</a-button>
</div>
<div class="header-title">
小智助手
<a-tag :color="connectionStatusColor">{{ localConnectionStatus }}</a-tag>
</div>
<div class="header-right">
<a-dropdown>
<template #overlay>
<a-menu>
<a-menu-item key="1" @click="showServerConfig = true">
<a-icon type="setting" />
服务器设置
</a-menu-item>
<a-menu-item key="2" @click="showDebugPanel = !showDebugPanel">
<a-icon type="bug" />
调试面板
</a-menu-item>
<a-menu-item key="3" @click="messages = []">
<a-icon type="delete" />
清空聊天
</a-menu-item>
</a-menu>
</template>
<a-button type="text">
<a-icon type="more" />
</a-button>
</a-dropdown>
</div>
</div>
<!-- 使用ChatComponent替换原来的聊天内容和输入区域 -->
<ChatComponent
ref="chatComponentRef"
:message-list="messages"
:show-input="true"
:show-voice-toggle="true"
:user-avatar="userAvatar"
:ai-avatar="aiAvatar"
:input-placeholder="'输入消息...'"
:empty-text="'暂无对话,开始聊天吧'"
:is-connected-prop="localIsConnected"
@recording-start="handleRecordingStart"
@recording-stop="handleRecordingStop"
@recording-error="handleRecordingError"
@mode-change="handleModeChange"
/>
<!-- 连接提示 -->
<a-alert
v-if="connectionAlert.show"
:message="connectionAlert.title"
:description="connectionAlert.message"
:type="connectionAlert.type"
class="connection-alert"
closable
@close="connectionAlert.show = false"
>
<template slot="action">
<div class="connection-actions">
<a-button size="small" @click="connect">连接</a-button>
<a-button size="small" type="primary" @click="showServerConfig = true">配置服务器</a-button>
</div>
</template>
</a-alert>
<!-- 服务器配置对话框 -->
<a-modal
v-model="showServerConfig"
title="服务器配置"
@ok="saveServerConfig"
okText="保存"
cancelText="取消"
>
<a-form :form="form" layout="vertical">
<a-form-item label="服务器地址">
<a-input v-model="serverConfig.url" placeholder="ws://服务器地址:端口/路径" />
</a-form-item>
<a-form-item label="设备ID">
<a-input v-model="serverConfig.deviceId" placeholder="设备唯一标识" />
</a-form-item>
<a-form-item label="设备名称">
<a-input v-model="serverConfig.deviceName" placeholder="设备名称" />
</a-form-item>
<a-form-item label="认证令牌">
<a-input v-model="serverConfig.token" placeholder="认证令牌" />
</a-form-item>
</a-form>
</a-modal>
<!-- 调试面板 -->
<div class="debug-panel-container" :class="{ show: showDebugPanel }">
<div class="debug-panel-header">
<span>调试面板</span>
<a-button type="text" @click="showDebugPanel = false">
<a-icon type="close" />
</a-button>
</div>
<div class="debug-panel">
<a-space direction="vertical" style="width: 100%">
<a-card size="small" title="连接状态">
<p><strong>状态:</strong> {{ localConnectionStatus }}</p>
<p v-if="connectionTime"><strong>连接时间:</strong> {{ formatTimestamp(connectionTime) }}</p>
<p v-if="sessionId"><strong>会话ID:</strong> {{ sessionId }}</p>
<a-space>
<a-button size="small" @click="connectToServer" :disabled="localIsConnected">连接</a-button>
<a-button size="small" @click="disconnectFromServer" :disabled="!localIsConnected">断开</a-button>
</a-space>
</a-card>
<a-card size="small" title="日志">
<div class="log-container" ref="logContainerRef">
<div v-for="(entry, index) in logs" :key="index" class="log-entry" :class="`log-${entry.type}`">
<span class="log-time">{{ formatLogTime(entry.time) }}</span>
<span class="log-message">{{ entry.message }}</span>
</div>
</div>
</a-card>
<a-card size="small" title="音频可视化">
<canvas ref="audioVisualizerRef" class="audio-visualizer"></canvas>
</a-card>
</a-space>
</div>
</div>
<!-- 添加一个悬浮的重连按钮当连接断开时显示 -->
<div v-if="!localIsConnected && !connectionAlert.show" class="floating-reconnect-btn">
<a-button type="primary" shape="circle" @click="connect" title="重新连接">
<a-icon type="reload" />
</a-button>
</div>
</div>
</template>
<script>
import {
// WebSocket相关
connectionStatus,
connectionTime,
sessionId,
isConnected,
connectToServer,
disconnectFromServer,
startDirectRecording,
stopDirectRecording,
isWebSocketConnected,
registerMessageHandler,
// 日志相关
log,
getLogs,
// 消息相关
messages,
addMessage,
updateMessage,
addSystemMessage,
addAudioMessage
} from '@/services/websocketService';
import {
initAudio,
handleBinaryMessage,
getAudioState
} from '@/services/audioService';
// 导入ChatComponent
import ChatComponent from '@/components/ChatComponent';
export default {
name: 'Chat',
components: {
ChatComponent
},
data() {
return {
// 状态变量
isVoiceMode: false, // 默认为文字输入模式
messages: messages, // 使用websocketService中的消息列表
logs: [],
showDebugPanel: false,
showServerConfig: false,
connectionAlert: {
show: true,
title: '未连接到服务器',
message: '请配置并连接到小智服务器',
type: 'info'
},
// 服务器配置
serverConfig: {
url: localStorage.getItem('xiaozhi_server_url') || 'ws://localhost:8091/ws/xiaozhi/v1/',
deviceId: localStorage.getItem('xiaozhi_device_id') || 'web_test',
deviceName: localStorage.getItem('xiaozhi_device_name') || '网页客户端',
token: localStorage.getItem('xiaozhi_token') || ''
},
// 头像
userAvatar: '/assets/user-avatar.png',
aiAvatar: '/assets/ai-avatar.png',
form: this.$form.createForm(this),
// 自动重连计时器
reconnectTimer: null,
reconnectAttempts: 0,
maxReconnectAttempts: 5,
// 本地连接状态副本
localConnectionStatus: '未连接',
localIsConnected: false
};
},
computed: {
// 使用计算属性获取最新的连接状态
connectionStatusColor() {
if (this.localIsConnected) return 'green';
if (this.localConnectionStatus && (
this.localConnectionStatus.includes('错误') ||
this.localConnectionStatus.includes('失败')
)) return 'red';
if (this.localConnectionStatus === '正在连接...') return 'blue';
return 'red';
}
},
watch: {
// 监听外部连接状态变化
isConnected: {
handler(newValue) {
this.localIsConnected = newValue;
if (newValue) {
// 连接成功
this.connectionAlert.show = false;
this.reconnectAttempts = 0;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
} else {
// 连接断开
this.connectionAlert.show = true;
this.connectionAlert.title = '连接已断开';
this.connectionAlert.message = '与服务器的连接已断开';
this.connectionAlert.type = 'warning';
// 设置自动重连
this.setupAutoReconnect();
}
},
immediate: true
},
// 监听外部连接状态文本变化
connectionStatus: {
handler(newValue) {
this.localConnectionStatus = newValue || '未连接';
},
immediate: true
}
},
mounted() {
// 初始化音频可视化
this.initAudioVisualizer();
// 加载日志
this.logs = getLogs();
// 注册消息处理函数
registerMessageHandler(this.handleServerMessage);
// 设置全局二进制音频处理函数
window.handleBinaryAudioMessage = this.handleBinaryAudioMessage;
// 尝试自动连接
if (this.serverConfig.url) {
setTimeout(() => {
this.connect();
}, 500);
}
// 初始化音频服务
this.initAudioService();
// 监听窗口大小变化
window.addEventListener('resize', this.initAudioVisualizer);
// 监听日志更新
this.$watch(
() => getLogs(),
(newLogs) => {
this.logs = newLogs;
// 滚动日志到底部
this.$nextTick(() => {
if (this.$refs.logContainerRef) {
this.$refs.logContainerRef.scrollTop = this.$refs.logContainerRef.scrollHeight;
}
});
},
{ deep: true }
);
// 定期检查连接状态确保UI更新
this.startConnectionStatusPoller();
},
beforeDestroy() {
disconnectFromServer();
window.removeEventListener('resize', this.initAudioVisualizer);
// 清除全局处理函数
window.handleBinaryAudioMessage = null;
// 清除重连计时器
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
// 清除状态轮询器
if (this.statusPoller) {
clearInterval(this.statusPoller);
}
},
methods: {
// 开始连接状态轮询
startConnectionStatusPoller() {
// 每秒检查一次连接状态确保UI更新
this.statusPoller = setInterval(() => {
// 更新本地连接状态副本
this.localIsConnected = isConnected;
this.localConnectionStatus = connectionStatus || '未连接';
}, 1000);
},
// 切换连接状态
toggleConnection() {
if (this.localIsConnected) {
this.disconnectFromServer();
} else {
this.connect();
}
},
// 设置自动重连
setupAutoReconnect() {
// 清除之前的计时器
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
// 如果尝试次数超过最大值,不再尝试
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
log('达到最大重连次数,停止自动重连', 'warning');
return;
}
// 计算重连延迟(指数退避策略)
const delay = Math.min(30000, 1000 * Math.pow(2, this.reconnectAttempts));
this.reconnectTimer = setTimeout(() => {
if (!isConnected) {
log(`尝试自动重连 (${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})`, 'info');
this.reconnectAttempts++;
this.connect();
}
}, delay);
},
// 初始化音频服务
async initAudioService() {
try {
await initAudio();
} catch (error) {
log(`音频初始化失败: ${error.message}`, 'error');
}
},
// 初始化音频可视化
initAudioVisualizer() {
if (!this.$refs.audioVisualizerRef) return;
const canvas = this.$refs.audioVisualizerRef;
canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.fillStyle = '#fafafa';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
},
// 处理服务器消息
handleServerMessage(data) {
if (!data || !data.type) return;
// 处理消息逻辑保持不变
},
// 处理二进制音频消息
async handleBinaryAudioMessage(data) {
try {
// 调用ChatComponent中的处理方法
if (this.$refs.chatComponentRef) {
return await this.$refs.chatComponentRef.handleBinaryAudioMessage(data);
} else {
// 如果ChatComponent还没准备好则使用audioService中的方法
return await handleBinaryMessage(data);
}
} catch (error) {
log(`处理二进制音频消息失败: ${error.message}`, 'error');
return false;
}
},
// 连接到服务器
async connect() {
try {
const connected = await connectToServer(this.serverConfig);
if (connected) {
this.connectionAlert.show = false;
this.$message.success('已连接到服务器');
// 立即更新本地状态
this.localIsConnected = true;
this.localConnectionStatus = connectionStatus || '已连接';
} else {
this.showConnectionError('连接失败', '无法连接到服务器,请检查配置');
}
} catch (error) {
this.showConnectionError('连接错误', error.message);
}
},
// 显示连接错误
showConnectionError(title, message) {
this.connectionAlert.show = true;
this.connectionAlert.title = title;
this.connectionAlert.message = message;
this.connectionAlert.type = 'error';
},
// 处理录音开始
handleRecordingStart() {
log('录音开始', 'info');
},
// 处理录音结束
handleRecordingStop() {
log('录音结束', 'info');
},
// 处理录音错误
handleRecordingError(error) {
log(`录音错误: ${error.message}`, 'error');
},
// 处理输入模式变化
handleModeChange(isVoiceMode) {
this.isVoiceMode = isVoiceMode;
log(`切换到${isVoiceMode ? '语音' : '文字'}输入模式`, 'info');
},
// 保存服务器配置
saveServerConfig() {
localStorage.setItem('xiaozhi_server_url', this.serverConfig.url);
localStorage.setItem('xiaozhi_device_id', this.serverConfig.deviceId);
localStorage.setItem('xiaozhi_device_name', this.serverConfig.deviceName);
localStorage.setItem('xiaozhi_token', this.serverConfig.token);
this.showServerConfig = false;
// 如果已连接但配置改变,提示重新连接
if (isConnected) {
this.$message.info('配置已保存,需要重新连接才能生效');
} else {
// 尝试连接
this.connect();
}
},
// 格式化时间戳
formatTimestamp(timestamp) {
if (!timestamp) return '';
const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
return date.toLocaleString();
},
// 格式化日志时间
formatLogTime(time) {
if (!time) return '';
const date = time instanceof Date ? time : new Date(time);
return date.toLocaleTimeString();
},
// 连接到服务器调用websocketService
connectToServer() {
return this.connect();
},
// 断开连接
disconnectFromServer() {
disconnectFromServer();
// 立即更新本地状态
this.localIsConnected = false;
this.localConnectionStatus = '已断开';
this.$message.info('已断开连接');
},
}
};
</script>
<style scoped>
.chat-container {
display: flex;
flex-direction: column;
height: 80vh;
background-color: #f5f5f5;
position: relative;
max-width: 800px;
margin: 0 auto;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background-color: #fff;
border-bottom: 1px solid #e8e8e8;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
z-index: 10;
}
.header-left {
flex: 0 0 auto;
width: 40px; /* 与右侧按钮宽度保持一致 */
}
.header-title {
flex: 1;
text-align: center;
font-size: 16px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
}
.header-title .ant-tag {
margin-left: 8px;
font-size: 12px;
padding: 0 6px;
height: 22px;
line-height: 22px;
}
.header-right {
flex: 0 0 auto;
width: 40px; /* 与左侧按钮宽度保持一致 */
display: flex;
justify-content: flex-end;
}
/* 连接按钮样式 */
.connection-btn {
height: 32px;
width: 32px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.connection-btn .anticon {
font-size: 18px;
}
.connection-alert {
position: fixed;
top: 60px;
left: 50%;
transform: translateX(-50%);
width: 90%;
max-width: 500px;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.connection-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
/* 悬浮重连按钮 */
.floating-reconnect-btn {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 90;
}
.floating-reconnect-btn .ant-btn {
width: 48px;
height: 48px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.floating-reconnect-btn .anticon {
font-size: 20px;
}
.debug-panel-container {
position: fixed;
top: 0;
right: -350px;
width: 350px;
height: 100vh;
background-color: #fff;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
transition: right 0.3s;
z-index: 1000;
display: flex;
flex-direction: column;
}
.debug-panel-container.show {
right: 0;
}
.debug-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #e8e8e8;
font-weight: 500;
}
.debug-panel {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.log-container {
height: 200px;
overflow-y: auto;
background-color: #f5f5f5;
border-radius: 4px;
padding: 8px;
font-family: monospace;
font-size: 12px;
}
.log-entry {
margin-bottom: 4px;
line-height: 1.4;
}
.log-time {
color: #888;
margin-right: 6px;
}
.log-info {
color: #333;
}
.log-error {
color: #f5222d;
}
.log-warning {
color: #faad14;
}
.log-success {
color: #52c41a;
}
.log-debug {
color: #8c8c8c;
}
.audio-visualizer {
width: 100%;
height: 100px;
background-color: #fafafa;
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,447 @@
<template>
<a-layout>
<!-- 面包屑 -->
<a-layout-header>
<div style="padding: 10px 24px">
<a-skeleton :loading="loading" active avatar>
<!-- 首页信息 -->
<a-page-header
:avatar="{
props: {
src: userInfo.avatar ? getAvatarUrl(userInfo.avatar) : '',
class: 'page-header-content-avatar',
},
style:
'display: block; min-height: 72px; min-width: 72px; border-radius: 72px;',
}"
class="page-header-content"
>
<template slot="subTitle">
<p class="page-header-content-title">
{{ timeFix }}, {{ userInfo.name }}, {{ welcome }}
</p>
<a-tooltip title="点击翻译" placement="right">
<p
@click="sentenceShow ? (sentenceShow = false) : (sentenceShow = true)"
style="cursor: pointer"
>
{{ sentenceShow ? sentence.content : sentence.note }}
</p>
</a-tooltip>
</template>
<div class="page-head-content-statistic">
<a-statistic
title="对话次数"
:value="userInfo.totalMessage ? userInfo.totalMessage : 0"
style="padding: 0 25px; padding-left: 0; text-align: right"
/>
<a-statistic
title="活跃设备数"
:value="userInfo.aliveNumber ? userInfo.aliveNumber : 0"
style="padding: 0 25px; text-align: right"
/>
<a-statistic
title="总设备数"
:value="userInfo.totalDevice ? userInfo.totalDevice : 0"
style="padding: 0 25px; padding-right: 0; text-align: right"
/>
</div>
</a-page-header>
</a-skeleton>
</div>
</a-layout-header>
<a-layout-content>
<div class="layout-content-margin">
<a-row type="flex" :gutter="[20, 20]">
<a-col
:xl="{ order: 0, span: 14 }"
:lg="{ order: 0, span: 12 }"
:xs="{ order: 1, span: 24 }"
>
<a-card title="聊天记录" :bordered="false">
<a-skeleton :loading="loading" active :paragraph="{ rows: 10 }">
<chat-component
:message-list="formattedChatMessages"
:show-input="true"
:user-avatar="userAvatar"
:ai-avatar="aiAvatar"
/>
</a-skeleton>
</a-card>
</a-col>
<a-col
:xl="{ order: 1, span: 10 }"
:lg="{ order: 1, span: 12 }"
:xs="{ order: 0, span: 24 }"
>
<a-row :gutter="[20, 20]">
<a-col>
<a-card title="设备列表" :bordered="false" :loading="loading">
<a-table
rowKey="deviceId"
size="small"
:columns="columns"
:dataSource="data"
:pagination="{ pageSize: 5 }"
:loading=userLoading
:scroll="{ x: 500 }"
>
<div
slot="filterDropdown"
slot-scope="{
setSelectedKeys,
selectedKeys,
confirm,
clearFilters,
column,
}"
style="padding: 8px"
>
<a-input
v-ant-ref="(c) => (searchInput = c)"
:placeholder="`搜索 ${column.title}`"
:value="selectedKeys[0]"
style="width: 188px; margin-bottom: 8px; display: block"
@change="
(e) => setSelectedKeys(e.target.value ? [e.target.value] : [])
"
@pressEnter="
() => departmentSearch(selectedKeys, confirm, column.dataIndex)
"
/>
<a-button
type="primary"
icon="search"
size="small"
style="width: 90px; margin-right: 8px"
@click="
() => departmentSearch(selectedKeys, confirm, column.dataIndex)
"
>
搜索
</a-button>
<a-button
size="small"
style="width: 90px"
@click="() => reset(clearFilters)"
>
重置
</a-button>
</div>
<a-icon
slot="filterIcon"
slot-scope="filtered"
type="search"
:style="{ color: filtered ? '#108ee9' : undefined }"
/>
<template
slot="customRender"
slot-scope="text, record, index, column"
>
<span v-if="searchText && searchedColumn === column.dataIndex">
<template
v-for="(fragment, i) in text
.toString()
.split(
new RegExp(`(?<=${searchText})|(?=${searchText})`, 'i')
)"
>
<mark
v-if="fragment.toLowerCase() === searchText.toLowerCase()"
:key="i"
class="highlight"
>{{ fragment }}</mark
>
<template v-else>{{ fragment }}</template>
</template>
</span>
<template v-else>
{{ text }}
</template>
</template>
</a-table>
</a-card>
</a-col>
</a-row>
</a-col>
</a-row>
</div>
</a-layout-content>
<a-back-top />
</a-layout>
</template>
<script>
import api from "@/services/api";
import axios from "@/services/axios";
import { getResourceUrl } from "@/services/axios";
import mixin from "@/mixins/index";
import Cookies from "js-cookie";
import { jsonp } from 'vue-jsonp';
import { timeFix, welcome } from "@/utils/util";
import ChatComponent from '@/components/ChatComponent.vue';
export default {
mixins: [mixin],
components: { ChatComponent },
data() {
return {
// 欢迎页面
timeFix: timeFix(),
welcome: welcome(),
sentenceShow: true,
sentence: {
content: "",
note: "",
},
controlRule: {
messageDate: "",
limit: 0,
active: false,
},
userLoading: true,
searchText: "",
searchInput: null,
searchedColumn: "",
columns: [
{
title: "设备名称",
dataIndex: "deviceName",
name: "deviceName",
with: 100,
scopedSlots: {
filterDropdown: "filterDropdown",
filterIcon: "filterIcon",
customRender: "customRender",
},
onFilter: (value, record) =>
record.deviceName.toString().toLowerCase().includes(value.toLowerCase()),
onFilterDropdownVisibleChange: (visible) => {
if (visible) {
setTimeout(() => {
this.searchInput.focus();
}, 0);
}
},
},
{
title: "对话次数",
dataIndex: "totalMessage",
key: "totalMessage",
align: "right",
width: 100,
sorter: (a, b) => a.totalMessage - b.totalMessage,
},
{
title: "在线状态",
dataIndex: "state",
key: "state",
align: "right",
sorter: (a, b) => a.status - b.status,
},
{
title: "上次对话时间",
dataIndex: "lastLogin",
key: "lastLogin",
align: "right",
width: 180,
sorter: (a, b) => a.lastLogin - b.lastLogin,
},
],
data: [],
// 加载等待
busy: true,
// 动态数据
listItem: [],
form: this.$form.createForm(this),
// 动态页数
page: 1,
// 已到最后一条数据
isLastData: false,
uploadLoading: false,
};
},
beforeMount() {},
mounted() {
this.getSentence()
this.getDate();
this.infiniteOnLoad();
},
computed: {
userInfo() {
return this.$store.getters.USER_INFO;
},
// 格式化聊天消息将listItem转换为ChatComponent需要的格式
formattedChatMessages() {
return this.listItem.map(item => {
// 根据消息类型确定是用户消息还是系统消息
const isUser = item.type === 3; // 假设type=3是用户消息
return {
id: item.messageId,
content: item.description,
type: 'text',
isUser: isUser,
timestamp: new Date(item.createTime),
avatar: item.avatar
};
});
}
},
methods: {
getAvatarUrl(avatar) {
return getResourceUrl(avatar);
},
updateInformation() {
this.form.validateFields((err, values) => {
if (!err) {
values[values.changeItem] = values.content;
this.update(values);
}
});
},
getSentence() {
const day = this.moment().format("YYYY-MM-DD");
jsonp(`https://sentence.iciba.com/index.php?c=dailysentence&m=getdetail&title=${day}`, {
param: "callback",
}).then((res) => {
this.sentence = res
}).catch((err) => {
this.$message.error(err.errmsg);
})
},
reset(clearFilters) {
clearFilters();
this.searchText = "";
},
/* 查询设备列表 */
getDate() {
axios
.get({
url: api.device.query,
})
.then((res) => {
this.userLoading = false
if (res.code === 200) {
this.data = res.data.list;
} else {
this.$message.error(res.message)
}
})
.catch(() => {
this.userLoading = false
this.showError();
});
},
infiniteOnLoad() {
if (this.isLastData) return;
this.busy = false;
const key = "loading";
axios
.get({
url: api.message.query,
data: {
start: this.page,
limit: 10,
},
})
.then((res) => {
this.busy = true;
if (res.code === 200) {
if (res.data.length === 0) {
this.$message.warning({ content: "已到最后一条数据", key });
this.isLastData = true;
return;
}
this.page++;
res.data.forEach((item) => {
let description;
switch (item.type) {
case 1:
description = `${item.deviceName} 于 ${item.createTime} 注册`;
break;
case 2:
description = `${item.name} 于 ${item.createTime} 修改 ${item.deviceName} 信息`;
break;
case 3:
description = `${item.deviceName} 于 ${item.createTime} 报名`;
break;
case 4:
description = `${item.name} 于 ${item.createTime} 取消 ${item.deviceName} 报名`;
break;
}
item.description = description;
});
this.listItem = this.listItem.concat(res.data);
} else {
this.$message.error({ content: res.message, key });
}
})
.catch(() => {
this.busy = true;
})
.finally(() => {
this.loading = false
})
},
},
};
</script>
<style lang="scss" scoped>
.ant-layout >>> .ant-layout-header {
height: 100%;
line-height: 100%;
padding: 0;
background: #fff;
.ant-page-header {
padding: 0;
.ant-page-header-heading {
width: auto;
flex: auto;
display: flex;
}
.ant-page-header-content {
overflow: unset;
}
}
.ant-page-header-heading-sub-title {
margin-left: 12px;
}
}
.page-header-content-title {
margin-bottom: 12px;
color: rgba(0, 0, 0, 0.85);
font-weight: 500;
font-size: 20px;
line-height: 28px;
}
.page-head-content-statistic {
display: -webkit-box;
}
.page-header-content {
display: flex;
}
.infinite-loading {
// width: 100%;
// text-align: center;
}
@media screen and (max-width: 800px) {
.page-head-content-statistic {
width: max-content;
margin: 0 auto;
}
.page-header-content {
display: table-cell;
}
}
.ant-btn-link {
color: rgba(0, 0, 0, 0.65);
}
.highlight {
background-color: rgb(255, 192, 105);
padding: 0px;
}
</style>

View File

@@ -0,0 +1,497 @@
<template>
<a-layout>
<a-layout-content>
<div class="layout-content-margin">
<!-- 查询框 -->
<div class="table-search">
<a-form layout="horizontal" :colon="false" :labelCol="{ span: 6 }" :wrapperCol="{ span: 16 }">
<a-row class="filter-flex">
<a-col :xl="6" :lg="12" :xs="24" v-for="(item, index) in queryFilter" :key="index">
<a-form-item :label="item.label">
<a-input-search v-model="item.value" :placeholder="`请输入${item.label}`" allowClear @search="getData()" />
</a-form-item>
</a-col>
<a-col :xxl="6" :xl="6" :lg="12" :xs="24">
<a-form-item label="设备状态">
<a-select v-model="query.state" placeholder="请选择" @change="getData()">
<a-select-option v-for="item in stateItems" :key="item.key" :value="item.value">
{{ item.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
<!-- 表格数据 -->
<a-card :bodyStyle="{ padding: 0 }" :bordered="false">
<div slot="extra" style="display: flex; align-items: center;">
<a-input-search enter-button="添加设备" autoFocus placeholder="请输入设备码" @search="addDevice" />
</div>
<template slot="title">
<span>设备管理</span>
</template>
<a-table
rowKey="deviceId"
:columns="tableColumns"
:data-source="data"
:loading="loading"
:pagination="pagination"
:scroll="{ x: 1200 }"
size="middle">
<!-- 设备名称列 -->
<template slot="deviceName" slot-scope="text, record">
<div>
<a-input v-if="record.editable" style="margin: -5px 0; text-align: center" :value="text"
@change="e => inputEdit(e.target.value, record.deviceId, 'deviceName')"
@keyup.enter="e => update(record, record.deviceId)"
@keyup.esc="e => cancel(record.deviceId)" />
<span v-else-if="editingKey === ''" @click="edit(record.deviceId)" style="cursor: pointer">
<a-tooltip title="点击编辑" :mouseEnterDelay="0.5">
<span v-if="text">{{ text }}</span>
<span v-else style="padding: 0 50px">&nbsp;&nbsp;&nbsp;</span>
</a-tooltip>
</span>
<span v-else>{{ text }}</span>
</div>
</template>
<!-- 角色列 -->
<template slot="roleName" slot-scope="text, record">
<a-select v-if="record.editable" style="margin: -5px 0; text-align: center; width: 100%"
:value="record.roleId" @change="value => handleSelectChange(value, record.deviceId, 'role')">
<a-select-option v-for="item in roleItems" :key="item.roleId" :value="item.roleId">
<div style="text-align: center">{{ item.roleName }}</div>
</a-select-option>
</a-select>
<span v-else-if="editingKey === ''" @click="edit(record.deviceId)" style="cursor: pointer">
<a-tooltip :title="record.roleDesc" :mouseEnterDelay="0.5" placement="right">
<span v-if="record.roleId">{{ getRoleName(record.roleId) }}</span>
<span v-else style="padding: 0 50px">&nbsp;&nbsp;&nbsp;</span>
</a-tooltip>
</span>
<span v-else>{{ text }}</span>
</template>
<!-- 设备状态列 -->
<template slot="state" slot-scope="text">
<a-tag :color="text == 1 ? 'green' : 'red'">{{ text == 1 ? '在线' : '离线' }}</a-tag>
</template>
<!-- 时间列通用模板 -->
<template slot="timeColumn" slot-scope="text">
{{ text || '-' }}
</template>
<!-- 操作列 -->
<template slot="operation" slot-scope="text, record">
<a-space v-if="record.editable">
<a-popconfirm title="确定保存?" @confirm="update(record, record.deviceId)">
<a>保存</a>
</a-popconfirm>
<a @click="cancel(record.deviceId)">取消</a>
</a-space>
<a-space v-else>
<a @click="edit(record.deviceId)">编辑</a>
<a @click="editWithDialog(record)">详情</a>
<a-popconfirm
title="确定要删除此设备吗?"
ok-text="确定"
cancel-text="取消"
@confirm="deleteDevice(record)"
>
<a style="color: #ff4d4f">删除</a>
</a-popconfirm>
</a-space>
</template>
</a-table>
</a-card>
</div>
</a-layout-content>
<!-- 设备详情弹窗 -->
<DeviceEditDialog
@submit="update"
@close="editVisible = false"
@clear-memory="clearMemory"
:visible="editVisible"
:current="currentDevice"
:role-items="roleItems"
:clearMemoryLoading="clearMemoryLoading"/>
<a-back-top />
</a-layout>
</template>
<script>
import axios from "@/services/axios";
import api from "@/services/api";
import mixin from "@/mixins/index";
import DeviceEditDialog from "@/components/DeviceEditDialog.vue";
export default {
components: { DeviceEditDialog },
mixins: [mixin],
data() {
return {
// 查询框
editVisible: false,
currentDevice: {},
query: {
state: "",
},
queryFilter: [
{
label: "设备编号",
value: "",
index: "deviceId",
},
{
label: "设备名称",
value: "",
index: "deviceName",
},
{
label: "角色",
value: "",
index: "roleName",
},
],
stateItems: [
{ label: "全部", value: "", key: "" },
{ label: "在线", value: "1", key: "1" },
{ label: "离线", value: "0", key: "0" },
],
// 表格数据
tableColumns: [
{
title: "设备编号",
dataIndex: "deviceId",
width: 160,
fixed: "left",
align: "center",
},
{
title: "设备名称",
dataIndex: "deviceName",
scopedSlots: { customRender: "deviceName" },
width: 100,
align: "center",
},
{
title: "设备角色",
dataIndex: "roleName",
scopedSlots: { customRender: "roleName" },
width: 100,
align: "center",
},
{
title: "WIFI名称",
dataIndex: "wifiName",
width: 100,
align: "center",
ellipsis: true,
},
{
title: "IP地址",
dataIndex: "ip",
width: 180,
align: "center",
ellipsis: true,
},
{
title: "设备状态",
dataIndex: "state",
scopedSlots: { customRender: "state" },
width: 100,
align: "center",
},
{
title: "产品类型",
dataIndex: "chipModelName",
width: 100,
align: "center",
},
{
title: "设备类型",
dataIndex: "type",
width: 150,
align: "center",
ellipsis: true,
},
{
title: "版本号",
dataIndex: "version",
width: 100,
align: "center",
},
{
title: "活跃时间",
dataIndex: "lastLogin",
scopedSlots: { customRender: "timeColumn" },
width: 180,
align: "center",
},
{
title: "创建时间",
dataIndex: "createTime",
scopedSlots: { customRender: "timeColumn" },
width: 180,
align: "center",
},
{
title: "操作",
dataIndex: "operation",
scopedSlots: { customRender: "operation" },
width: 150,
align: "center",
fixed: "right",
},
],
// 资源数据
roleItems: [],
// 设备数据
data: [],
cacheData: [],
editingKey: "",
// 加载状态标志
clearMemoryLoading: false,
};
},
mounted() {
this.getRole()
this.getData();
},
methods: {
/**
* 数据获取方法
*/
// 获取设备列表数据
getData() {
this.loading = true;
this.editingKey = "";
// 构建查询参数
const queryParams = {
start: this.pagination.page,
limit: this.pagination.pageSize,
...this.query,
};
// 添加过滤条件
this.queryFilter.forEach(filter => {
if (filter.value) {
queryParams[filter.index] = filter.value;
}
});
axios
.get({
url: api.device.query,
data: queryParams,
})
.then((res) => {
if (res.code === 200) {
this.data = res.data.list;
this.cacheData = res.data.list.map((item) => ({ ...item }));
this.pagination.total = res.data.total;
} else {
this.$message.error(res.message);
}
})
.catch(() => {
this.showError();
})
.finally(() => {
this.loading = false;
});
},
// 获取角色列表
getRole() {
return axios
.get({ url: api.role.query, data: {} })
.then((res) => {
if (res.code === 200) {
this.roleItems = res.data.list;
} else {
this.$message.error(res.message);
}
})
.catch(() => {
this.showError();
});
},
/**
* 设备操作方法
*/
// 添加设备
addDevice(value) {
if (!value) {
this.$message.info("请输入设备编号");
return;
}
if(this.roleItems.length == 0) {
this.$message.warn("请先配置默认角色");
return;
}
axios
.post({
url: api.device.add,
data: {
code: value
},
})
.then((res) => {
if (res.code === 200) {
this.$message.success("设备添加成功");
this.getData();
} else {
this.$message.error(res.message);
}
})
.catch(() => {
this.showError();
});
},
// 删除设备
deleteDevice(record) {
this.loading = true;
axios
.post({
url: api.device.delete,
data: { deviceId: record.deviceId }
})
.then((res) => {
if (res.code === 200) {
this.$message.success("设备删除成功");
this.getData();
} else {
this.$message.error(res.message);
}
})
.catch(() => {
this.showError();
})
.finally(() => {
this.loading = false;
});
},
// 更新设备信息
update(val, key) {
if (key) {
this.loading = true;
delete val.editable;
}
axios
.post({
url: api.device.update,
data: {
deviceId: val.deviceId,
deviceName: val.deviceName,
roleId: val.roleId
}
})
.then((res) => {
if (res.code === 200) {
this.getData();
this.editVisible = false;
this.$message.success("修改成功");
} else {
this.$message.error(res.message);
}
})
.catch(() => {
this.showError();
})
.finally(() => {
this.loading = false;
this.editingKey = "";
});
},
// 清除设备记忆
clearMemory(record) {
this.clearMemoryLoading = true;
axios
.post({
url: api.message.delete,
data: { deviceId: record.deviceId }
})
.then((res) => {
if (res.code === 200) {
this.editVisible = false;
this.$message.success("记忆清除成功");
} else {
this.$message.error(res.message);
}
})
.catch(() => {
this.showError();
})
.finally(() => {
this.clearMemoryLoading = false;
});
},
// 在弹窗中编辑设备
editWithDialog(device) {
this.editVisible = true;
this.currentDevice = { ...device };
},
// 选择变更处理函数
handleSelectChange(value, key, type) {
// 获取编辑中的数据行
const data = this.editLine(key);
if (type === "role") {
const role = this.roleItems.find((item) => item.roleId === value);
const name = role ? role.roleName : "";
// 更新数据
data.target.roleId = value;
data.target.roleName = name;
this.data = [...this.data]; // 强制更新视图
}
},
// 获取角色名称
getRoleName(roleId) {
if (!roleId) return "";
const role = this.roleItems.find(r => r.roleId === roleId);
return role ? role.roleName : `角色ID:${roleId}`;
}
},
};
</script>
<style scoped>
/* 确保所有下拉框中的文本居中 */
>>> .ant-select-selection__rendered .ant-select-selection-selected-value {
text-align: center !important;
width: 100% !important;
}
/* 查询框中的下拉框保持默认对齐方式 */
>>> .table-search .ant-select-selection-selected-value {
text-align: left !important;
}
</style>

View File

@@ -0,0 +1,857 @@
<template>
<a-layout class="login-container">
<!-- 背景效果 -->
<div class="background-animation">
<div class="circuit-board"></div>
<div class="gradient-overlay"></div>
</div>
<!-- 主要内容区 -->
<a-row type="flex" justify="center" align="middle" class="full-height">
<a-col :xs="22" :sm="20" :md="18" :lg="16" :xl="14">
<a-card class="login-panel" :bordered="false">
<a-row type="flex">
<!-- 系统信息区 -->
<a-col :xs="24" :md="12" class="system-info">
<div class="system-logo">
<a-icon type="api" class="logo-icon" />
</div>
<a-typography-title level="{1}" class="system-title"
>小智 ESP32</a-typography-title
>
<a-typography-paragraph class="system-subtitle"
>智能物联网管理平台</a-typography-paragraph
>
</a-col>
<!-- 忘记密码表单区 -->
<a-col :xs="24" :md="12" class="login-form-container">
<div class="form-header">
<a-typography-title level="{3}" class="form-title"
>找回密码</a-typography-title
>
<a-typography-paragraph class="form-subtitle">
{{
currentStep === 1
? "请输入您的注册邮箱"
: currentStep === 2
? "请查看邮箱获取验证码"
: "请设置新密码"
}}
</a-typography-paragraph>
</div>
<!-- 步骤条 - 改用简化版本 -->
<div class="custom-steps">
<div
v-for="step in 3"
:key="step"
:class="[
'step-item',
currentStep >= step ? 'active' : '',
currentStep === step ? 'current' : '',
]"
>
<div class="step-number">{{ step }}</div>
<div class="step-title">
{{
step === 1
? "验证邮箱"
: step === 2
? "验证码"
: "重置密码"
}}
</div>
</div>
</div>
<!-- 步骤1输入邮箱 -->
<a-form-model
v-if="currentStep === 1"
ref="emailForm"
:model="formData"
:rules="formRules"
class="login-form"
>
<a-form-model-item prop="email">
<a-input
v-model="formData.email"
size="large"
placeholder="请输入您的注册邮箱"
class="custom-input"
>
<a-icon slot="prefix" type="mail" />
</a-input>
</a-form-model-item>
<a-button
type="primary"
:loading="loading"
class="login-button"
block
size="large"
@click="sendResetEmail"
>
<span>发送验证码</span>
<a-icon type="arrow-right" />
</a-button>
</a-form-model>
<!-- 步骤2输入验证码 -->
<a-form-model
v-if="currentStep === 2"
ref="codeForm"
:model="formData"
:rules="formRules"
class="login-form"
>
<a-alert
class="email-alert"
type="info"
show-icon
:message="`验证码已发送至 ${formData.email}`"
banner
/>
<a-form-model-item prop="verifyCode">
<a-input
v-model="formData.verifyCode"
size="large"
placeholder="请输入验证码"
class="custom-input"
>
<a-icon slot="prefix" type="safety-certificate" />
</a-input>
</a-form-model-item>
<a-row
type="flex"
justify="space-between"
align="middle"
class="form-options"
>
<a-col>
<a-button
type="link"
class="resend-btn"
@click="resendEmail"
:disabled="countdown > 0"
>
{{
countdown > 0 ? `重新发送(${countdown}s)` : "重新发送"
}}
</a-button>
</a-col>
</a-row>
<a-button
type="primary"
:loading="loading"
class="login-button"
block
size="large"
@click="verifyCode"
>
<span>验证</span>
<a-icon type="arrow-right" />
</a-button>
</a-form-model>
<!-- 步骤3重置密码 -->
<a-form-model
v-if="currentStep === 3"
ref="passwordForm"
:model="formData"
:rules="formRules"
class="login-form"
>
<a-form-model-item prop="password">
<a-input-password
v-model="formData.password"
size="large"
placeholder="请输入新密码"
class="custom-input"
>
<a-icon slot="prefix" type="lock" />
</a-input-password>
</a-form-model-item>
<a-form-model-item prop="confirmPassword">
<a-input-password
v-model="formData.confirmPassword"
size="large"
placeholder="请确认新密码"
class="custom-input"
>
<a-icon slot="prefix" type="lock" />
</a-input-password>
</a-form-model-item>
<a-button
type="primary"
:loading="loading"
class="login-button"
block
size="large"
@click="resetPassword"
>
<span>重置密码</span>
<a-icon type="arrow-right" />
</a-button>
</a-form-model>
<a-divider style="margin-top: 25px; margin-bottom: 15px" />
<div class="form-footer">
<router-link to="/login" class="back-link">
<a-icon type="arrow-left" /> 返回登录
</router-link>
</div>
</a-col>
</a-row>
</a-card>
</a-col>
</a-row>
</a-layout>
</template>
<script>
import axios from "@/services/axios";
import api from "@/services/api";
export default {
data() {
// 确认密码验证
const validateConfirmPassword = (rule, value, callback) => {
if (value !== this.formData.password) {
callback(new Error("两次输入的密码不一致"));
} else {
callback();
}
};
return {
currentStep: 1, // 当前步骤
loading: false,
formData: {
email: "",
verifyCode: "",
password: "",
confirmPassword: "",
},
formRules: {
email: [
{ required: true, message: "请输入邮箱地址", trigger: "blur" },
{ type: "email", message: "请输入有效的邮箱地址", trigger: "blur" },
],
verifyCode: [
{ required: true, message: "请输入验证码", trigger: "blur" },
{ len: 6, message: "验证码长度为6位", trigger: "blur" },
],
password: [
{ required: true, message: "请输入新密码", trigger: "blur" },
{ min: 6, message: "密码长度不能少于6个字符", trigger: "blur" },
],
confirmPassword: [
{ required: true, message: "请确认新密码", trigger: "blur" },
{ validator: validateConfirmPassword, trigger: "blur" },
],
},
countdown: 0,
countdownTimer: null,
};
},
beforeDestroy() {
if (this.countdownTimer) {
clearInterval(this.countdownTimer);
}
},
methods: {
// 发送重置邮件
sendResetEmail() {
this.$refs.emailForm.validate((valid) => {
if (valid) {
this.loading = true;
// 调用后端API发送重置邮件
axios
.jsonPost({
url: api.user.sendEmailCaptcha,
data: {
email: this.formData.email,
type: "forget",
},
})
.then((res) => {
this.loading = false;
if (res.code === 200) {
this.$message.success("验证码已发送到您的邮箱");
this.currentStep = 2;
this.startCountdown();
} else {
this.showError(res.message);
}
})
.catch((err) => {
this.loading = false;
this.$message.error("服务器错误,请稍后重试");
console.error(err);
});
}
});
},
// 重新发送邮件
resendEmail() {
if (this.countdown > 0) return;
this.loading = true;
// 调用后端API重新发送重置邮件
axios
.jsonPost({
url: api.user.sendEmailCaptcha,
data: {
email: this.formData.email,
type: "forget",
},
})
.then((res) => {
this.loading = false;
if (res.code === 200) {
this.$message.success("验证码已重新发送到您的邮箱");
this.startCountdown();
} else {
this.showError(res.message);
}
})
.catch((err) => {
this.loading = false;
this.$message.error("服务器错误,请稍后重试");
console.error(err);
});
},
// 验证验证码
verifyCode() {
this.$refs.codeForm.validate((valid) => {
if (valid) {
this.loading = true;
// 调用后端API验证验证码
axios
.get({
url: api.user.checkCaptcha,
data: {
code: this.formData.verifyCode,
email: this.formData.email,
type: "forget",
},
})
.then((res) => {
this.loading = false;
if (res.code === 200) {
this.$message.success("验证成功");
this.currentStep = 3;
} else {
this.showError(res.message);
}
})
.catch((err) => {
this.loading = false;
this.$message.error("服务器错误,请稍后重试");
console.error(err);
});
}
});
},
// 重置密码
resetPassword() {
this.$refs.passwordForm.validate((valid) => {
if (valid) {
this.loading = true;
// 调用后端API重置密码
axios
.jsonPost({
url: api.user.update,
data: {
email: this.formData.email,
code: this.formData.verifyCode,
password: this.formData.password,
},
})
.then((res) => {
this.loading = false;
if (res.code === 200) {
this.$message.success("密码重置成功");
setTimeout(() => {
this.$router.push("/login");
}, 1500);
} else {
this.showError(res.message);
}
})
.catch((err) => {
this.loading = false;
this.$message.error("服务器错误,请稍后重试");
console.error(err);
});
}
});
},
// 开始倒计时
startCountdown() {
this.countdown = 60;
if (this.countdownTimer) {
clearInterval(this.countdownTimer);
}
this.countdownTimer = setInterval(() => {
if (this.countdown > 0) {
this.countdown--;
} else {
clearInterval(this.countdownTimer);
}
}, 1000);
},
},
};
</script>
<style lang="scss" scoped>
// 继承登录页面的样式
.login-container {
position: relative;
width: 100%;
height: 100vh;
background-color: #001529;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
overflow: hidden;
}
.full-height {
height: 100vh;
display: flex;
align-items: center;
}
// 背景效果
.background-animation {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.circuit-board {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.07) 1px,
transparent 1px
),
linear-gradient(0deg, rgba(255, 255, 255, 0.07) 1px, transparent 1px);
background-size: 20px 20px;
background-position: center center;
}
.gradient-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle at 30% 30%, #1d39c4 0%, transparent 70%),
radial-gradient(circle at 70% 70%, #722ed1 0%, transparent 70%);
opacity: 0.6;
}
// 登录面板
.login-panel {
background: transparent !important;
border-radius: 16px !important;
overflow: hidden;
border: none !important;
z-index: 10;
position: relative;
box-shadow: none;
::v-deep .ant-card-body {
padding: 0;
}
}
// 系统信息区
.system-info {
padding: 40px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: linear-gradient(
135deg,
rgba(29, 57, 196, 0.9) 0%,
rgba(114, 46, 209, 0.9) 100%
);
color: #ffffff;
position: relative;
overflow: hidden;
min-height: 360px;
border-radius: 16px 0 0 16px;
&::before {
content: "";
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: repeating-linear-gradient(
45deg,
rgba(255, 255, 255, 0.05),
rgba(255, 255, 255, 0.05) 10px,
transparent 10px,
transparent 20px
);
animation: move-bg 20s linear infinite;
}
}
@keyframes move-bg {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(50px, 50px);
}
}
.system-logo {
margin-bottom: 30px;
position: relative;
z-index: 1;
}
.logo-icon {
font-size: 64px;
color: #ffffff;
background: rgba(255, 255, 255, 0.2);
padding: 20px;
border-radius: 50%;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
}
.system-title,
.system-subtitle {
color: #ffffff !important;
text-align: center;
position: relative;
z-index: 1;
}
.system-title {
margin-bottom: 10px !important;
font-weight: 600;
}
.system-subtitle {
color: rgba(255, 255, 255, 0.8) !important;
margin-bottom: 0;
}
// 登录表单容器
.login-form-container {
padding: 40px;
background-color: rgba(18, 24, 38, 0.95);
position: relative;
min-height: 360px;
display: flex;
flex-direction: column;
justify-content: center;
border-radius: 0 16px 16px 0;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.form-header {
margin-bottom: 25px;
text-align: center;
}
.form-title {
color: #ffffff !important;
margin-bottom: 10px !important;
font-weight: 500;
}
.form-subtitle {
color: rgba(255, 255, 255, 0.6) !important;
margin-bottom: 0;
}
// 自定义步骤条
.custom-steps {
display: flex;
justify-content: space-between;
margin-bottom: 30px;
position: relative;
&::before {
content: "";
position: absolute;
top: 16px;
left: 30px;
right: 30px;
height: 2px;
background-color: rgba(255, 255, 255, 0.2);
z-index: 1;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
z-index: 2;
flex: 1;
.step-number {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: rgb(255, 255, 255);
color: rgb(0, 0, 0);
display: flex;
align-items: center;
justify-content: center;
font-weight: 500;
margin-bottom: 8px;
border: 2px solid transparent;
transition: all 0.3s;
}
.step-title {
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
text-align: center;
transition: all 0.3s;
white-space: nowrap;
}
&.active {
.step-number {
background-color: rgb(255, 255, 255);
color: #1890ff;
border-color: #1890ff;
}
.step-title {
color: rgba(255, 255, 255, 0.8);
}
}
&.current {
.step-number {
background-color: #1890ff;
color: #ffffff;
}
.step-title {
color: #ffffff;
font-weight: 500;
}
}
}
}
.login-form {
width: 100%;
position: relative;
z-index: 2;
}
// 输入框样式
.custom-input {
border-radius: 8px !important;
height: 46px;
overflow: hidden;
background: linear-gradient(
to right,
rgba(40, 48, 65, 0.6),
rgba(40, 48, 65, 0.8)
) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1) !important;
::v-deep input {
background: transparent !important;
color: #ffffff !important;
height: 44px;
padding-left: 15px !important;
&::placeholder {
color: rgba(255, 255, 255, 0.5) !important;
font-weight: 300;
}
}
::v-deep .ant-input-prefix {
color: #13c2c2 !important;
margin-right: 12px !important;
font-size: 18px !important;
opacity: 0.9;
}
&:hover {
border-color: rgba(24, 144, 255, 0.7) !important;
background: linear-gradient(
to right,
rgba(40, 48, 65, 0.7),
rgba(40, 48, 65, 0.9)
) !important;
}
&:focus,
&:focus-within {
border-color: #1890ff !important;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2) !important;
background: rgba(40, 48, 65, 0.9) !important;
}
}
// 表单选项
.form-options {
margin-bottom: 20px;
position: relative;
z-index: 2;
}
// 登录按钮
.login-button {
height: 46px;
border-radius: 8px;
font-size: 16px;
background: linear-gradient(90deg, #1890ff, #13c2c2);
border: none;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
position: relative;
z-index: 2;
span {
margin-right: 8px;
font-weight: 500;
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(24, 144, 255, 0.3);
background: linear-gradient(
90deg,
lighten(#1890ff, 5%),
lighten(#13c2c2, 5%)
);
}
}
// 邮件提示
.email-alert {
margin-bottom: 24px;
::v-deep .ant-alert-message {
color: #1890ff;
}
}
// 重新发送按钮
.resend-btn {
color: #1890ff;
padding: 0;
height: auto;
&:hover:not([disabled]) {
color: lighten(#1890ff, 10%);
}
&[disabled] {
color: rgba(255, 255, 255, 0.3);
}
}
// 页脚
.form-footer {
text-align: center;
margin-top: 0;
position: relative;
z-index: 2;
}
.back-link {
color: rgba(255, 255, 255, 0.7);
transition: all 0.3s ease;
&:hover {
color: #1890ff;
}
}
// 响应式调整
@media (max-width: 768px) {
.system-info,
.login-form-container {
padding: 30px 20px;
min-height: auto;
}
.system-info {
border-radius: 16px 16px 0 0;
}
.login-form-container {
border-radius: 0 0 16px 16px;
}
.custom-steps {
.step-title {
font-size: 10px;
}
}
}
@media (max-width: 480px) {
.custom-steps {
&::before {
left: 16px;
right: 16px;
}
.step-item {
.step-number {
width: 28px;
height: 28px;
font-size: 12px;
}
.step-title {
font-size: 10px;
}
}
}
}
</style>

View File

@@ -0,0 +1,651 @@
<template>
<a-layout class="login-container">
<!-- 背景效果 -->
<div class="background-animation">
<div class="circuit-board"></div>
<div class="gradient-overlay"></div>
</div>
<!-- 主要内容区 -->
<a-row type="flex" justify="center" align="middle" class="full-height">
<a-col :xs="22" :sm="20" :md="18" :lg="16" :xl="14">
<a-card class="login-panel" :bordered="false">
<a-row type="flex">
<!-- 系统信息区 -->
<a-col :xs="24" :md="12" class="system-info">
<div class="system-logo">
<a-icon type="api" class="logo-icon" />
</div>
<a-typography-title level={1} class="system-title">小智 ESP32</a-typography-title>
<a-typography-paragraph class="system-subtitle">智能物联网管理平台</a-typography-paragraph>
</a-col>
<!-- 登录表单区 -->
<a-col :xs="24" :md="12" class="login-form-container">
<a-form-model
id="login"
ref="loginForm"
:model="loginForm"
:rules="loginRules"
class="login-form"
@submit="handleSubmit"
>
<div class="form-header">
<a-typography-paragraph class="form-subtitle">欢迎回来请登录您的账户</a-typography-paragraph>
</div>
<a-form-model-item prop="username">
<a-input
v-model="loginForm.username"
size="large"
placeholder="用户名"
class="custom-input"
>
<a-icon slot="prefix" type="user" />
</a-input>
</a-form-model-item>
<a-form-model-item prop="password">
<a-input-password
v-model="loginForm.password"
size="large"
type="password"
placeholder="密码"
class="custom-input"
>
<a-icon slot="prefix" type="lock" />
</a-input-password>
</a-form-model-item>
<a-row type="flex" justify="space-between" align="middle" class="form-options">
<a-col>
<a-checkbox v-model="loginForm.rememberMe" class="remember-me">
记住我
</a-checkbox>
</a-col>
<a-col>
<router-link to="forget" class="forgot-password">
忘记密码?
</router-link>
</a-col>
</a-row>
<a-button
type="primary"
html-type="submit"
class="login-button"
:loading="loading"
block
size="large"
>
<span>登录</span>
<a-icon type="arrow-right" />
</a-button>
<!-- 添加注册按钮 -->
<div class="register-container">
<span class="register-text">还没有账户?</span>
<router-link to="register" class="register-link">
立即注册
</router-link>
</div>
</a-form-model>
<a-divider style="margin-top: 25px; margin-bottom: 15px;" />
<a-typography-paragraph class="login-footer">
© {{ new Date().getFullYear() }} 小智ESP32物联网平台
</a-typography-paragraph>
</a-col>
</a-row>
</a-card>
</a-col>
</a-row>
<!-- 技术信息卡片 - 优化尺寸 -->
<a-row type="flex" justify="center" class="tech-cards-row">
<a-col :xs="22" :md="16" :xl="12">
<a-row type="flex" justify="space-around" :gutter="[16, 16]">
<a-col :xs="7" :sm="7" :md="7">
<div class="tech-card">
<a-icon type="dashboard" />
<span>实时监控</span>
</div>
</a-col>
<a-col :xs="7" :sm="7" :md="7">
<div class="tech-card">
<a-icon type="setting" />
<span>系统配置</span>
</div>
</a-col>
<a-col :xs="7" :sm="7" :md="7">
<div class="tech-card">
<a-icon type="cloud-server" />
<span>云端管理</span>
</div>
</a-col>
</a-row>
</a-col>
</a-row>
<!-- 浮动图标 (保留少量装饰性元素) -->
<div class="floating-icons">
<div class="icon-item" v-for="(icon, index) in icons" :key="index"
:style="{ left: icon.left + '%', top: icon.top + '%', animationDelay: icon.delay + 's' }">
<a-icon :type="icon.type" />
</div>
</div>
</a-layout>
</template>
<script>
import axios from '@/services/axios'
import api from '@/services/api'
import Cookies from 'js-cookie'
import { encrypt, decrypt } from '@/utils/jsencrypt'
export default {
data() {
return {
loginForm: {
username: '',
password: '',
rememberMe: false
},
loginRules: {
username: [{ required: true, message: '请输入用户名!', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码!', trigger: 'blur' }]
},
loading: false,
icons: [
{ type: 'wifi', left: 10, top: 20, delay: 0 },
{ type: 'cloud', left: 85, top: 15, delay: 1.5 },
{ type: 'mobile', left: 20, top: 70, delay: 2.3 },
{ type: 'bulb', left: 95, top: 90, delay: 0.8 },
{ type: 'robot', left: 40, top: 30, delay: 1.2 },
{ type: 'thunderbolt', left: 65, top: 80, delay: 2 }
]
}
},
mounted() {
this.getCookie()
},
methods: {
getCookie() {
const username = Cookies.get('username')
const password = Cookies.get('rememberMe')
this.loginForm = {
username: username === undefined ? this.loginForm.username : username,
password: password === undefined ? this.loginForm.password : decrypt(password),
rememberMe: password === undefined ? false : Boolean(password)
}
},
handleSubmit(e) {
e.preventDefault()
this.$refs.loginForm.validate(valid => {
if (valid) {
this.loading = true
axios
.jsonPost({
url: api.user.login,
data: {
...this.loginForm
}
}).then(res => {
this.loading = false
if (res.code === 200) {
Cookies.set('userInfo', JSON.stringify(res.data), { expires: 30 })
if (this.loginForm.rememberMe) {
Cookies.set('username', this.loginForm.username, { expires: 30 })
Cookies.set('rememberMe', encrypt(this.loginForm.password), { expires: 30 })
} else {
Cookies.remove('username')
Cookies.remove('rememberMe')
}
this.$store.commit('USER_INFO', res.data)
this.$router.push('/dashboard')
} else {
this.$message.error(res.message)
}
}).catch(() => {
this.loading = false
this.showError();
})
}
})
}
}
}
</script>
<style lang="scss" scoped>
// 全局变量
$primary-color: #1890ff;
$dark-color: #001529;
$light-color: #ffffff;
$accent-color: #13c2c2;
$gradient-start: #1d39c4;
$gradient-end: #722ed1;
// 通用样式
%full-abs {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
%flex-center {
display: flex;
align-items: center;
justify-content: center;
}
%z-layer {
position: relative;
z-index: 2;
}
// 主容器
.login-container {
position: relative;
width: 100%;
height: 100vh;
background-color: $dark-color;
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
overflow: hidden;
}
.full-height {
height: 100vh;
display: flex;
align-items: center;
}
// 背景效果
.background-animation {
@extend %full-abs;
z-index: 0;
}
.circuit-board {
@extend %full-abs;
background:
linear-gradient(90deg, rgba(255,255,255,.07) 1px, transparent 1px),
linear-gradient(0deg, rgba(255,255,255,.07) 1px, transparent 1px);
background-size: 20px 20px;
background-position: center center;
}
.gradient-overlay {
@extend %full-abs;
background: radial-gradient(circle at 30% 30%, $gradient-start 0%, transparent 70%),
radial-gradient(circle at 70% 70%, $gradient-end 0%, transparent 70%);
opacity: 0.6;
}
// 浮动图标
.floating-icons {
@extend %full-abs;
z-index: 1;
pointer-events: none;
}
.icon-item {
position: absolute;
font-size: 24px;
color: rgba(255, 255, 255, 0.2);
animation: float 6s ease-in-out infinite;
i {
font-size: 24px;
}
}
@keyframes float {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-15px) rotate(5deg); }
}
// 登录面板
.login-panel {
background: transparent !important;
border-radius: 16px !important;
overflow: hidden;
border: none !important;
z-index: 10;
position: relative;
box-shadow: none;
::v-deep .ant-card-body {
padding: 0;
}
}
// 系统信息区
.system-info {
padding: 40px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: linear-gradient(135deg, rgba(29, 57, 196, 0.9) 0%, rgba(114, 46, 209, 0.9) 100%);
color: $light-color;
position: relative;
overflow: hidden;
min-height: 360px;
border-radius: 16px 0 0 16px;
&::before {
content: '';
@extend %full-abs;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: repeating-linear-gradient(
45deg,
rgba(255, 255, 255, 0.05),
rgba(255, 255, 255, 0.05) 10px,
transparent 10px,
transparent 20px
);
animation: move-bg 20s linear infinite;
}
}
@keyframes move-bg {
0% { transform: translate(0, 0); }
100% { transform: translate(50px, 50px); }
}
.system-logo {
margin-bottom: 30px;
@extend %z-layer;
}
.logo-icon {
font-size: 64px;
color: $light-color;
background: rgba(255, 255, 255, 0.2);
padding: 20px;
border-radius: 50%;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
}
.system-title, .system-subtitle {
color: $light-color !important;
text-align: center;
@extend %z-layer;
}
.system-title {
margin-bottom: 10px !important;
font-weight: 600;
}
.system-subtitle {
color: rgba(255, 255, 255, 0.8) !important;
margin-bottom: 0;
}
// 登录表单容器
.login-form-container {
padding: 40px;
background-color: rgba(18, 24, 38, 0.95);
position: relative;
min-height: 360px;
display: flex;
flex-direction: column;
justify-content: center;
border-radius: 0 16px 16px 0;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.form-header {
margin-bottom: 25px;
text-align: center;
@extend %z-layer;
::v-deep h2.ant-typography {
color: $light-color;
font-size: 24px;
margin-bottom: 10px;
font-weight: 500;
letter-spacing: 1px;
position: relative;
display: inline-block;
&::after {
content: '';
position: absolute;
bottom: -5px;
left: 50%;
transform: translateX(-50%);
width: 40px;
height: 2px;
background: $accent-color;
}
}
}
.form-subtitle {
color: rgba(255, 255, 255, 0.6) !important;
text-align: center;
margin-bottom: 0;
}
.login-form {
width: 100%;
@extend %z-layer;
}
// 输入框样式
.custom-input {
border-radius: 8px !important;
height: 46px;
overflow: hidden;
background: linear-gradient(to right, rgba(40, 48, 65, 0.6), rgba(40, 48, 65, 0.8)) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1) !important;
input {
background: transparent !important;
color: $light-color !important;
height: 44px;
padding-left: 15px !important;
&::placeholder {
color: rgba(255, 255, 255, 0.5) !important;
font-weight: 300;
}
}
.ant-input-prefix {
color: $accent-color !important;
margin-right: 12px !important;
font-size: 18px !important;
opacity: 0.9;
}
&:hover {
border-color: rgba($primary-color, 0.7) !important;
background: linear-gradient(to right, rgba(40, 48, 65, 0.7), rgba(40, 48, 65, 0.9)) !important;
}
&:focus, &:focus-within {
border-color: $primary-color !important;
box-shadow: 0 0 0 2px rgba($primary-color, 0.2) !important;
background: rgba(40, 48, 65, 0.9) !important;
}
.ant-input-password-icon {
color: rgba(255, 255, 255, 0.7) !important;
&:hover { color: $accent-color !important; }
}
.ant-input-affix-wrapper {
background: transparent !important;
border: none !important;
input { background: transparent !important; }
}
}
// 表单选项
.form-options {
margin-bottom: 20px;
@extend %z-layer;
}
.remember-me {
color: rgba(255, 255, 255, 0.8);
::v-deep .ant-checkbox-inner {
background-color: rgba(30, 38, 55, 0.8);
border-color: rgba(255, 255, 255, 0.3);
width: 16px;
height: 16px;
.ant-checkbox-checked & {
background-color: $primary-color;
border-color: $primary-color;
}
}
}
.forgot-password {
color: $accent-color;
font-size: 14px;
&:hover {
text-decoration: underline;
color: lighten($accent-color, 10%);
}
}
// 登录按钮
.login-button {
height: 46px;
border-radius: 8px;
font-size: 16px;
background: linear-gradient(90deg, $primary-color, $accent-color);
border: none;
@extend %flex-center;
transition: all 0.3s ease;
@extend %z-layer;
span {
margin-right: 8px;
font-weight: 500;
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(24, 144, 255, 0.3);
background: linear-gradient(90deg, lighten($primary-color, 5%), lighten($accent-color, 5%));
}
}
// 注册链接容器
.register-container {
text-align: center;
margin-top: 20px;
@extend %z-layer;
}
.register-text {
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
margin-right: 8px;
}
.register-link {
color: $accent-color;
font-size: 14px;
font-weight: 500;
&:hover {
text-decoration: underline;
color: lighten($accent-color, 10%);
}
}
// 页脚
.login-footer {
color: rgba(255, 255, 255, 0.4) !important;
font-size: 12px;
text-align: center;
margin-bottom: 0;
@extend %z-layer;
}
// 技术信息卡片
.tech-cards-row {
position: absolute;
bottom: 30px;
width: 100%;
z-index: 5;
}
.tech-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(18, 24, 38, 0.75);
backdrop-filter: blur(10px);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
color: $light-color;
transition: all 0.3s ease;
padding: 10px;
height: 80px;
width: 180px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
margin: 0 auto;
i {
font-size: 20px;
margin-bottom: 5px;
color: $accent-color;
}
span { font-size: 12px; }
&:hover {
transform: translateY(-3px);
background: rgba(24, 36, 52, 0.85);
border-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25);
}
}
// 响应式调整
@media (max-width: 768px) {
.system-info, .login-form-container {
padding: 30px 20px;
min-height: auto;
}
.system-info { border-radius: 16px 16px 0 0; }
.login-form-container { border-radius: 0 0 16px 16px; }
.tech-cards-row {
position: relative;
bottom: auto;
margin-top: -20px;
margin-bottom: 30px;
}
.tech-card {
height: 70px;
width: 70px;
i { font-size: 18px; }
span { font-size: 11px; }
}
}
</style>

View File

@@ -0,0 +1,269 @@
<template>
<a-layout>
<a-layout-content>
<div class="layout-content-margin">
<!-- 查询框 -->
<div class="table-search">
<a-form layout="horizontal" :colon="false" :labelCol="{ span: 7 }" :wrapperCol="{ span: 16 }">
<a-row class="filter-flex">
<a-col :xxl="6" :xl="6" :lg="12" :md="12" :xs="24" v-for="item in queryFilter" :key="item.index">
<a-form-item :label="item.label">
<a-input-search v-model="query[item.index]" placeholder="请输入" allow-clear @search="getData()" />
</a-form-item>
</a-col>
<a-col :xxl="6" :xl="6" :lg="12" :md="12" :xs="24">
<a-form-item label="消息发送方">
<a-select v-model="query.sender" @change="getData()">
<a-select-option v-for="item in senderItems" :key="item.value">
<span>{{ item.label }}</span>
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xxl="5" :xl="5" :lg="12" :md="12" :xs="24">
<a-form-item label="对话日期">
<a-range-picker :ranges="{
今天: [moment().startOf('day'), moment().endOf('day')],
本月: [moment().startOf('month'), moment().endOf('month')],
}" :allowClear="false" :style="{ width: 100 }" v-model="timeRange" format="MM-DD"
@change="getData()" />
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
<!-- 表格数据 -->
<a-card title="查询表格" :bodyStyle="{ padding: 0 }" :bordered="false">
<a-table rowKey="messageId" :columns="tableColumns" :data-source="data" :loading="loading"
:pagination="pagination" :scroll="{ x: 800 }" size="middle">
<templace slot="roleName" slot-scope="text, record">
<a-tooltip :title="record.roleDesc" :mouseEnterDelay="0.5" placement="leftTop">
<span v-if="text">{{ text }}</span>
<span v-else style="padding: 0 50px">&nbsp;&nbsp;&nbsp;</span>
</a-tooltip>
</templace>
<templace slot="message" slot-scope="text, record">
<a-tooltip :title="text" :mouseEnterDelay="0.5" placement="leftTop">
<span v-if="text">{{ text }}</span>
<span v-else style="padding: 0 50px">&nbsp;&nbsp;&nbsp;</span>
</a-tooltip>
</templace>
<template slot="audioPath" slot-scope="text, record">
<div v-if="text && text.trim()" class="audio-player-container">
<audio-player :audio-url="text" />
</div>
<span v-else>无音频</span>
</template>
<a-button slot="footer" :loading="exportLoading" :disabled="true" @click="exportExcel('message')">
导出
</a-button>
<template slot="operation" slot-scope="text, record">
<a-space>
<!-- <a href="javascript:;" @click="edit(record.messageId)" :disabled="true">详情</a> -->
<a-popconfirm
title="确定要删除此消息吗?"
ok-text="确定"
cancel-text="取消"
@confirm="deleteMessage(record)"
>
<a href="javascript:;" style="color: #ff4d4f">删除</a>
</a-popconfirm>
</a-space>
</template>
</a-table>
</a-card>
</div>
</a-layout-content>
<a-back-top />
</a-layout>
</template>
<script>
import axios from "@/services/axios";
import api from "@/services/api";
import mixin from "@/mixins/index";
import AudioPlayer from "@/components/AudioPlayer.vue";
import EventBus from "@/utils/eventBus";
export default {
mixins: [mixin],
components: {
AudioPlayer,
},
data() {
return {
// 查询框
query: {
sender: "",
},
queryFilter: [
{
label: "设备编号",
value: "",
index: "deviceId",
},
{
label: "设备名称",
value: "",
index: "deviceName",
},
],
senderItems: [
{
label: "全部",
value: "",
key: "",
},
{
label: "用户",
value: "user",
key: "1",
},
{
label: "AI",
value: "assistant",
key: "0",
},
],
// 表格数据
tableColumns: [
{
title: "设备编号",
dataIndex: "deviceId",
width: 160,
align: "center",
},
{
title: "设备名称",
dataIndex: "deviceName",
width: 100,
align: "center",
},
{
title: "模型角色",
dataIndex: "roleName",
scopedSlots: { customRender: "roleName" },
width: 100,
align: "center",
},
{
title: "消息发送方",
dataIndex: "sender",
width: 100,
align: "center",
customRender: (text) => {
return text === "user" ? "用户" : "AI";
},
},
{
title: "消息内容",
dataIndex: "message",
scopedSlots: { customRender: "message" },
align: "center",
width: 200,
ellipsis: true,
},
{
title: "语音",
dataIndex: "audioPath",
scopedSlots: { customRender: "audioPath" },
width: 400,
align: "center",
},
{
title: "对话时间",
dataIndex: "createTime",
scopedSlots: { customRender: "createTime" },
width: 150,
align: "center",
},
{
title: "操作",
dataIndex: "operation",
scopedSlots: { customRender: "operation" },
width: 110,
fixed: "right",
align: "center",
},
],
data: [],
};
},
mounted() {
this.getData();
},
beforeRouteLeave(to, from, next) {
// 在路由离开前触发全局事件,通知所有音频播放器停止播放
EventBus.$emit('stop-all-audio');
next();
},
beforeDestroy() {
// 在组件销毁前触发全局事件,通知所有音频播放器停止播放
EventBus.$emit('stop-all-audio');
},
methods: {
/* 查询参数列表 */
getData() {
this.loading = true;
axios
.get({
url: api.message.query,
data: {
start: this.pagination.page,
limit: this.pagination.pageSize,
...this.query,
startTime: this.moment(this.timeRange[0]).format("YYYY-MM-DD HH:mm:ss"),
endTime: this.moment(this.timeRange[1]).format("YYYY-MM-DD HH:mm:ss"),
},
})
.then((res) => {
if (res.code === 200) {
this.data = res.data.list;
this.pagination.total = res.data.total;
} else {
this.showError(res.message);
}
})
.catch(() => {
this.showError();
})
.finally(() => {
this.loading = false
})
},
/* 删除消息 */
deleteMessage(record) {
this.loading = true;
axios
.post({
url: api.message.delete,
data: {
messageId: record.messageId,
},
})
.then((res) => {
if (res.code === 200) {
this.$message.success("删除成功");
this.getData();
} else {
this.showError(res.message);
}
})
.catch(() => {
this.showError();
})
.finally(() => {
this.loading = false;
});
},
},
};
</script>
<style scoped>
.audio-player-container {
position: relative;
width: 100%;
overflow: hidden;
z-index: 1; /* 确保不会超过固定列 */
}
</style>

View File

@@ -0,0 +1,475 @@
<template>
<a-layout>
<a-layout-content>
<div class="layout-content-margin">
<!-- 查询框 -->
<div class="table-search">
<a-form layout="horizontal" :colon="false" :labelCol="{ span: 6 }" :wrapperCol="{ span: 16 }">
<a-row class="filter-flex">
<a-col :xl="6" :lg="12" :xs="24">
<a-form-item label="模板名称">
<a-input-search v-model="query.templateName" placeholder="请输入" allow-clear @search="getData()" />
</a-form-item>
</a-col>
<a-col :xl="6" :lg="12" :xs="24">
<a-form-item label="分类">
<a-select v-model="query.category" @change="getData()" placeholder="请选择分类">
<a-select-option key="" value="">
<span>全部</span>
</a-select-option>
<a-select-option v-for="item in categoryOptions" :key="item.value" :value="item.value">
{{ item.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
<!-- 表格数据 -->
<a-card title="提示词模板列表" :bodyStyle="{ padding: 0 }" :bordered="false">
<!-- 将创建按钮移至卡片标题栏右侧 -->
<a-button slot="extra" type="primary" @click="showCreateDialog">
<a-icon type="plus" />创建模板
</a-button>
<a-table rowKey="templateId" :columns="tableColumns" :dataSource="data" :loading="loading"
:pagination="pagination" :scroll="{ x: 800 }" size="middle">
<!-- 模板内容 -->
<template slot="templateContent" slot-scope="text, record">
<a-tooltip :title="text" :mouseEnterDelay="0.5" placement="leftTop">
<span v-if="text">{{ text }}</span>
<span v-else style="padding: 0 50px">&nbsp;&nbsp;&nbsp;</span>
</a-tooltip>
</template>
<!-- 添加默认状态列的自定义渲染 -->
<template slot="isDefault" slot-scope="text">
<a-tag v-if="text == 1" color="green">默认</a-tag>
<span v-else>-</span>
</template>
<!-- 操作 -->
<template slot="operation" slot-scope="text, record">
<a-space>
<a @click="showEditDialog(record)">编辑</a>
<!-- 该处预览后续可以尝试点击后与之对话用于调试 -->
<!-- todo -->
<a @click="previewTemplate(record)">预览</a>
<a-popconfirm title="确定要删除此模板吗?" ok-text="确定" cancel-text="取消" @confirm="deleteTemplate(record)">
<a style="color: #ff4d4f">删除</a>
</a-popconfirm>
<a v-if="record.isDefault != 1" @click="setAsDefault(record)">设为默认</a>
</a-space>
</template>
</a-table>
</a-card>
</div>
</a-layout-content>
<!-- 创建/编辑模板对话框 -->
<a-modal :visible="dialogVisible" :title="isEdit ? '编辑提示词模板' : '创建提示词模板'" @ok="handleSubmit" @cancel="closeDialog"
:confirmLoading="submitLoading" width="800px" :maskClosable="false">
<a-form :form="form" :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
<a-form-item label="模板名称">
<a-input v-decorator="[
'templateName',
{
rules: [{ required: true, message: '请输入模板名称' }],
},
]" placeholder="请输入模板名称" />
</a-form-item>
<a-form-item label="模板分类">
<a-select v-decorator="[
'category',
{
rules: [{ required: true, message: '请选择模板分类' }],
},
]" placeholder="请选择模板分类" @change="handleCategoryChange">
<a-select-option v-for="category in categoryOptions" :key="category.value" :value="category.value">
{{ category.label }}
</a-select-option>
<a-select-option value="custom">自定义分类</a-select-option>
</a-select>
</a-form-item>
<!-- 自定义分类输入框,当选择"自定义分类"时显示 -->
<a-form-item label="自定义分类" v-if="showCustomCategory">
<a-input v-decorator="[
'customCategory',
{
rules: [{ required: true, message: '请输入自定义分类名称' }],
},
]" placeholder="请输入自定义分类名称" />
</a-form-item>
<a-form-item label="模板描述">
<a-input v-decorator="['templateDesc']" placeholder="请输入模板描述(简短描述角色性格等)" />
</a-form-item>
<a-form-item label="是否默认">
<a-switch v-decorator="[
'isDefault',
{ valuePropName: 'checked' },
]" />
<span style="margin-left: 8px; color: #999">设为默认后将优先显示此模板</span>
</a-form-item>
<a-form-item label="模板内容">
<a-textarea v-decorator="[
'templateContent',
{
rules: [{ required: true, message: '请输入模板内容' }],
},
]" :rows="12" placeholder="请输入模板内容,描述角色的特点、知识背景和行为方式等" />
</a-form-item>
</a-form>
</a-modal>
<!-- 预览模板对话框 -->
<a-modal :visible="previewVisible" title="模板预览" @cancel="previewVisible = false" :footer="null" width="700px">
<div v-if="currentTemplate">
<h3>{{ currentTemplate.templateName }}</h3>
<p v-if="currentTemplate.templateDesc" class="template-desc">{{ currentTemplate.templateDesc }}</p>
<a-divider />
<div class="template-preview-content">
{{ currentTemplate.templateContent }}
</div>
</div>
</a-modal>
</a-layout>
</template>
<script>
import axios from "@/services/axios";
import api from "@/services/api";
import mixin from "@/mixins/index";
export default {
name: "PromptTemplate",
mixins: [mixin],
data() {
return {
// 查询参数
query: {
templateName: "",
category: ""
},
// 表格列定义
tableColumns: [
{
title: "模板名称",
dataIndex: "templateName",
scopedSlots: { customRender: "templateName" },
width: 100,
align: "center",
ellipsis: true,
},
{
title: "分类",
dataIndex: "category",
width: 120,
align: "center",
},
{
title: "模板内容",
dataIndex: "templateContent",
scopedSlots: { customRender: "templateContent" },
width: 200,
ellipsis: true,
align: "center",
},
// 添加默认标识列
{
title: "默认",
dataIndex: "isDefault",
key: "isDefault",
width: 80,
align: "center",
scopedSlots: { customRender: "isDefault" },
align: "center",
},
{
title: "创建时间",
dataIndex: "createTime",
width: 180,
align: "center",
},
{
title: "操作",
dataIndex: "operation",
scopedSlots: { customRender: "operation" },
width: 220,
fixed: "right",
align: "center",
},
],
// 分类选项
categoryOptions: [
{ label: "基础角色", value: "基础角色" },
{ label: "专业角色", value: "专业角色" },
{ label: "社交角色", value: "社交角色" },
{ label: "娱乐角色", value: "娱乐角色" }
],
// 表格数据
data: [],
// 对话框相关
dialogVisible: false,
isEdit: false,
submitLoading: false,
showCustomCategory: false,
form: null,
// 预览相关
previewVisible: false,
currentTemplate: null,
};
},
created() {
this.form = this.$form.createForm(this);
},
mounted() {
this.getData();
},
methods: {
// 获取模板数据
getData() {
this.loading = true;
axios.get({
url: api.template.query,
data: {
...this.query,
start: this.pagination.page,
limit: this.pagination.pageSize
}
})
.then(res => {
if (res.code === 200) {
this.data = res.data.list || [];
this.pagination.total = res.data.total || 0;
// 收集所有分类
const categories = new Set();
this.data.forEach(item => {
if (item.category) {
categories.add(item.category);
}
});
// 更新分类选项
const defaultCategories = ["基础角色", "专业角色", "社交角色", "娱乐角色"];
const customCategories = [...categories].filter(c => !defaultCategories.includes(c));
if (customCategories.length > 0) {
this.categoryOptions = [
...defaultCategories.map(c => ({ label: c, value: c })),
...customCategories.map(c => ({ label: c, value: c }))
];
}
} else {
this.$message.error(res.message || "获取模板列表失败");
}
})
.catch(() => {
this.showError();
})
.finally(() => {
this.loading = false;
});
},
// 显示创建对话框
showCreateDialog() {
this.isEdit = false;
this.dialogVisible = true;
this.showCustomCategory = false;
// 重置表单
this.$nextTick(() => {
this.form.resetFields();
this.form.setFieldsValue({
category: "基础角色",
isDefault: false
});
});
},
// 显示编辑对话框
showEditDialog(record) {
this.isEdit = true;
this.dialogVisible = true;
this.currentTemplate = { ...record };
// 检查是否需要显示自定义分类输入框
this.showCustomCategory = record.category &&
!this.categoryOptions.some(c => c.value === record.category);
// 设置表单值
this.$nextTick(() => {
this.form.resetFields();
if (this.showCustomCategory) {
this.form.setFieldsValue({
templateName: record.templateName,
category: "custom",
customCategory: record.category,
templateDesc: record.templateDesc || "",
templateContent: record.templateContent,
isDefault: record.isDefault == 1
});
} else {
this.form.setFieldsValue({
templateName: record.templateName,
category: record.category,
templateDesc: record.templateDesc || "",
templateContent: record.templateContent,
isDefault: record.isDefault == 1
});
}
});
},
// 关闭对话框
closeDialog() {
this.dialogVisible = false;
this.isEdit = false;
this.showCustomCategory = false;
},
// 处理分类变化
handleCategoryChange(value) {
this.showCustomCategory = value === "custom";
},
// 提交表单
handleSubmit() {
this.form.validateFields((err, values) => {
if (err) return;
this.submitLoading = true;
// 处理自定义分类
let category = values.category;
if (category === "custom" && values.customCategory) {
category = values.customCategory;
}
// 构建请求数据
const requestData = {
templateName: values.templateName,
templateDesc: values.templateDesc || "",
category: category,
templateContent: values.templateContent,
isDefault: values.isDefault ? 1 : 0,
state: 1
};
// 如果是编辑模式添加templateId
if (this.isEdit && this.currentTemplate) {
requestData.templateId = this.currentTemplate.templateId;
}
// 确定API端点
const url = this.isEdit ? api.template.update : api.template.add;
// 发送请求
axios.post({
url,
data: requestData
})
.then(res => {
if (res.code === 200) {
this.$message.success(this.isEdit ? "模板更新成功" : "模板创建成功");
this.closeDialog();
this.getData();
} else {
this.$message.error(res.message || "操作失败");
}
})
.catch(() => {
this.showError();
})
.finally(() => {
this.submitLoading = false;
});
});
},
// 预览模板
previewTemplate(record) {
this.currentTemplate = record;
this.previewVisible = true;
},
// 删除模板
deleteTemplate(record) {
this.loading = true;
axios.post({
url: api.template.delete,
data: {
templateId: record.templateId
}
})
.then(res => {
if (res.code === 200) {
this.$message.success("模板已删除");
this.getData();
} else {
this.$message.error(res.message || "删除失败");
}
})
.catch(() => {
this.showError();
})
.finally(() => {
this.loading = false;
});
},
// 设为默认模板
setAsDefault(record) {
this.loading = true;
axios.post({
url: api.template.update,
data: {
templateId: record.templateId,
isDefault: 1
}
})
.then(res => {
if (res.code === 200) {
this.$message.success("已设为默认模板");
this.getData();
} else {
this.$message.error(res.message || "操作失败");
}
})
.catch(() => {
this.showError();
})
.finally(() => {
this.loading = false;
});
}
}
};
</script>
<style scoped>
.template-preview-content {
white-space: pre-wrap;
background: #f5f5f5;
padding: 16px;
border-radius: 4px;
max-height: 400px;
overflow-y: auto;
}
.template-desc {
color: #666;
font-style: italic;
}
</style>

File diff suppressed because it is too large Load Diff

2034
web/src/views/page/Role.vue Normal file

File diff suppressed because it is too large Load Diff

214
web/src/views/page/User.vue Normal file
View File

@@ -0,0 +1,214 @@
<template>
<a-layout>
<a-layout-content>
<div class="layout-content-margin">
<!-- 查询框 -->
<div class="table-search">
<a-form
layout="horizontal"
:colon="false"
:labelCol="{ span: 6 }"
:wrapperCol="{ span: 16 }"
>
<a-row class="filter-flex">
<a-col
:xl="6"
:lg="12"
:xs="24"
v-for="item in queryFilter"
:key="item.index"
>
<a-form-item :label="item.label">
<a-input-search
v-model="query[item.index]"
placeholder="请输入"
allow-clear
@search="getData()"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
<!-- 表格数据 -->
<a-card title="用户管理" :bodyStyle="{ padding: 0 }" :bordered="false">
<a-table
rowKey="userId"
:columns="tableColumns"
:data-source="data"
:loading="loading"
:pagination="pagination"
:scroll="{ x: 1200 }"
size="middle"
>
<!-- 头像 -->
<template slot="avatar" slot-scope="text">
<a-avatar :src="getAvatarUrl(text)" />
</template>
<!-- 状态 -->
<template slot="state" slot-scope="text">
<a-tag color="green" v-if="text == 1">正常</a-tag>
<a-tag color="red" v-else>禁用</a-tag>
</template>
<!-- 是否管理员 -->
<template slot="isAdmin" slot-scope="text">
<a-tag color="blue" v-if="text == 1">管理员</a-tag>
<a-tag v-else>用户</a-tag>
</template>
</a-table>
</a-card>
</div>
</a-layout-content>
<a-back-top />
</a-layout>
</template>
<script>
import axios from "@/services/axios";
import api from "@/services/api";
import mixin from "@/mixins/index";
import { getResourceUrl } from "@/services/axios";
export default {
mixins: [mixin],
data() {
return {
// 查询框
query: {},
queryFilter: [
{
label: "姓名",
value: "",
index: "name",
},
{
label: "邮箱",
value: "",
index: "email",
},
{
label: "电话",
value: "",
index: "tel",
},
],
// 表格数据
tableColumns: [
{
title: "姓名",
dataIndex: "name",
width: 100,
scopedSlots: { customRender: "name" },
fixed: "left",
align: "center",
},
{
title: "用户头像",
dataIndex: "avatar",
width: 80,
scopedSlots: { customRender: "avatar" },
fixed: "left",
align: "center",
},
{
title: "邮箱",
dataIndex: "email",
width: 180,
align: "center",
ellipsis: true
},
{
title: "电话",
dataIndex: "tel",
width: 150,
align: "center",
ellipsis: true
},
{
title: "设备数量",
dataIndex: "totalDevice",
width: 100,
align: "center",
},
{
title: "在线设备",
dataIndex: "aliveNumber",
width: 100,
align: "center",
},
{
title: "对话消息数",
dataIndex: "totalMessage",
width: 120,
align: "center",
},
{
title: "状态",
dataIndex: "state",
scopedSlots: { customRender: "state" },
width: 80,
align: "center",
},
{
title: "账户类型",
dataIndex: "isAdmin",
scopedSlots: { customRender: "isAdmin" },
width: 80,
align: "center",
},
{
title: "最后登录时间",
dataIndex: "loginTime",
width: 180,
align: "center",
},
{
title: "最后登录IP",
dataIndex: "loginIp",
width: 150,
align: "center",
},
],
data: [],
};
},
mounted() {
this.getData();
},
methods: {
getAvatarUrl(avatar) {
return getResourceUrl(avatar);
},
/* 查询用户列表 */
getData() {
this.loading = true;
axios
.get({
url: api.user.queryUsers,
data: {
start: this.pagination.page,
limit: this.pagination.pageSize,
...this.query,
},
})
.then((res) => {
if (res.code === 200) {
this.data = res.data.list;
this.pagination.total = res.data.total;
} else {
this.showError(res.message);
}
})
.catch(() => {
this.showError();
})
.finally(() => {
this.loading = false;
});
},
},
};
</script>

View File

@@ -0,0 +1,451 @@
<template>
<a-layout>
<a-layout-content>
<div class="layout-content-margin">
<!-- 查询框 -->
<div class="table-search">
<a-form layout="horizontal" :colon="false" :labelCol="{ span: 6 }" :wrapperCol="{ span: 16 }">
<a-row class="filter-flex">
<a-col :xl="8" :lg="12" :xs="24">
<a-form-item label="平台">
<a-select v-model="query.provider" @change="getData()">
<a-select-option v-for="item in providerOptions" :key="item.value" :value="item.value">
{{ item.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xl="8" :lg="12" :xs="24">
<a-form-item label="智能体名称">
<a-input-search v-model="query.agentName" placeholder="请输入" allow-clear @search="getData()" />
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
<!-- 表格数据 -->
<a-card title="智能体管理" :bodyStyle="{ padding: 0 }" :bordered="false">
<template slot="extra">
<a-button type="primary" @click="handleConfigPlatform" style="margin-right: 8px">
<a-icon type="setting" />平台配置
</a-button>
</template>
<a-table rowKey="configId" :columns="getTableColumns" :data-source="agentList" :loading="loading"
:pagination="pagination" @change="handleTableChange" size="middle" :scroll="{ x: 1000 }">
<!-- Icon -->
<template slot="iconUrl" slot-scope="text, record">
<a-avatar :src="record.iconUrl" shape="square" size="large" />
</template>
<!-- 智能体名称 -->
<template slot="agentName" slot-scope="text, record">
<a-tooltip :title="text" :mouseEnterDelay="0.5" placement="leftTop">
<span v-if="text">{{ text }}</span>
<span v-else style="padding: 0 50px">&nbsp;&nbsp;&nbsp;</span>
</a-tooltip>
</template>
<!-- 平台 -->
<template slot="provider" slot-scope="text">
<a-tag color="blue">{{ text }}</a-tag>
</template>
<!-- 描述 -->
<template slot="agentDesc" slot-scope="text">
<a-tooltip :title="text" :mouseEnterDelay="0.5" placement="leftTop">
<span v-if="text">{{ text }}</span>
<span v-else style="padding: 0 50px">&nbsp;&nbsp;&nbsp;</span>
</a-tooltip>
</template>
<!-- 默认状态 -->
<template slot="isDefault" slot-scope="text">
<a-tag v-if="text == 1" color="green">默认</a-tag>
<span v-else>-</span>
</template>
<!-- 操作 -->
<template slot="operation" slot-scope="text, record">
<a-space>
<!-- 添加设为默认按钮 -->
<a v-if="record.isDefault != 1" href="javascript:" :disabled="record.isDefault == 1" @click="setAsDefault(record)">设为默认</a>
<a-popconfirm title="确定要删除此智能体吗?" @confirm="handleDelete(record)">
<a v-if="record.isDefault != 1" href="javascript:" style="color: #ff4d4f">删除</a>
</a-popconfirm>
</a-space>
</template>
</a-table>
</a-card>
</div>
</a-layout-content>
<a-back-top />
<!-- 平台配置对话框 -->
<a-modal :title="'平台配置 - ' + (query.provider === 'coze' ? 'Coze' : query.provider === 'dify' ? 'Dify' : query.provider)"
:visible="platformModalVisible" :confirm-loading="platformModalLoading"
@ok="handlePlatformModalOk" @cancel="handlePlatformModalCancel">
<a-form-model ref="platformForm" :model="platformForm" :rules="platformRules" :label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }">
<a-form-model-item v-for="item in getFormItems" :key="item.field" :label="item.label" :prop="item.field">
<a-input v-model="platformForm[item.field]" :placeholder="item.placeholder">
<template v-if="item.suffix" slot="suffix">
<span style="color: #999">{{ item.suffix }}</span>
</template>
</a-input>
</a-form-model-item>
</a-form-model>
</a-modal>
</a-layout>
</template>
<script>
import axios from "@/services/axios";
import api from "@/services/api";
import mixin from "@/mixins/index";
export default {
name: 'Agent',
mixins: [mixin],
data() {
return {
// 查询参数
query: {
agentName: '',
provider: 'coze'
},
// 平台选项
providerOptions: [
{ label: 'Coze', value: 'coze' },
{ label: 'Dify', value: 'dify' }
],
// 表格列定义
tableColumns: [
{ title: '头像', dataIndex: 'iconUrl', width: 80, align: 'center', scopedSlots: { customRender: 'iconUrl' }, fixed: 'left' },
{ title: '智能体名称', dataIndex: 'agentName', scopedSlots: { customRender: 'agentName' }, width: 150, align: 'center', fixed: 'left', ellipsis: true },
{ title: '平台', dataIndex: 'provider', scopedSlots: { customRender: 'provider' }, width: 80, align: 'center' },
{ title: '智能体描述', dataIndex: 'agentDesc', align: 'center', scopedSlots: { customRender: 'agentDesc' }, ellipsis: true },
// 添加默认状态列
{ title: '默认', dataIndex: 'isDefault', width: 80, align: 'center', scopedSlots: { customRender: 'isDefault' } },
{ title: '发布时间', dataIndex: 'publishTime', width: 180, align: 'center' },
{ title: '操作', dataIndex: 'operation', scopedSlots: { customRender: 'operation' }, width: 150, align: 'center', fixed: 'right' }
],
// 表格数据
agentList: [],
// 平台配置模态框
platformModalVisible: false,
platformModalLoading: false,
// 是否为编辑模式
isEdit: false,
// 当前编辑的配置ID
currentConfigId: null,
// 平台表单对象
platformForm: {
configType: 'agent',
provider: 'coze',
configName: '',
configDesc: '',
appId: '',
apiKey: '',
apiUrl: '',
ak: '',
sk: ''
},
// 表单项配置
formItems: {
coze: [
{
field: 'appId',
label: 'App ID',
placeholder: '请输入Coze App ID'
},
{
field: 'apiSecret',
label: 'Space ID',
placeholder: '请输入Coze Space ID'
},
{
field: 'ak',
label: '公钥',
placeholder: '请输入公钥'
},
{
field: 'sk',
label: '私钥',
placeholder: '请输入私钥'
}
],
dify: [
{
field: 'apiUrl',
label: 'API URL',
placeholder: '请输入API URL',
suffix: '/chat_message'
},
{
field: 'apiKey',
label: 'API Key',
placeholder: '请输入API Key'
}
]
},
// 平台表单验证规则
platformRules: {
appId: [{ required: true, message: '请输入App ID', trigger: 'blur' }],
apiKey: [{ required: true, message: '请输入API Key', trigger: 'blur' }],
apiSecret: [{ required: true, message: '请输入Space Id', trigger: 'blur' }],
ak: [{ required: true, message: '请输入公钥', trigger: 'blur' }],
sk: [{ required: true, message: '请输入私钥', trigger: 'blur' }],
apiUrl: [{ required: true, message: '请输入URL', trigger: 'blur' }]
}
}
},
computed: {
// 根据当前选择的平台动态生成表格列
getTableColumns() {
// 创建列数组的副本,以免修改原始数据
const columns = [...this.tableColumns];
// 如果当前选择的是Coze平台则插入智能体ID列
if (this.query.provider === 'coze') {
const botIdColumn = {
title: '智能体ID',
dataIndex: 'botId',
width: 180,
align: 'center',
scopedSlots: { customRender: 'botId' }
}
// 在第二列后插入智能体ID列
columns.splice(2, 0, botIdColumn);
}
return columns;
},
// 根据当前选择的平台获取对应的表单项
getFormItems() {
return this.formItems[this.query.provider] || [];
}
},
created() {
this.getData()
},
methods: {
// 获取智能体列表
getData() {
this.loading = true;
// 调用后端API获取智能体列表
axios.get({
url: api.agent.query,
data: {
provider: this.query.provider,
agentName: this.query.agentName,
configType: 'agent',
start: this.pagination.page,
limit: this.pagination.pageSize
}
})
.then(res => {
if (res.code === 200) {
this.agentList = res.data.list;
this.pagination.total = res.data.total;
} else {
this.$message.error(res.msg);
}
})
.catch(error => {
console.error('Error fetching agents:', error);
this.$message.error('获取智能体列表失败');
})
.finally(() => {
this.loading = false;
});
},
// 平台配置按钮点击
handleConfigPlatform() {
// 查询当前平台的配置
this.platformModalLoading = true;
axios.get({
url: api.config.query,
data: {
configType: 'agent',
provider: this.query.provider
}
})
.then(res => {
if (res.code === 200) {
const configs = res.data.list || [];
// 重置表单
this.platformForm = {
configType: 'agent',
provider: this.query.provider,
configName: '',
configDesc: '',
appId: '',
apiKey: '',
apiSecret: '',
apiUrl: '',
ak: '',
sk: ''
};
// 如果存在配置,则填充表单
if (configs.length > 0) {
const config = configs[0];
this.isEdit = true;
this.currentConfigId = config.configId;
// 填充表单数据
this.platformForm = {
configType: config.configType || 'agent',
provider: config.provider,
configName: config.configName || '',
configDesc: config.configDesc || '',
appId: config.appId || '',
apiSecret: config.apiSecret || '',
apiKey: config.apiKey || '',
apiUrl: config.apiUrl || '',
ak: config.ak || '',
sk: config.sk || ''
};
} else {
// 不存在配置,则为添加模式
this.isEdit = false;
this.currentConfigId = null;
// 如果是Dify平台设置默认的apiUrl
if (this.query.provider === 'dify') {
this.platformForm.apiUrl = 'https://api.dify.ai/v1';
}
}
this.platformModalVisible = true;
} else {
this.$message.error(res.msg || '获取平台配置失败');
}
})
.catch(error => {
console.error('Error fetching platform config:', error);
this.$message.error('获取平台配置失败');
})
.finally(() => {
this.platformModalLoading = false;
});
},
// 平台配置模态框确认
handlePlatformModalOk() {
this.$refs.platformForm.validate(valid => {
if (valid) {
this.platformModalLoading = true;
// 如果是Dify平台确保apiUrl有正确的后缀
if (this.platformForm.provider === 'dify' && this.platformForm.apiUrl) {
// 确保URL末尾没有斜杠
let baseUrl = this.platformForm.apiUrl;
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, -1);
}
this.platformForm.apiUrl = baseUrl;
}
// 根据是否为编辑模式选择不同的API
const apiEndpoint = this.isEdit ? api.config.update : api.config.add;
// 如果是编辑模式添加configId
if (this.isEdit) {
this.platformForm.configId = this.currentConfigId;
}
// 调用后端API添加或更新配置
axios.post({
url: apiEndpoint,
data: {...this.platformForm}
})
.then(res => {
if (res.code === 200) {
this.$message.success(this.isEdit ? '更新平台配置成功' : '添加平台配置成功');
this.platformModalVisible = false;
// 刷新智能体列表
this.getData();
} else {
this.$message.error(res.msg || (this.isEdit ? '更新平台配置失败' : '添加平台配置失败'));
}
})
.catch(error => {
console.error('Error with platform config:', error);
this.$message.error(this.isEdit ? '更新平台配置失败' : '添加平台配置失败');
})
.finally(() => {
this.platformModalLoading = false;
});
}
});
},
// 平台配置模态框取消
handlePlatformModalCancel() {
this.platformModalVisible = false;
},
// 设置为默认智能体
setAsDefault(record) {
this.$confirm({
title: '确定要将此智能体设为默认吗?',
content: '设为默认后,系统将优先使用此智能体,原默认智能体将被取消默认状态。',
okText: '确定',
cancelText: '取消',
onOk: () => {
this.loading = true;
// 调用后端API更新配置为默认
axios.post({
url: api.config.update,
data: {
configId: record.configId,
configType: 'llm',
isDefault: 1
}
})
.then(res => {
if (res.code === 200) {
this.$message.success(`已将"${record.agentName}"设为默认智能体`);
this.getData();
} else {
this.showError(res.message);
}
})
.catch(error => {
this.$message.error('设置默认智能体失败');
})
.finally(() => {
this.loading = false;
});
}
});
},
// 删除智能体
handleDelete(record) {
axios.post({
url: api.agent.delete,
data: { bot_id: record.bot_id }
})
.then(res => {
if (res.code === 200) {
this.$message.success('删除成功');
this.getData();
} else {
this.$message.error(res.msg || '删除失败');
}
})
.catch(() => {
this.$message.error('服务器错误,请稍后再试');
});
},
}
}
</script>

View File

@@ -0,0 +1,13 @@
<template>
<config-manager config-type="llm" />
</template>
<script>
import ConfigManager from '@/components/ConfigManager.vue'
export default {
components: {
ConfigManager
}
}
</script>

View File

@@ -0,0 +1,13 @@
<template>
<config-manager config-type="stt" />
</template>
<script>
import ConfigManager from '@/components/ConfigManager.vue'
export default {
components: {
ConfigManager
}
}
</script>

View File

@@ -0,0 +1,13 @@
<template>
<config-manager config-type="tts" />
</template>
<script>
import ConfigManager from '@/components/ConfigManager.vue'
export default {
components: {
ConfigManager
}
}
</script>

View File

@@ -0,0 +1,121 @@
<template>
<a-layout>
<a-layout-content>
<div class="layout-content-margin">
<a-row :gutter="[20, 20]">
<a-col :md="24" :lg="7">
<a-card :bordered="false">
<div class="account-center-avatarHolder">
<div class="avatar">
<img :src="user.avatar" />
</div>
<div class="name">{{ user.name }}</div>
<div class="username">{{ user.userName }}</div>
</div>
<a-divider />
<div class="account-center-detail">
<div class="detailTitle">个人信息</div>
<p>账户: {{ 'Admin' }}</p>
<p>注册时间: {{ user.createTime }}</p>
<p>手机: {{ user.tel }}</p>
<p>邮箱: {{ user.email }}</p>
</div>
<a-divider />
<div class="account-center-detail">
<div class="detailTitle">安全信息</div>
<p>上次登录地点: {{ user.address }}</p>
<p>上次登录时间: {{ user.loginTime }}</p>
</div>
</a-card>
</a-col>
<a-col :md="24" :lg="17">
<a-card :bordered="false">
<a-skeleton :loading="loading" active :paragraph="{ rows: 10 }">
<bar :data="data" title="对话统计" />
</a-skeleton>
</a-card>
</a-col>
</a-row>
</div>
</a-layout-content>
</a-layout>
</template>
<script>
import axios from '@/services/axios'
import api from '@/services/api'
import Bar from '@/components/Bar'
export default {
components: {
Bar
},
data () {
return {
data: {
columns: ['日期', '用户数', '设备', '对话'],
rows: [
{ 日期: '4/18', 用户数: 1111, 设备: 1000, 对话: 900 },
{ 日期: '5/18', 用户数: 2222, 设备: 1600, 对话: 987 },
{ 日期: '6/17', 用户数: 2333, 设备: 1700, 对话: 1200 },
{ 日期: '7/17', 用户数: 4444, 设备: 1600, 对话: 1300 },
{ 日期: '8/16', 用户数: 5555, 设备: 1800, 对话: 1100 },
{ 日期: '9/18', 用户数: 6666, 设备: 1500, 对话: 1450 }
]
},
user: {},
// 遮罩层
loading: false
}
},
computed: {
info () {
return this.$store.getters.USER_INFO
}
},
created () {
this.user = this.info
},
methods: {}
}
</script>
<style lang="scss" scoped>
.account-center-avatarHolder {
text-align: center;
margin-bottom: 24px;
& > .avatar {
margin: 0 auto;
width: 104px;
height: 104px;
margin-bottom: 20px;
border-radius: 50%;
overflow: hidden;
img {
height: 100%;
width: 100%;
}
}
.name {
color: rgba(0, 0, 0, 0.85);
font-size: 20px;
line-height: 28px;
font-weight: 500;
margin-bottom: 4px;
}
}
.account-center-detail {
p {
margin-bottom: 8px;
// padding-left: 26px;
position: relative;
}
}
.detailTitle {
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
margin-bottom: 12px;
}
</style>

View File

@@ -0,0 +1,190 @@
<template>
<a-modal
title="修改头像"
:visible="visible"
:maskClosable="false"
:confirmLoading="confirmLoading"
:width="800"
:footer="null"
@cancel="cancelHandel">
<a-row>
<a-col :xs="24" :md="12" :style="{height: '350px'}">
<vue-cropper
ref="cropper"
:img="options.img"
:info="true"
:autoCrop="options.autoCrop"
:autoCropWidth="options.autoCropWidth"
:autoCropHeight="options.autoCropHeight"
:fixedBox="options.fixedBox"
@realTime="realTime"
>
</vue-cropper>
</a-col>
<a-col :xs="24" :md="12" :style="{height: '350px'}">
<div class="avatar-upload-preview">
<img :src="previews.url" :style="previews.img"/>
</div>
</a-col>
</a-row>
<br>
<a-row>
<a-col :sm="2" :xs="2">
<a-upload name="file" :beforeUpload="beforeUpload" :showUploadList="false" accept=".png,.jpg,.jpeg,.gif">
<a-button icon="upload">选择图片</a-button>
</a-upload>
</a-col>
<a-col :sm="{span: 1, offset: 2}" :xs="2">
<a-button icon="plus" @click="changeScale(1)"/>
</a-col>
<a-col :sm="{span: 1, offset: 1}" :xs="2">
<a-button icon="minus" @click="changeScale(-1)"/>
</a-col>
<a-col :sm="{span: 1, offset: 1}" :xs="2">
<a-button icon="undo" @click="rotateLeft"/>
</a-col>
<a-col :sm="{span: 1, offset: 1}" :xs="2">
<a-button icon="redo" @click="rotateRight"/>
</a-col>
<a-col :sm="{span: 2, offset: 6}" :xs="2">
<a-button type="primary" @click="finish('blob')">保存</a-button>
</a-col>
</a-row>
</a-modal>
</template>
<script>
import api from '@/services/api'
import axios from 'axios'
export default {
data () {
return {
visible: false,
id: null,
confirmLoading: false,
fileList: [],
uploading: false,
options: {
// img: 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png',
img: '',
autoCrop: true,
autoCropWidth: 200,
autoCropHeight: 200,
fixedBox: true
},
previews: {}
}
},
methods: {
edit (id) {
this.visible = true
this.id = id
/* 获取原始头像 */
},
close () {
this.id = null
this.visible = false
},
cancelHandel () {
this.close()
},
changeScale (num) {
num = num || 1
this.$refs.cropper.changeScale(num)
},
rotateLeft () {
this.$refs.cropper.rotateLeft()
},
rotateRight () {
this.$refs.cropper.rotateRight()
},
beforeUpload (file) {
const reader = new FileReader()
// 把Array Buffer转化为blob 如果是base64不需要
// 转化为base64
reader.readAsDataURL(file)
reader.onload = () => {
this.options.img = reader.result
}
// 转化为blob
// reader.readAsArrayBuffer(file)
return false
},
// 上传图片(点击上传按钮)
finish (type) {
const _this = this
const formData = new FormData()
// 输出
if (type === 'blob') {
this.$refs.cropper.getCropBlob((data) => {
const img = window.URL.createObjectURL(data)
this.model = true
this.modelSrc = img
const fileName = `${this.moment().format('YYYYMMDD')}_avatar.png`
formData.append('file', data, fileName)
// let request = new XMLHttpRequest()
// request.open('POST', api.upload)
// request.send(formData)
axios
.post(api.upload, formData).then(response => {
let res = response.data
if (res.code === 200) {
_this.imgFile = ''
_this.headImg = res.url // 完整路径
_this.uploadImgRelaPath = res.url // 非完整路径
_this.$message.success('上传成功')
this.visible = false
_this.$emit('ok', res.url)
} else {
this.$message.error(res.message)
}
}).catch(() => {
this.showError();
})
})
} else {
this.$refs.cropper.getCropData((data) => {
this.model = true
this.modelSrc = data
})
}
},
okHandel () {
const vm = this
vm.confirmLoading = true
setTimeout(() => {
vm.confirmLoading = false
vm.close()
vm.$message.success('上传头像成功')
}, 2000)
},
realTime (data) {
this.previews = data
}
}
}
</script>
<style lang="scss" scoped>
.avatar-upload-preview {
position: absolute;
top: 50%;
transform: translate(50%, -50%);
width: 200px;
height: 200px;
border-radius: 50%;
box-shadow: 0 0 4px #ccc;
overflow: hidden;
img {
width: 100%;
height: 100%;
}
}
</style>

View File

@@ -0,0 +1,318 @@
<template>
<a-layout>
<a-layout-content>
<div class="layout-content-margin">
<a-card title="个人设置" :bordered="false">
<a-row>
<!-- 信息 -->
<a-col :md="24" :lg="12">
<a-form
layout="vertical"
:form="infoForm"
:colon="false"
@submit="submit"
>
<a-form-item label="姓名">
<a-input
v-decorator="[
'name'
]"
autocomplete="off"
placeholder="请输入自己的姓名"
/>
</a-form-item>
<a-form-item label="手机">
<a-input
v-decorator="['tel', {rules: [{ required: false, message: '请输入正确的手机号', pattern: /^1[3456789]\d{9}$/ }], validateTrigger: ['change', 'blur']}]"
placeholder="请输入手机号码"
/>
</a-form-item>
<a-form-item label="电子邮件">
<a-input
v-decorator="['email', {rules: [{ required: false, type: 'email', message: '请输入邮箱地址' }], validateTrigger: ['change', 'blur']}]"
placeholder="请输入邮箱地址"
/>
</a-form-item>
<a-popover
:placement="state.placement"
:trigger="['focus']"
:getPopupContainer="(trigger) => trigger.parentElement"
:visible="state.passwordLevelChecked"
>
<template slot="content">
<div :style="{ width: '240px' }" >
<div :class="['user-register', passwordLevelClass]">强度:<span>{{ passwordLevelName }}</span></div>
<a-progress :percent="state.percent" :showInfo="false" :strokeColor=" passwordLevelColor " />
<div style="margin-top: 10px;">
<span>请至少输入 6 个字符。请不要使用容易被猜到的密码。</span>
</div>
</div>
</template>
<a-form-item label="密码">
<a-input-password
@click="passwordInputClick"
:visibilityToggle="false"
placeholder="至少6位密码区分大小写"
v-decorator="['password', {rules: [{ required: state.passwordRequire, message: '至少6位密码区分大小写'}, { validator: passwordLevel }], validateTrigger: ['change', 'blur']}]"
></a-input-password>
</a-form-item>
</a-popover>
<a-form-item label="确认密码">
<a-input-password
placeholder="确认密码"
:visibilityToggle="false"
v-decorator="['password2', {rules: [{ required: state.passwordRequire, message: '至少6位密码区分大小写' }, { validator: passwordCheck }], validateTrigger: ['change', 'blur']}]"
></a-input-password>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">保存</a-button>
</a-form-item>
</a-form>
</a-col>
<a-col :md="24" :lg="12">
<div class="ant-upload-preview" @click="$refs.modal.edit(1)" >
<a-icon type="cloud-upload-o" class="upload-icon"/>
<div class="mask">
<a-icon type="plus" />
</div>
<img :src="user.avatar"/>
</div>
</a-col>
</a-row>
</a-card>
<!-- 头像 -->
<avatar-modal ref="modal" @ok="setavatar" />
</div>
</a-layout-content>
</a-layout>
</template>
<script>
import axios from '@/services/axios'
import api from '@/services/api'
import AvatarModal from './AvatarModal'
import Cookies from 'js-cookie'
const levelNames = {
0: '低',
1: '低',
2: '中',
3: '强'
}
const levelClass = {
0: 'error',
1: 'error',
2: 'warning',
3: 'success'
}
const levelColor = {
0: '#ff0000',
1: '#ff0000',
2: '#ff7e05',
3: '#52c41a'
}
export default {
components: {
AvatarModal
},
data () {
return {
// 密码状态
state: {
placement: 'rightTop',
passwordLevel: 0,
passwordLevelChecked: false,
percent: 10,
progressColor: '#FF0000',
passwordRequire: false
},
// 用户信息
user: {},
infoForm: this.$form.createForm(this)
}
},
computed: {
/* 判断设备 */
isMobile () {
return this.$store.getters.MOBILE_TYPE
},
/* 人员信息 */
info () {
return this.$store.getters.USER_INFO
},
/* 密码状态 */
passwordLevelClass () {
return levelClass[this.state.passwordLevel]
},
passwordLevelName () {
return levelNames[this.state.passwordLevel]
},
passwordLevelColor () {
return levelColor[this.state.passwordLevel]
}
},
created () {
this.user = this.info
},
methods: {
/* 自定义验证规则 */
passwordLevel (rules, value, callback) {
let level = 0
if (!value) {
this.state.passwordRequire = false
return callback()
} else {
this.state.passwordRequire = true
}
// 判断这个字符串中有没有数字
if (/[0-9]/.test(value)) {
level++
}
// 判断字符串中有没有字母
if (/[a-zA-Z]/.test(value)) {
level++
}
// 判断字符串中有没有特殊符号
if (/[^0-9a-zA-Z_]/.test(value)) {
level++
}
// 判断字符串长度
if (value.length < 6) {
level = 0
}
this.state.passwordLevel = level
this.state.percent = level * 30
if (level >= 2) {
if (level >= 3) {
this.state.percent = 100
}
callback()
} else {
if (level === 0) {
this.state.percent = 10
}
callback(new Error('密码强度不够'))
}
},
/* 二次密码确认 */
passwordCheck (rule, value, callback) {
const password = this.infoForm.getFieldValue('password')
if (value && password && value.trim() !== password.trim()) {
callback(new Error('两次密码不一致'))
}
callback()
},
/* 手机端则不显示密码强度框 */
passwordInputClick () {
if (this.isMobile) {
this.state.passwordLevelChecked = false
} else {
this.state.passwordLevelChecked = true
}
},
/* 提交按钮 */
submit (e) {
e.preventDefault()
this.infoForm.validateFields((err, values) => {
if (!err && this.infoForm.isFieldsTouched()) {
axios
.jsonPost({
url: api.user.update,
data: {
username: this.user.username,
...values
}
}).then(res => {
if (res.code === 200) {
Cookies.set('userInfo', JSON.stringify(res.data), { expires: 30 })
this.$store.commit('USER_INFO', res.data)
this.$message.success(res.message)
} else {
this.$message.error(res.message)
}
}).catch(() => {
this.showError();
})
}
})
},
setavatar (url) {
this.user.avatar = url
Cookies.set('userInfo', JSON.stringify(this.user), { expires: 30 })
this.$store.commit('USER_INFO', this.user)
}
}
}
</script>
<style lang="scss" scoped>
.ant-form-vertical >>> .ant-form-item {
margin-bottom: 24px;
}
.ant-upload-preview {
position: relative;
margin: 0 auto;
width: 100%;
max-width: 180px;
border-radius: 50%;
box-shadow: 0 0 4px #ccc;
.upload-icon {
position: absolute;
top: 0;
right: 10px;
font-size: 1.4rem;
padding: 0.5rem;
background: rgba(222, 221, 221, 0.7);
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.2);
}
.mask {
opacity: 0;
position: absolute;
background: rgba(0,0,0,0.4);
cursor: pointer;
transition: opacity 0.4s;
&:hover {
opacity: 1;
}
i {
font-size: 2rem;
position: absolute;
top: 50%;
left: 50%;
margin-left: -1rem;
margin-top: -1rem;
color: #d6d6d6;
}
}
img, .mask {
width: 100%;
max-width: 180px;
height: 100%;
border-radius: 50%;
overflow: hidden;
}
}
.user-register {
&.error {
color: #ff0000;
}
&.warning {
color: #ff7e05;
}
&.success {
color: #52c41a;
}
}
</style>