整合前端
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div ref="chartContainer" :style="{ width: '400px', height: '400px' }"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
export default {
|
||||
name: 'ChartRenderer',
|
||||
props: {
|
||||
// 图表配置项
|
||||
chartOptions: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
// 数据集配置
|
||||
dataset: {
|
||||
type: [Array, Object],
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
chart: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
// 监听配置项变化
|
||||
chartOptions: {
|
||||
handler(newVal) {
|
||||
this.updateChart()
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
// 监听数据集变化
|
||||
dataset: {
|
||||
handler(newVal) {
|
||||
this.updateChart()
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
console.log(this.chartOptions, this.dataset, 'chart mounted');
|
||||
this.initChart()
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.chart) {
|
||||
this.chart.dispose()
|
||||
this.chart = null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 初始化图表
|
||||
initChart() {
|
||||
if (this.chart) {
|
||||
this.chart.dispose()
|
||||
}
|
||||
this.chart = echarts.init(this.$refs.chartContainer)
|
||||
this.updateChart()
|
||||
},
|
||||
// 更新图表
|
||||
updateChart() {
|
||||
if (!this.chart) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log('更新图表:', this.chartOptions, this.dataset);
|
||||
|
||||
// 合并配置项和数据集
|
||||
const options = {
|
||||
...this.chartOptions,
|
||||
dataset: this.dataset
|
||||
}
|
||||
|
||||
// 设置配置项
|
||||
this.chart.setOption(options, true)
|
||||
}
|
||||
},
|
||||
// 监听容器大小变化
|
||||
mounted() {
|
||||
window.addEventListener('resize', () => {
|
||||
if (this.chart) {
|
||||
this.chart.resize()
|
||||
}
|
||||
})
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.chart.resize)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -0,0 +1,469 @@
|
||||
<template>
|
||||
<div class="table-renderer">
|
||||
<!-- 搜索和工具栏 -->
|
||||
<div class="table-toolbar" v-if="showToolbar">
|
||||
<div class="left-tools">
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="请输入搜索内容"
|
||||
class="search-input"
|
||||
@input="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
<div class="right-tools">
|
||||
<!-- 全屏按钮 -->
|
||||
<el-tooltip content="全屏">
|
||||
<el-button
|
||||
:icon="isFullscreen ? 'el-icon-aim' : 'el-icon-full-screen'"
|
||||
type="primary"
|
||||
link
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<el-popover
|
||||
placement="bottom-end"
|
||||
:width="200"
|
||||
trigger="click"
|
||||
popper-class="column-selector-popover"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button type="primary" link icon="el-icon-setting" />
|
||||
</template>
|
||||
<div class="column-selector">
|
||||
<el-checkbox-group v-model="selectedColumns" @change="handleColumnChange">
|
||||
<div class="column-item" v-for="col in filteredColumns" :key="col.field">
|
||||
<el-checkbox :label="col.field">{{ col.title }}</el-checkbox>
|
||||
</div>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
</el-popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格主体 -->
|
||||
<el-table
|
||||
:data="paginatedData"
|
||||
border
|
||||
stripe
|
||||
style="width: 100%"
|
||||
:max-height="400"
|
||||
@sort-change="handleSort"
|
||||
>
|
||||
<el-table-column
|
||||
v-for="column in displayColumns"
|
||||
:key="column.field"
|
||||
:prop="column.field"
|
||||
:label="column.title"
|
||||
:width="column.width"
|
||||
:min-width="column.minWidth || 100"
|
||||
:fixed="column.fixed"
|
||||
:sortable="column.sortable"
|
||||
:show-overflow-tooltip="true"
|
||||
>
|
||||
<template v-if="column.render" #default="scope">
|
||||
<component
|
||||
:is="column.render"
|
||||
:row="scope.row"
|
||||
:column="column"
|
||||
:index="scope.$index"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页器 -->
|
||||
<div class="pagination-container" v-if="showPagination">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="filteredData.length"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'TableRenderer',
|
||||
props: {
|
||||
// 表格数据源
|
||||
datasource: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
// 列配置
|
||||
columns: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
validator: function(value) {
|
||||
return value.every(column =>
|
||||
typeof column === 'object' &&
|
||||
'field' in column &&
|
||||
'title' in column
|
||||
);
|
||||
}
|
||||
},
|
||||
// 是否开启系统字段过滤
|
||||
enableSystemFieldFilter: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 是否显示工具栏(搜索框和列设置)
|
||||
showToolbar: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 是否显示分页
|
||||
showPagination: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// 过滤后的列配置
|
||||
filteredColumns() {
|
||||
if (!this.enableSystemFieldFilter) {
|
||||
return this.columns;
|
||||
}
|
||||
// 需要排除的系统字段
|
||||
const excludeFields = ['create_by', 'update_by', 'create_time', 'update_time', 'del_flag'];
|
||||
return this.columns.filter(column => !excludeFields.includes(column.field));
|
||||
},
|
||||
// 搜索和排序后的数据
|
||||
filteredData() {
|
||||
let result = [...this.datasource];
|
||||
|
||||
// 搜索过滤
|
||||
if (this.searchText) {
|
||||
const searchLower = this.searchText.toLowerCase();
|
||||
result = result.filter(item => {
|
||||
return Object.keys(item).some(key => {
|
||||
const value = item[key];
|
||||
return value != null && value.toString().toLowerCase().includes(searchLower);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 排序
|
||||
if (this.sortField && this.sortOrder) {
|
||||
result.sort((a, b) => {
|
||||
const aVal = a[this.sortField];
|
||||
const bVal = b[this.sortField];
|
||||
if (this.sortOrder === 'ascending') {
|
||||
return aVal > bVal ? 1 : -1;
|
||||
} else {
|
||||
return aVal < bVal ? 1 : -1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
// 分页后的数据
|
||||
paginatedData() {
|
||||
const start = (this.currentPage - 1) * this.pageSize;
|
||||
const end = start + this.pageSize;
|
||||
return this.filteredData.slice(start, end);
|
||||
},
|
||||
// 实际显示的列
|
||||
displayColumns() {
|
||||
return this.filteredColumns.filter(col => this.selectedColumns.includes(col.field));
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// 搜索相关
|
||||
searchText: '',
|
||||
// 分页相关
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
// 排序相关
|
||||
sortField: '',
|
||||
sortOrder: '',
|
||||
// 列选择器相关
|
||||
selectedColumns: [],
|
||||
// 防抖定时器
|
||||
searchTimer: null,
|
||||
// 是否显示所有列
|
||||
allColumnsSelected: true,
|
||||
// 是否全屏
|
||||
isFullscreen: false
|
||||
}
|
||||
},
|
||||
created() {
|
||||
// 初始化选中的列
|
||||
this.selectedColumns = this.filteredColumns.map(col => col.field);
|
||||
},
|
||||
methods: {
|
||||
// 搜索处理(防抖)
|
||||
handleSearch() {
|
||||
if (this.searchTimer) {
|
||||
clearTimeout(this.searchTimer);
|
||||
}
|
||||
this.searchTimer = setTimeout(() => {
|
||||
this.currentPage = 1; // 重置页码
|
||||
this.$emit('search', this.searchText);
|
||||
}, 300);
|
||||
},
|
||||
|
||||
// 排序处理
|
||||
handleSort({ prop, order }) {
|
||||
this.sortField = prop;
|
||||
this.sortOrder = order;
|
||||
this.$emit('sort-change', { prop, order });
|
||||
},
|
||||
|
||||
// 分页处理
|
||||
handleSizeChange(size) {
|
||||
this.pageSize = size;
|
||||
this.$emit('page-size-change', size);
|
||||
},
|
||||
handleCurrentChange(page) {
|
||||
this.currentPage = page;
|
||||
this.$emit('current-page-change', page);
|
||||
},
|
||||
|
||||
// 列选择处理
|
||||
handleColumnChange() {
|
||||
this.allColumnsSelected = this.selectedColumns.length === this.filteredColumns.length;
|
||||
this.$emit('column-change', this.selectedColumns);
|
||||
},
|
||||
|
||||
// 全选/取消全选列
|
||||
selectAllColumns() {
|
||||
if (this.allColumnsSelected) {
|
||||
this.selectedColumns = [];
|
||||
} else {
|
||||
this.selectedColumns = this.filteredColumns.map(col => col.field);
|
||||
}
|
||||
this.allColumnsSelected = !this.allColumnsSelected;
|
||||
this.handleColumnChange();
|
||||
},
|
||||
|
||||
// 切换全屏
|
||||
async toggleFullscreen() {
|
||||
try {
|
||||
const element = this.$el;
|
||||
|
||||
if (!this.isFullscreen) {
|
||||
if (element.requestFullscreen) {
|
||||
await element.requestFullscreen();
|
||||
} else if (element.webkitRequestFullscreen) {
|
||||
await element.webkitRequestFullscreen();
|
||||
} else if (element.msRequestFullscreen) {
|
||||
await element.msRequestFullscreen();
|
||||
}
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
await document.exitFullscreen();
|
||||
} else if (document.webkitExitFullscreen) {
|
||||
await document.webkitExitFullscreen();
|
||||
} else if (document.msExitFullscreen) {
|
||||
await document.msExitFullscreen();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换全屏失败:', error);
|
||||
this.$message.error('切换全屏失败');
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// 监听全屏变化
|
||||
const handleFullscreenChange = () => {
|
||||
this.isFullscreen = !!(
|
||||
document.fullscreenElement ||
|
||||
document.webkitFullscreenElement ||
|
||||
document.msFullscreenElement
|
||||
);
|
||||
};
|
||||
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
|
||||
document.addEventListener('msfullscreenchange', handleFullscreenChange);
|
||||
|
||||
// 组件销毁时移除监听
|
||||
this.$once('hook:beforeDestroy', () => {
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
|
||||
document.removeEventListener('msfullscreenchange', handleFullscreenChange);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.table-renderer {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.table-renderer :deep(.el-table) {
|
||||
margin-bottom: 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.table-renderer :deep(.el-table th) {
|
||||
background-color: #f5f7fa;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.table-renderer :deep(.el-table td) {
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.table-renderer :deep(.el-table--striped .el-table__body tr.el-table__row--striped td) {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.table-renderer :deep(.el-table__body tr.hover-row > td) {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.left-tools {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.right-tools {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.column-item {
|
||||
margin: 8px 0;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* 列选择器样式 */
|
||||
.column-selector {
|
||||
padding: 8px 0;
|
||||
max-height: 300px;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.column-selector-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 16px 8px;
|
||||
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.column-selector-header span {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.column-selector-popover) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.column-selector-content {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #909399 #f4f4f5;
|
||||
}
|
||||
|
||||
/* Webkit浏览器的滚动条样式 */
|
||||
.column-selector-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.column-selector-content::-webkit-scrollbar-thumb {
|
||||
background-color: #909399;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.column-selector-content::-webkit-scrollbar-track {
|
||||
background-color: #f4f4f5;
|
||||
}
|
||||
|
||||
.column-item {
|
||||
padding: 8px 16px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.column-item:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox-group) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox__label) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.button-text {
|
||||
margin-left: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 全屏模式样式 */
|
||||
:deep(.table-renderer:fullscreen) {
|
||||
background-color: #fff;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:deep(.table-renderer:fullscreen) .el-table {
|
||||
height: calc(100vh - 160px);
|
||||
}
|
||||
|
||||
:deep(.table-renderer:-webkit-full-screen) {
|
||||
background-color: #fff;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:deep(.table-renderer:-webkit-full-screen) .el-table {
|
||||
height: calc(100vh - 160px);
|
||||
}
|
||||
|
||||
:deep(.table-renderer:-ms-fullscreen) {
|
||||
background-color: #fff;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:deep(.table-renderer:-ms-fullscreen) .el-table {
|
||||
height: calc(100vh - 160px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="markdown-body" v-html="renderedContent"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import hljs from 'highlight.js';
|
||||
import 'highlight.js/styles/github.css';
|
||||
// 直接引入编译后的 marked
|
||||
const marked = require('marked');
|
||||
|
||||
export default {
|
||||
name: 'TextRenderer',
|
||||
props: {
|
||||
content: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
renderedContent: ''
|
||||
}
|
||||
},
|
||||
created() {
|
||||
// 配置 marked
|
||||
marked.setOptions({
|
||||
highlight: function (code, language) {
|
||||
if (language && hljs.getLanguage(language)) {
|
||||
try {
|
||||
return hljs.highlight(code, { language }).value;
|
||||
} catch (err) {}
|
||||
}
|
||||
return code;
|
||||
},
|
||||
breaks: true
|
||||
});
|
||||
this.renderMarkdown();
|
||||
},
|
||||
watch: {
|
||||
content: {
|
||||
handler: 'renderMarkdown',
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
renderMarkdown() {
|
||||
try {
|
||||
this.renderedContent = marked(this.content);
|
||||
} catch (error) {
|
||||
console.error('Markdown 渲染错误:', error);
|
||||
this.renderedContent = this.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.markdown-body {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
background-color: #f6f8fa;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
background-color: rgba(0,0,0,0.05);
|
||||
border-radius: 3px;
|
||||
padding: 2px 4px;
|
||||
font-family: Consolas, Monaco, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.markdown-body ul, .markdown-body ol {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
padding: 0 1em;
|
||||
color: #6a737d;
|
||||
border-left: 0.25em solid #dfe2e5;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.markdown-body table th,
|
||||
.markdown-body table td {
|
||||
padding: 6px 13px;
|
||||
border: 1px solid #dfe2e5;
|
||||
}
|
||||
|
||||
.markdown-body table tr:nth-child(2n) {
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<el-popover
|
||||
placement="top"
|
||||
width="400"
|
||||
trigger="click"
|
||||
>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="表名">
|
||||
<el-select v-model="form.tableName" placeholder="请选择表名">
|
||||
<el-option v-for="item in dict.type.ai_table_map" :key="item.value" :label="item.label" :value="item.label" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="数据筛选">
|
||||
<el-input type="textarea" v-model="form.filter" placeholder="请输入数据的筛选条件,比如上个月的数据" />
|
||||
</el-form-item>
|
||||
<!-- <el-form-item label="展示方式">
|
||||
<el-input type="textarea" v-model="form.show" placeholder="请输入你希望的数据展示类型,比如图表+文字+表格" />
|
||||
</el-form-item> -->
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSubmit">发送</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-button slot="reference" type="text">数据分析(测试版)</el-button>
|
||||
</el-popover>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'steer',
|
||||
dicts: ['ai_table_map'],
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
tableName: '',
|
||||
filter: '',
|
||||
show: '表格',
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async handleSubmit() {
|
||||
const text = `从${this.form.tableName}表中查询数据,
|
||||
筛选条件为:${this.form.filter},
|
||||
展示方式包括:${this.form.show}`
|
||||
this.$emit('submit', text)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
545
ruoyi-ui/src/layout/components/AIChat/index.vue
Normal file
545
ruoyi-ui/src/layout/components/AIChat/index.vue
Normal file
@@ -0,0 +1,545 @@
|
||||
<template>
|
||||
<div>
|
||||
<svg-icon icon-class="ai-chat" @click="openChat" />
|
||||
|
||||
<el-drawer title="AI对话" :visible.sync="isChatVisible" :show-close="false" direction="rtl" size="60%">
|
||||
<template slot="title">
|
||||
<div class="ai-chat-title">
|
||||
<div>AI对话</div>
|
||||
<div style="display: flex; align-items: center;">
|
||||
<div class="icon-wrapper" title="新建对话" @click="newChat">
|
||||
<i class="el-icon-plus"></i>
|
||||
</div>
|
||||
<div class="icon-wrapper" title="历史对话" @click="showHistory = true">
|
||||
<i class="el-icon-time"></i>
|
||||
</div>
|
||||
<div class="icon-wrapper" title="关闭对话" @click="closeChat">
|
||||
<i class="el-icon-close"></i>
|
||||
</div>
|
||||
|
||||
<!-- 历史对话弹窗 -->
|
||||
<el-dialog :visible.sync="showHistory" :append-to-body="true" width="400px"
|
||||
custom-class="history-dialog" @open="handleHistoryDialogOpen">
|
||||
<div slot="title" class="dialog-title">
|
||||
<div class="title-left">
|
||||
<span>历史对话</span>
|
||||
<i class="el-icon-refresh" @click="refreshHistory" :class="{'is-loading': isLoadingHistory}" title="刷新"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div v-loading="isLoadingHistory" class="history-list">
|
||||
<template v-if="chatHistory.length === 0 && !isLoadingHistory">
|
||||
<div class="empty-history">
|
||||
暂无历史对话
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-for="(chat, index) in chatHistory" :key="index" class="history-item"
|
||||
:class="{ 'active': currentChatId === chat.id }" @click="loadChat(chat)">
|
||||
<div class="history-item-content" v-loading="chat.loading" element-loading-background="rgba(255, 255, 255, 0.7)">
|
||||
<div class="history-title">{{ chat.title || '未命名对话' }}</div>
|
||||
<div class="history-time">{{ chat.time }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="ai-chat-container">
|
||||
<!-- 消息列表 -->
|
||||
<div class="message-list">
|
||||
<div v-for="(msg, index) in messages" :key="index"
|
||||
:class="['message-item', msg.type === 'user' ? 'user-message' : 'system-message']">
|
||||
<div class="message-avatar">
|
||||
<img v-if="msg.type === 'user'" :src="avatar" />
|
||||
<i v-else class="el-icon-service"></i>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="message-text" :class="{ 'loading-message': msg.isLoading }">
|
||||
<template v-if="msg.isLoading">
|
||||
<i class="el-icon-loading"></i>
|
||||
{{ msg.content }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<ChartRenderer v-if="msg.renderType == 'chart'" :chartOptions="msg.content.options" :dataset="msg.content.dataset"/>
|
||||
<TableRenderer v-else-if="msg.renderType == 'table'" :columns="msg.content.columns" :datasource="msg.content.datasource"/>
|
||||
<TextRenderer v-else-if="msg.type === 'system'" :content="msg.content" />
|
||||
<template v-else>{{ msg.content }}</template>
|
||||
</template>
|
||||
</div>
|
||||
<div class="message-time">{{ msg.time }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息发送和操作栏 -->
|
||||
<div class="message-input">
|
||||
<!-- 快捷操作栏: 例如分析数据、生成报告、生成代码等,主要是构建不同的提示词快速发送消息 -->
|
||||
<div class="quick-actions">
|
||||
<steer @submit="handleSteerSubmit" />
|
||||
</div>
|
||||
|
||||
<el-input type="textarea" v-model="message" placeholder="请输入内容" :rows="3" class="message-textarea" />
|
||||
<el-button type="primary" @click="handleSendMessage" :disabled="!message.trim()">发送</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { dataAnalysis, getConversationDetail, getConversationList, newConversation, sendMessage } from '@/api/oa/ai';
|
||||
import { mapGetters } from 'vuex';
|
||||
import ChartRenderer from './components/renderer/chart/index.vue';
|
||||
import TableRenderer from './components/renderer/table/index.vue';
|
||||
import TextRenderer from './components/renderer/text/index.vue';
|
||||
import steer from './components/steer/analysic.vue';
|
||||
|
||||
export default {
|
||||
name: 'AIChat',
|
||||
components: {
|
||||
steer,
|
||||
TextRenderer,
|
||||
ChartRenderer,
|
||||
TableRenderer
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isChatVisible: false,
|
||||
showHistory: false,
|
||||
message: '',
|
||||
messages: [],
|
||||
isResponding: false,
|
||||
chatHistory: [],
|
||||
isLoadingHistory: false,
|
||||
historyLoaded: false,
|
||||
currentChatId: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(["avatar"]),
|
||||
},
|
||||
created() {},
|
||||
updated () {
|
||||
this.scrollToBottom();
|
||||
},
|
||||
methods: {
|
||||
openChat () {
|
||||
this.isChatVisible = true;
|
||||
},
|
||||
closeChat () {
|
||||
this.isChatVisible = false;
|
||||
},
|
||||
newChat () {
|
||||
this.currentChatId = null;
|
||||
this.messages = [];
|
||||
this.showHistory = false;
|
||||
},
|
||||
scrollToBottom () {
|
||||
const messageList = this.$el.querySelector('.message-list');
|
||||
if (messageList) {
|
||||
setTimeout(() => {
|
||||
messageList.scrollTop = messageList.scrollHeight;
|
||||
}, 50);
|
||||
}
|
||||
},
|
||||
handleSteerSubmit (text) {
|
||||
// 显示正在回复状态
|
||||
this.isResponding = true;
|
||||
this.messages.push({
|
||||
type: 'system',
|
||||
content: '正在思考中...',
|
||||
time: new Date().toLocaleTimeString(),
|
||||
isLoading: true
|
||||
});
|
||||
dataAnalysis(text).then(res => {
|
||||
// 移除loading消息
|
||||
this.messages = this.messages.filter(msg => !msg.isLoading);
|
||||
const systemMessage = {
|
||||
type: 'system',
|
||||
content: res.data.response,
|
||||
renderType: res.data.renderType,
|
||||
time: new Date().toLocaleTimeString()
|
||||
};
|
||||
this.messages.push(systemMessage);
|
||||
this.isResponding = false;
|
||||
});
|
||||
},
|
||||
async handleSendMessage () {
|
||||
if (!this.message.trim()) return;
|
||||
// 如果是第一条消息,则先创建对话
|
||||
if (!this.currentChatId) {
|
||||
// 如果消息很长需要截取前20个字符
|
||||
const message = this.message.length > 20 ? this.message.slice(0, 20) : this.message;
|
||||
const res = await newConversation(message);
|
||||
this.currentChatId = res.data.conversationId;
|
||||
}
|
||||
const userMessage = this.message;
|
||||
this.message = '';
|
||||
// 添加用户消息
|
||||
const newMessage = {
|
||||
type: 'user',
|
||||
content: userMessage,
|
||||
time: new Date().toLocaleTimeString()
|
||||
};
|
||||
this.messages.push(newMessage);
|
||||
|
||||
// 显示正在回复状态
|
||||
this.isResponding = true;
|
||||
this.messages.push({
|
||||
type: 'system',
|
||||
content: '正在思考中...',
|
||||
time: new Date().toLocaleTimeString(),
|
||||
isLoading: true
|
||||
});
|
||||
|
||||
const res = await sendMessage({
|
||||
conversationId: this.currentChatId,
|
||||
message: userMessage
|
||||
});
|
||||
// 移除loading消息
|
||||
this.messages = this.messages.filter(msg => !msg.isLoading);
|
||||
|
||||
// 添加系统回复
|
||||
// 根据res.data.renderType来区分回复类型
|
||||
// 如果是text则直接显示文本
|
||||
// 如果是fix则是混合内容,需要调用多个renderer
|
||||
// 如果是chart则是图表
|
||||
// 如果是table则是表格
|
||||
const systemMessage = {
|
||||
type: 'system',
|
||||
content: res.data.response,
|
||||
renderType: res.data.renderType,
|
||||
time: new Date().toLocaleTimeString()
|
||||
};
|
||||
|
||||
this.messages.push(systemMessage);
|
||||
this.isResponding = false;
|
||||
},
|
||||
|
||||
async handleSendTest() {
|
||||
this.testRenderer(this.message);
|
||||
},
|
||||
|
||||
async testRenderer (type) {
|
||||
const systemMessage = {
|
||||
type: 'system',
|
||||
renderType: type,
|
||||
time: new Date().toLocaleTimeString()
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case 'text':
|
||||
systemMessage.content = '测试文本';
|
||||
break;
|
||||
case 'chart':
|
||||
systemMessage.content = {
|
||||
options: {
|
||||
title: {
|
||||
text: '测试图表'
|
||||
},
|
||||
},
|
||||
dataset: [
|
||||
{
|
||||
name: '测试数据',
|
||||
data: [1, 2, 3, 4, 5]
|
||||
}
|
||||
]
|
||||
};
|
||||
break;
|
||||
case 'table':
|
||||
systemMessage.content = {
|
||||
columns: [
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name'
|
||||
},
|
||||
{
|
||||
title: '年龄',
|
||||
dataIndex: 'age'
|
||||
},
|
||||
{
|
||||
title: '性别',
|
||||
dataIndex: 'gender'
|
||||
}
|
||||
],
|
||||
datasource: [{ name: '张三', age: 18, gender: '男' }, { name: '李四', age: 20, gender: '女' }, { name: '王五', age: 22, gender: '男' }]
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
console.log('测试渲染器:', systemMessage);
|
||||
|
||||
this.messages.push(systemMessage);
|
||||
},
|
||||
|
||||
async handleHistoryDialogOpen() {
|
||||
if (!this.historyLoaded) {
|
||||
await this.refreshHistory();
|
||||
}
|
||||
},
|
||||
|
||||
async refreshHistory() {
|
||||
try {
|
||||
this.isLoadingHistory = true;
|
||||
const res = await getConversationList();
|
||||
this.chatHistory = res.data.map((chat, idx) => ({
|
||||
id: chat.conversationId,
|
||||
title: chat.conversationTitle || '未命名对话' + (idx + 1),
|
||||
time: chat.createTime,
|
||||
loading: false
|
||||
}));
|
||||
this.historyLoaded = true;
|
||||
} catch (error) {
|
||||
this.$message.error('获取历史对话失败');
|
||||
} finally {
|
||||
this.isLoadingHistory = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadChat (chat) {
|
||||
try {
|
||||
chat.loading = true;
|
||||
this.showHistory = false;
|
||||
const res = await getConversationDetail(chat.id);
|
||||
this.currentChatId = chat.id;
|
||||
// 将消息列表转换为本地格式
|
||||
this.messages = res.data.map(msg => ({
|
||||
type: msg.role === 'user' ? 'user' : 'system',
|
||||
content: msg.content,
|
||||
renderType: msg.renderType || 'text',
|
||||
time: msg.createTime
|
||||
}));
|
||||
} catch (error) {
|
||||
this.$message.error('加载对话内容失败');
|
||||
return;
|
||||
} finally {
|
||||
chat.loading = false;
|
||||
}
|
||||
},
|
||||
analyzeData () {
|
||||
console.log('分析数据');
|
||||
this.message = '分析数据';
|
||||
this.handleSendMessage();
|
||||
},
|
||||
generateReport () {
|
||||
console.log('生成报告');
|
||||
this.message = '生成报告';
|
||||
this.handleSendMessage();
|
||||
},
|
||||
generateCode () {
|
||||
console.log('生成代码');
|
||||
this.message = '生成代码';
|
||||
this.handleSendMessage();
|
||||
},
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-chat-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ai-chat-container {
|
||||
height: calc(100vh - 120px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
display: flex;
|
||||
margin-bottom: 20px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.user-message {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: #f0f2f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.loading-message .el-icon-loading {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.user-message .message-text {
|
||||
background-color: #409EFF;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.system-message .message-text {
|
||||
background-color: #f0f2f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.message-input {
|
||||
border-top: 1px solid #eee;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.message-textarea {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.user-message .message-content {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.system-message .message-content {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.history-dialog {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dialog-title .title-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dialog-title .el-icon-refresh {
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.dialog-title .el-icon-refresh:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.dialog-title .el-icon-refresh.is-loading {
|
||||
animation: rotating 2s linear infinite;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.history-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.history-item:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.history-item.active {
|
||||
background-color: #ecf5ff;
|
||||
}
|
||||
|
||||
.history-item-content {
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.history-title {
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.history-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-history {
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
padding: 30px 0;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.icon-wrapper:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.icon-wrapper+.icon-wrapper {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.system-message .message-text {
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
76
ruoyi-ui/src/layout/components/AppMain.vue
Normal file
76
ruoyi-ui/src/layout/components/AppMain.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<section class="app-main">
|
||||
<transition name="fade-transform" mode="out-in">
|
||||
<keep-alive :include="cachedViews">
|
||||
<router-view v-if="!$route.meta.link" :key="key" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
<iframe-toggle />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import iframeToggle from "./IframeToggle/index";
|
||||
|
||||
export default {
|
||||
name: 'AppMain',
|
||||
components: { iframeToggle },
|
||||
computed: {
|
||||
cachedViews () {
|
||||
return this.$store.state.tagsView.cachedViews
|
||||
},
|
||||
key () {
|
||||
return this.$route.path
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-main {
|
||||
/* 50= navbar 50 */
|
||||
min-height: calc(100vh - 50px);
|
||||
width: 100%;
|
||||
background-color: #f5f5f5;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fixed-header+.app-main {
|
||||
padding-top: 50px;
|
||||
}
|
||||
|
||||
.hasTagsView {
|
||||
.app-main {
|
||||
/* 84 = navbar + tags-view = 50 + 34 */
|
||||
min-height: calc(100vh - 84px);
|
||||
}
|
||||
|
||||
.fixed-header+.app-main {
|
||||
padding-top: 84px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
// fix css style bug in open el-dialog
|
||||
.el-popup-parent--hidden {
|
||||
.fixed-header {
|
||||
padding-right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #c0c0c0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
251
ruoyi-ui/src/layout/components/ChatComponent/index.vue
Normal file
251
ruoyi-ui/src/layout/components/ChatComponent/index.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
:visible.sync="drawerVisible"
|
||||
size="50%"
|
||||
title="聊天"
|
||||
:before-close="handleClose"
|
||||
>
|
||||
<div class="chat-container">
|
||||
<!-- 联系人列表 -->
|
||||
<div class="contact-list">
|
||||
<el-scrollbar>
|
||||
<div
|
||||
v-for="contact in contacts"
|
||||
:key="contact.contactId"
|
||||
class="contact-item"
|
||||
@click="selectContact(contact)"
|
||||
>
|
||||
<el-avatar :src="contact.user.avatar"/>
|
||||
<span>{{ contact.user.nickName }}</span>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
|
||||
<!-- 聊天内容区域 -->
|
||||
<div class="chat-box">
|
||||
<el-scrollbar style="height:80%" id="message_content_end">
|
||||
<div class="messages" ref="messageBox" >
|
||||
<div v-for="message in chatHistory" :key="message.id" class="message">
|
||||
<span>{{ contactUser.nickName }}: </span>
|
||||
<span>{{ message.content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
|
||||
<!-- 消息输入框 -->
|
||||
<div style="bottom:0;height:20%">
|
||||
<el-input
|
||||
v-model="newMessage"
|
||||
placeholder="输入消息..."
|
||||
suffix-icon="el-icon-chat-dot-round"
|
||||
@keyup.enter="sendMessage"
|
||||
/>
|
||||
<el-button @click="sendMessage" icon="el-icon-paperclip">发送</el-button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getContact, listContact } from "@/api/system/contact";
|
||||
import { addMessage } from "@/api/system/message";
|
||||
import { parseTime } from "@/utils/ruoyi";
|
||||
|
||||
export default {
|
||||
name: 'ChatComponent',
|
||||
props:{
|
||||
drawerVisible:Boolean,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
contacts: [], // 联系人列表
|
||||
selectedContact: null, // 当前选中的联系人
|
||||
chatHistory: [], // 当前聊天记录
|
||||
newMessage: '', // 输入框中的消息
|
||||
socket: null, // WebSocket实例
|
||||
contactQueryParams: {
|
||||
pageSize: 10,
|
||||
pageNum: 1
|
||||
},
|
||||
contactUser:{}
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
// 打开聊天窗口
|
||||
openChat() {
|
||||
this.drawerVisible = true;
|
||||
},
|
||||
|
||||
// 关闭聊天窗口
|
||||
handleClose() {
|
||||
console.log("关闭聊天窗口");
|
||||
// props是只读的,不能直接修改,需要通过$emit通知父组件关闭
|
||||
// this.drawerVisible = false;
|
||||
this.$emit('close'); // 通知父组件关闭聊天窗口
|
||||
},
|
||||
|
||||
|
||||
// 选择联系人
|
||||
selectContact(contact) {
|
||||
this.selectedContact = contact;
|
||||
this.chatHistory = []; // 清空当前聊天记录
|
||||
this.loadMessage(contact.id);
|
||||
},
|
||||
|
||||
getContactList() {
|
||||
this.contactListLoading = true;
|
||||
this.contactQueryParams.userId = this.userId;
|
||||
listContact(this.contactQueryParams).then(response => {
|
||||
if (response.code === 200) {
|
||||
this.contacts = response.rows;
|
||||
this.contactListTotal = response.total;
|
||||
const contactUserId = this.$route.query.userId;
|
||||
if (contactUserId) {
|
||||
this.contactUserId = contactUserId;
|
||||
let contact = response.rows.find(row => row.contactUserId === contactUserId);
|
||||
this.loadMessage(contact.id);
|
||||
}
|
||||
}
|
||||
this.contactListLoading = false;
|
||||
})
|
||||
},
|
||||
contactLoadMore() {
|
||||
// this.contactQueryParams.pageSize = 5;
|
||||
// this.contactQueryParams.pageNum++;
|
||||
this.getContactList();
|
||||
},
|
||||
loadMessage(concatId) {
|
||||
this.msgListLoading = true;
|
||||
getContact(concatId).then(response => {
|
||||
if (response.code === 200) {
|
||||
this.currentContact = response.data;
|
||||
this.contactUser = response.data.user;
|
||||
this.chatHistory = response.data.messages;
|
||||
}
|
||||
this.msgListLoading = false;
|
||||
this.fleshScroll();
|
||||
})
|
||||
},
|
||||
insertEmoji(emoji) {
|
||||
this.inputVal += emoji.data;
|
||||
},
|
||||
sendMessage() {
|
||||
const message = {
|
||||
contactId: this.currentContact.id,
|
||||
userId: this.userId,
|
||||
content: this.inputVal,
|
||||
roomId: this.currentContact.roomId
|
||||
}
|
||||
this.msgList.push({
|
||||
...message,
|
||||
id: this.msgList.length + 1,
|
||||
createTime: parseTime(new Date())
|
||||
})
|
||||
this.fleshLastMsg();
|
||||
addMessage(message)
|
||||
const msg = {
|
||||
sendUserId: this.userId,
|
||||
sendUserName: this.$store.state.user.name,
|
||||
userId: this.currentContact.contactUserId === this.userId ? this.currentContact.user.userId : this.currentContact.contactUserId,
|
||||
type: "chat",
|
||||
detail: this.inputVal
|
||||
}
|
||||
this.$webSocket.sendWebsocket(JSON.stringify(msg));
|
||||
this.inputVal = '';
|
||||
this.fleshScroll();
|
||||
},
|
||||
subscribeMessage(res) {
|
||||
if (res) {
|
||||
const {sendUserId, sendUserName, userId, type, detail} = res.detail.data;
|
||||
|
||||
const message = {
|
||||
id: 1,
|
||||
contactId: this.currentContact.id,
|
||||
userId: sendUserId,
|
||||
content: detail,
|
||||
roomId: this.currentContact.roomId,
|
||||
createTime: parseTime(new Date()),
|
||||
user: this.contactUser
|
||||
}
|
||||
this.msgList.push(message);
|
||||
this.fleshLastMsg();
|
||||
this.fleshScroll();
|
||||
|
||||
|
||||
}
|
||||
},
|
||||
fleshLastMsg() {
|
||||
const index = this.contactList.findIndex(e => e.id === this.currentContact.id);
|
||||
this.contactList[index].endMsg = this.msgList[this.msgList.length - 1].content;
|
||||
},
|
||||
fleshScroll() {
|
||||
this.$nextTick(() => {
|
||||
document.getElementById("message_content_end").scrollIntoView();
|
||||
})
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.userId = this.$store.state.user.id;
|
||||
this.subscribeMessage();
|
||||
this.getContactList();
|
||||
},
|
||||
|
||||
mounted() {
|
||||
window.addEventListener("onmessageWS", this.subscribeMessage);
|
||||
|
||||
}
|
||||
,
|
||||
}
|
||||
;
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-container {
|
||||
display: flex;
|
||||
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.contact-list {
|
||||
width:40%;
|
||||
background: #f4f4f4;
|
||||
padding: 10px;
|
||||
border-right: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.contact-item:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.chat-box {
|
||||
display: flex;
|
||||
width: 55%;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.messages {
|
||||
|
||||
overflow-y: auto;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
24
ruoyi-ui/src/layout/components/IframeToggle/index.vue
Normal file
24
ruoyi-ui/src/layout/components/IframeToggle/index.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<transition-group name="fade-transform" mode="out-in">
|
||||
<inner-link
|
||||
v-for="(item, index) in iframeViews"
|
||||
:key="item.path"
|
||||
:iframeId="'iframe' + index"
|
||||
v-show="$route.path === item.path"
|
||||
:src="item.meta.link"
|
||||
></inner-link>
|
||||
</transition-group>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import InnerLink from "../InnerLink/index"
|
||||
|
||||
export default {
|
||||
components: { InnerLink },
|
||||
computed: {
|
||||
iframeViews() {
|
||||
return this.$store.state.tagsView.iframeViews
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
47
ruoyi-ui/src/layout/components/InnerLink/index.vue
Normal file
47
ruoyi-ui/src/layout/components/InnerLink/index.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div :style="'height:' + height" v-loading="loading" element-loading-text="正在加载页面,请稍候!">
|
||||
<iframe
|
||||
:id="iframeId"
|
||||
style="width: 100%; height: 100%"
|
||||
:src="src"
|
||||
frameborder="no"
|
||||
></iframe>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
src: {
|
||||
type: String,
|
||||
default: "/"
|
||||
},
|
||||
iframeId: {
|
||||
type: String
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
height: document.documentElement.clientHeight - 94.5 + "px;"
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
var _this = this;
|
||||
const iframeId = ("#" + this.iframeId).replace(/\//g, "\\/");
|
||||
const iframe = document.querySelector(iframeId);
|
||||
// iframe页面loading控制
|
||||
if (iframe.attachEvent) {
|
||||
this.loading = true;
|
||||
iframe.attachEvent("onload", function () {
|
||||
_this.loading = false;
|
||||
});
|
||||
} else {
|
||||
this.loading = true;
|
||||
iframe.onload = function () {
|
||||
_this.loading = false;
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
471
ruoyi-ui/src/layout/components/Navbar.vue
Normal file
471
ruoyi-ui/src/layout/components/Navbar.vue
Normal file
@@ -0,0 +1,471 @@
|
||||
<template>
|
||||
<div class="navbar">
|
||||
<hamburger id="hamburger-container" :is-active="sidebar.opened" class="hamburger-container"
|
||||
@toggleClick="toggleSideBar" />
|
||||
|
||||
<breadcrumb id="breadcrumb-container" class="breadcrumb-container" v-if="!topNav" />
|
||||
<top-nav id="topmenu-container" class="topmenu-container" v-if="topNav" />
|
||||
|
||||
<div class="right-menu">
|
||||
<template v-if="device !== 'mobile'">
|
||||
<search id="header-search" class="right-menu-item" />
|
||||
|
||||
<!-- <div style="position: absolute; top: 0; right: 300px; font-weight: 200">
|
||||
<el-button class="el-icon-s-comment" @click="chat = true" style=""
|
||||
>打开聊天</el-button
|
||||
>
|
||||
<chat-component
|
||||
:drawerVisible="chat"
|
||||
ref="chatComponent"
|
||||
@close="hiddenChat"
|
||||
/>
|
||||
</div> -->
|
||||
<AIChat class="right-menu-item" />
|
||||
<screenfull id="screenfull" class="right-menu-item hover-effect" />
|
||||
<el-tooltip content="用户" effect="dark" placement="bottom">
|
||||
<div class="right-menu-item hover-effect">
|
||||
{{ roleGroup }} / {{ user.nickName }}
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
|
||||
<el-dropdown class="avatar-container right-menu-item hover-effect" trigger="click">
|
||||
<div class="avatar-wrapper">
|
||||
<img :src="avatar" class="user-avatar" />
|
||||
<i class="el-icon-caret-bottom" />
|
||||
</div>
|
||||
<el-dropdown-menu slot="dropdown">
|
||||
<router-link to="/user/profile">
|
||||
<el-dropdown-item>个人中心</el-dropdown-item>
|
||||
</router-link>
|
||||
<el-dropdown-item @click.native="setting = true">
|
||||
<span>布局设置</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click.native="logout">
|
||||
<span>退出登录</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getContact, listContact } from "@/api/system/contact";
|
||||
import { addMessage } from "@/api/system/message";
|
||||
import { getUserProfile } from "@/api/system/user";
|
||||
import Breadcrumb from "@/components/Breadcrumb";
|
||||
import Hamburger from "@/components/Hamburger";
|
||||
import Search from "@/components/HeaderSearch";
|
||||
import RuoYiDoc from "@/components/RuoYi/Doc";
|
||||
import RuoYiGit from "@/components/RuoYi/Git";
|
||||
import Screenfull from "@/components/Screenfull";
|
||||
import SizeSelect from "@/components/SizeSelect";
|
||||
import TopNav from "@/components/TopNav";
|
||||
import AIChat from "@/layout/components/AIChat/index.vue";
|
||||
import { parseTime } from "@/utils/ruoyi";
|
||||
import { mapGetters } from "vuex";
|
||||
// import {
|
||||
// default as ChatComponent,
|
||||
// default as chatComponent,
|
||||
// } from "./ChatComponent/index.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
// ChatComponent,
|
||||
Breadcrumb,
|
||||
TopNav,
|
||||
Hamburger,
|
||||
Screenfull,
|
||||
SizeSelect,
|
||||
Search,
|
||||
RuoYiGit,
|
||||
RuoYiDoc,
|
||||
AIChat,
|
||||
},
|
||||
computed: {
|
||||
// chatComponent() {
|
||||
// return chatComponent;
|
||||
// },
|
||||
...mapGetters(["sidebar", "avatar", "device"]),
|
||||
setting: {
|
||||
get () {
|
||||
return this.$store.state.settings.showSettings;
|
||||
},
|
||||
set (val) {
|
||||
this.$store.dispatch("settings/changeSetting", {
|
||||
key: "showSettings",
|
||||
value: val,
|
||||
});
|
||||
},
|
||||
},
|
||||
topNav: {
|
||||
get () {
|
||||
return this.$store.state.settings.topNav;
|
||||
},
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
user: {},
|
||||
chat: false,
|
||||
roleGroup: {},
|
||||
// postGroup: {},
|
||||
|
||||
//联系人列表
|
||||
contactList: [],
|
||||
contactListTotal: 0,
|
||||
contactListLoading: false,
|
||||
//消息记录
|
||||
msgList: [],
|
||||
msgListTotal: 0,
|
||||
msgListLoading: false,
|
||||
inputVal: "",
|
||||
search: "",
|
||||
contactUserId: null,
|
||||
userId: null,
|
||||
contactQueryParams: {
|
||||
pageSize: 10,
|
||||
pageNum: 1,
|
||||
},
|
||||
currentContact: {},
|
||||
contactUser: {},
|
||||
};
|
||||
},
|
||||
mounted () {
|
||||
window.addEventListener("onmessageWS", this.subscribeMessage);
|
||||
},
|
||||
created () {
|
||||
this.getUser();
|
||||
this.userId = this.$store.state.user.id;
|
||||
this.subscribeMessage();
|
||||
this.getContactList();
|
||||
},
|
||||
methods: {
|
||||
toggleSideBar () {
|
||||
this.$store.dispatch("app/toggleSideBar");
|
||||
},
|
||||
|
||||
hiddenChat () {
|
||||
this.chat = false;
|
||||
console.log(this.chat, "关闭chat");
|
||||
},
|
||||
|
||||
getUser () {
|
||||
getUserProfile().then((response) => {
|
||||
this.user = response.data.user;
|
||||
this.roleGroup = response.data.roleGroup;
|
||||
this.postGroup = response.data.postGroup;
|
||||
});
|
||||
},
|
||||
async logout () {
|
||||
this.$confirm("确定注销并退出系统吗?", "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
})
|
||||
.then(() => {
|
||||
this.$store.dispatch("LogOut").then(() => {
|
||||
console.log('退出登录')
|
||||
location.href = process.env.VUE_APP_CONTEXT_PATH + "index";
|
||||
});
|
||||
})
|
||||
.catch(() => { });
|
||||
},
|
||||
|
||||
/***********************************************************************/
|
||||
|
||||
getContactList () {
|
||||
this.contactListLoading = true;
|
||||
this.contactQueryParams.userId = this.userId;
|
||||
listContact(this.contactQueryParams).then((response) => {
|
||||
if (response.code === 200) {
|
||||
this.contactList = response.rows;
|
||||
this.contactListTotal = response.total;
|
||||
const contactUserId = this.$route.query.userId;
|
||||
if (contactUserId) {
|
||||
this.contactUserId = contactUserId;
|
||||
let contact = response.rows.find(
|
||||
(row) => row.contactUserId == contactUserId
|
||||
);
|
||||
this.loadMessage(contact.id);
|
||||
}
|
||||
}
|
||||
this.contactListLoading = false;
|
||||
});
|
||||
},
|
||||
contactLoadMore () {
|
||||
// this.contactQueryParams.pageSize = 5;
|
||||
// this.contactQueryParams.pageNum++;
|
||||
this.getContactList();
|
||||
},
|
||||
loadMessage (concatId) {
|
||||
this.msgListLoading = true;
|
||||
getContact(concatId).then((response) => {
|
||||
if (response.code === 200) {
|
||||
this.currentContact = response.data;
|
||||
this.contactUser = response.data.user;
|
||||
this.msgList = response.data.messages;
|
||||
}
|
||||
this.msgListLoading = false;
|
||||
this.fleshScroll();
|
||||
});
|
||||
},
|
||||
insertEmoji (emoji) {
|
||||
this.inputVal += emoji.data;
|
||||
},
|
||||
send () {
|
||||
const message = {
|
||||
contactId: this.currentContact.id,
|
||||
userId: this.userId,
|
||||
content: this.inputVal,
|
||||
roomId: this.currentContact.roomId,
|
||||
};
|
||||
this.msgList.push({
|
||||
...message,
|
||||
id: this.msgList.length + 1,
|
||||
createTime: parseTime(new Date()),
|
||||
});
|
||||
this.fleshLastMsg();
|
||||
addMessage(message);
|
||||
const msg = {
|
||||
sendUserId: this.userId,
|
||||
sendUserName: this.$store.state.user.name,
|
||||
userId:
|
||||
this.currentContact.contactUserId === this.userId
|
||||
? this.currentContact.user.userId
|
||||
: this.currentContact.contactUserId,
|
||||
type: "chat",
|
||||
detail: this.inputVal,
|
||||
};
|
||||
// this.$webSocket.sendWebsocket(JSON.stringify(msg));
|
||||
this.inputVal = "";
|
||||
this.fleshScroll();
|
||||
},
|
||||
subscribeMessage (res) {
|
||||
if (res) {
|
||||
const { sendUserId, sendUserName, userId, type, detail } =
|
||||
res.detail.data;
|
||||
|
||||
const message = {
|
||||
id: 1,
|
||||
contactId: this.currentContact.id,
|
||||
userId: sendUserId,
|
||||
content: detail,
|
||||
roomId: this.currentContact.roomId,
|
||||
createTime: parseTime(new Date()),
|
||||
user: this.contactUser,
|
||||
};
|
||||
this.msgList.push(message);
|
||||
this.fleshLastMsg();
|
||||
this.fleshScroll();
|
||||
}
|
||||
},
|
||||
fleshLastMsg () {
|
||||
const index = this.contactList.findIndex(
|
||||
(e) => e.id === this.currentContact.id
|
||||
);
|
||||
this.contactList[index].endMsg =
|
||||
this.msgList[this.msgList.length - 1].content;
|
||||
},
|
||||
fleshScroll () {
|
||||
this.$nextTick(() => {
|
||||
document.getElementById("message_content_end").scrollIntoView();
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.navbar {
|
||||
height: 50px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #f5f5f5;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
|
||||
.hamburger-container {
|
||||
line-height: 46px;
|
||||
height: 100%;
|
||||
float: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.025);
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb-container {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.topmenu-container {
|
||||
position: absolute;
|
||||
left: 50px;
|
||||
}
|
||||
|
||||
.errLog-container {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.right-menu {
|
||||
float: right;
|
||||
height: 100%;
|
||||
line-height: 50px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.right-menu-item {
|
||||
display: inline-block;
|
||||
padding: 0 8px;
|
||||
height: 100%;
|
||||
font-size: 18px;
|
||||
color: #5a5e66;
|
||||
vertical-align: text-bottom;
|
||||
|
||||
&.hover-effect {
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.025);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
margin-right: 30px;
|
||||
height: 100%;
|
||||
|
||||
.avatar-wrapper {
|
||||
margin-top: 5px;
|
||||
// position: relative;
|
||||
|
||||
.user-avatar {
|
||||
cursor: pointer;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.el-icon-caret-bottom {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%);
|
||||
right: -20px;
|
||||
// top: 25px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app-container {
|
||||
background: linear-gradient(180deg,
|
||||
rgba(0, 190, 189, 0.1),
|
||||
rgba(136, 255, 254, 0.2) 50%,
|
||||
rgba(242, 244, 247, 0.1));
|
||||
}
|
||||
|
||||
.app {
|
||||
background-color: white;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.msgListMain {
|
||||
height: 500px;
|
||||
overflow-y: auto;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.msgUserList {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.msgListMain_empty {
|
||||
display: flex;
|
||||
height: 500px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hover_down_menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.msgUserList:hover {
|
||||
background-color: #f2f2f2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.msgUserList:hover .hover_down_menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.el-dropdown-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.el-icon-arrow-down {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.main {
|
||||
background-color: white;
|
||||
height: 86%;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.main_empty {
|
||||
display: flex;
|
||||
background-color: white;
|
||||
height: 600px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.main_empty .el-row {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.main_empty img {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.msg_content {
|
||||
margin-top: 30px;
|
||||
height: 50%;
|
||||
overflow: auto;
|
||||
//background-color: gray;
|
||||
}
|
||||
|
||||
.chat_bubble {
|
||||
float: right;
|
||||
margin-right: 35px;
|
||||
color: #333;
|
||||
background-color: rgba(0, 190, 189, 0.2);
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
padding: 0 12px 0 12px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.input_top_menu_img {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
260
ruoyi-ui/src/layout/components/Settings/index.vue
Normal file
260
ruoyi-ui/src/layout/components/Settings/index.vue
Normal file
@@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<el-drawer size="280px" :visible="visible" :with-header="false" :append-to-body="true" :show-close="false">
|
||||
<div class="drawer-container">
|
||||
<div>
|
||||
<div class="setting-drawer-content">
|
||||
<div class="setting-drawer-title">
|
||||
<h3 class="drawer-title">主题风格设置</h3>
|
||||
</div>
|
||||
<div class="setting-drawer-block-checbox">
|
||||
<div class="setting-drawer-block-checbox-item" @click="handleTheme('theme-dark')">
|
||||
<img src="@/assets/images/dark.svg" alt="dark">
|
||||
<div v-if="sideTheme === 'theme-dark'" class="setting-drawer-block-checbox-selectIcon" style="display: block;">
|
||||
<i aria-label="图标: check" class="anticon anticon-check">
|
||||
<svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false" class="">
|
||||
<path d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 0 0-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z"/>
|
||||
</svg>
|
||||
</i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-drawer-block-checbox-item" @click="handleTheme('theme-light')">
|
||||
<img src="@/assets/images/light.svg" alt="light">
|
||||
<div v-if="sideTheme === 'theme-light'" class="setting-drawer-block-checbox-selectIcon" style="display: block;">
|
||||
<i aria-label="图标: check" class="anticon anticon-check">
|
||||
<svg viewBox="64 64 896 896" data-icon="check" width="1em" height="1em" :fill="theme" aria-hidden="true" focusable="false" class="">
|
||||
<path d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 0 0-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z"/>
|
||||
</svg>
|
||||
</i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-item">
|
||||
<span>主题颜色</span>
|
||||
<theme-picker style="float: right;height: 26px;margin: -3px 8px 0 0;" @change="themeChange" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider/>
|
||||
|
||||
<h3 class="drawer-title">系统布局配置</h3>
|
||||
|
||||
<div class="drawer-item">
|
||||
<span>开启 TopNav</span>
|
||||
<el-switch v-model="topNav" class="drawer-switch" />
|
||||
</div>
|
||||
|
||||
<div class="drawer-item">
|
||||
<span>开启 Tags-Views</span>
|
||||
<el-switch v-model="tagsView" class="drawer-switch" />
|
||||
</div>
|
||||
|
||||
<div class="drawer-item">
|
||||
<span>固定 Header</span>
|
||||
<el-switch v-model="fixedHeader" class="drawer-switch" />
|
||||
</div>
|
||||
|
||||
<div class="drawer-item">
|
||||
<span>显示 Logo</span>
|
||||
<el-switch v-model="sidebarLogo" class="drawer-switch" />
|
||||
</div>
|
||||
|
||||
<div class="drawer-item">
|
||||
<span>动态标题</span>
|
||||
<el-switch v-model="dynamicTitle" class="drawer-switch" />
|
||||
</div>
|
||||
|
||||
<el-divider/>
|
||||
|
||||
<el-button size="small" type="primary" plain icon="el-icon-document-add" @click="saveSetting">保存配置</el-button>
|
||||
<el-button size="small" plain icon="el-icon-refresh" @click="resetSetting">重置配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ThemePicker from '@/components/ThemePicker'
|
||||
|
||||
export default {
|
||||
components: { ThemePicker },
|
||||
data() {
|
||||
return {
|
||||
theme: this.$store.state.settings.theme,
|
||||
sideTheme: this.$store.state.settings.sideTheme
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
visible: {
|
||||
get() {
|
||||
return this.$store.state.settings.showSettings
|
||||
}
|
||||
},
|
||||
fixedHeader: {
|
||||
get() {
|
||||
return this.$store.state.settings.fixedHeader
|
||||
},
|
||||
set(val) {
|
||||
this.$store.dispatch('settings/changeSetting', {
|
||||
key: 'fixedHeader',
|
||||
value: val
|
||||
})
|
||||
}
|
||||
},
|
||||
topNav: {
|
||||
get() {
|
||||
return this.$store.state.settings.topNav
|
||||
},
|
||||
set(val) {
|
||||
this.$store.dispatch('settings/changeSetting', {
|
||||
key: 'topNav',
|
||||
value: val
|
||||
})
|
||||
if (!val) {
|
||||
this.$store.dispatch('app/toggleSideBarHide', false);
|
||||
this.$store.commit("SET_SIDEBAR_ROUTERS", this.$store.state.permission.defaultRoutes);
|
||||
}
|
||||
}
|
||||
},
|
||||
tagsView: {
|
||||
get() {
|
||||
return this.$store.state.settings.tagsView
|
||||
},
|
||||
set(val) {
|
||||
this.$store.dispatch('settings/changeSetting', {
|
||||
key: 'tagsView',
|
||||
value: val
|
||||
})
|
||||
}
|
||||
},
|
||||
sidebarLogo: {
|
||||
get() {
|
||||
return this.$store.state.settings.sidebarLogo
|
||||
},
|
||||
set(val) {
|
||||
this.$store.dispatch('settings/changeSetting', {
|
||||
key: 'sidebarLogo',
|
||||
value: val
|
||||
})
|
||||
}
|
||||
},
|
||||
dynamicTitle: {
|
||||
get() {
|
||||
return this.$store.state.settings.dynamicTitle
|
||||
},
|
||||
set(val) {
|
||||
this.$store.dispatch('settings/changeSetting', {
|
||||
key: 'dynamicTitle',
|
||||
value: val
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
themeChange(val) {
|
||||
this.$store.dispatch('settings/changeSetting', {
|
||||
key: 'theme',
|
||||
value: val
|
||||
})
|
||||
this.theme = val;
|
||||
},
|
||||
handleTheme(val) {
|
||||
this.$store.dispatch('settings/changeSetting', {
|
||||
key: 'sideTheme',
|
||||
value: val
|
||||
})
|
||||
this.sideTheme = val;
|
||||
},
|
||||
saveSetting() {
|
||||
this.$modal.loading("正在保存到本地,请稍候...");
|
||||
this.$cache.local.set(
|
||||
"layout-setting",
|
||||
`{
|
||||
"topNav":${this.topNav},
|
||||
"tagsView":${this.tagsView},
|
||||
"fixedHeader":${this.fixedHeader},
|
||||
"sidebarLogo":${this.sidebarLogo},
|
||||
"dynamicTitle":${this.dynamicTitle},
|
||||
"sideTheme":"${this.sideTheme}",
|
||||
"theme":"${this.theme}"
|
||||
}`
|
||||
);
|
||||
setTimeout(this.$modal.closeLoading(), 1000)
|
||||
},
|
||||
resetSetting() {
|
||||
this.$modal.loading("正在清除设置缓存并刷新,请稍候...");
|
||||
this.$cache.local.remove("layout-setting")
|
||||
setTimeout("window.location.reload()", 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.setting-drawer-content {
|
||||
.setting-drawer-title {
|
||||
margin-bottom: 12px;
|
||||
color: rgba(0, 0, 0, .85);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.setting-drawer-block-checbox {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.setting-drawer-block-checbox-item {
|
||||
position: relative;
|
||||
margin-right: 16px;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.setting-drawer-block-checbox-selectIcon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-top: 15px;
|
||||
padding-left: 24px;
|
||||
color: #1890ff;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-container {
|
||||
padding: 20px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
|
||||
.drawer-title {
|
||||
margin-bottom: 12px;
|
||||
color: rgba(0, 0, 0, .85);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.drawer-item {
|
||||
color: rgba(0, 0, 0, .65);
|
||||
font-size: 14px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.drawer-switch {
|
||||
float: right
|
||||
}
|
||||
}
|
||||
</style>
|
||||
25
ruoyi-ui/src/layout/components/Sidebar/FixiOSBug.js
Normal file
25
ruoyi-ui/src/layout/components/Sidebar/FixiOSBug.js
Normal file
@@ -0,0 +1,25 @@
|
||||
export default {
|
||||
computed: {
|
||||
device() {
|
||||
return this.$store.state.app.device
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// In order to fix the click on menu on the ios device will trigger the mouseleave bug
|
||||
this.fixBugIniOS()
|
||||
},
|
||||
methods: {
|
||||
fixBugIniOS() {
|
||||
const $subMenu = this.$refs.subMenu
|
||||
if ($subMenu) {
|
||||
const handleMouseleave = $subMenu.handleMouseleave
|
||||
$subMenu.handleMouseleave = (e) => {
|
||||
if (this.device === 'mobile') {
|
||||
return
|
||||
}
|
||||
handleMouseleave(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
ruoyi-ui/src/layout/components/Sidebar/Item.vue
Normal file
33
ruoyi-ui/src/layout/components/Sidebar/Item.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script>
|
||||
export default {
|
||||
name: 'MenuItem',
|
||||
functional: true,
|
||||
props: {
|
||||
icon: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
render(h, context) {
|
||||
const { icon, title } = context.props
|
||||
const vnodes = []
|
||||
|
||||
if (icon) {
|
||||
vnodes.push(<svg-icon icon-class={icon}/>)
|
||||
}
|
||||
|
||||
if (title) {
|
||||
if (title.length > 5) {
|
||||
vnodes.push(<span slot='title' title={(title)}>{(title)}</span>)
|
||||
} else {
|
||||
vnodes.push(<span slot='title'>{(title)}</span>)
|
||||
}
|
||||
}
|
||||
return vnodes
|
||||
}
|
||||
}
|
||||
</script>
|
||||
43
ruoyi-ui/src/layout/components/Sidebar/Link.vue
Normal file
43
ruoyi-ui/src/layout/components/Sidebar/Link.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<component :is="type" v-bind="linkProps(to)">
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { isExternal } from '@/utils/validate'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
to: {
|
||||
type: [String, Object],
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isExternal() {
|
||||
return isExternal(this.to)
|
||||
},
|
||||
type() {
|
||||
if (this.isExternal) {
|
||||
return 'a'
|
||||
}
|
||||
return 'router-link'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
linkProps(to) {
|
||||
if (this.isExternal) {
|
||||
return {
|
||||
href: to,
|
||||
target: '_blank',
|
||||
rel: 'noopener'
|
||||
}
|
||||
}
|
||||
return {
|
||||
to: to
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
93
ruoyi-ui/src/layout/components/Sidebar/Logo.vue
Normal file
93
ruoyi-ui/src/layout/components/Sidebar/Logo.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="sidebar-logo-container" :class="{'collapse':collapse}" :style="{ backgroundColor: sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground }">
|
||||
<transition name="sidebarLogoFade">
|
||||
<router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/">
|
||||
<img v-if="logo" :src="logo" class="sidebar-logo" />
|
||||
<h1 v-else class="sidebar-title" :style="{ color: sideTheme === 'theme-dark' ? variables.logoTitleColor : variables.logoLightTitleColor }">{{ title }} </h1>
|
||||
</router-link>
|
||||
<router-link v-else key="expand" class="sidebar-logo-link" to="/">
|
||||
<img v-if="logo" :src="logo" class="sidebar-logo" />
|
||||
<h1 class="sidebar-title" :style="{ color: sideTheme === 'theme-dark' ? variables.logoTitleColor : variables.logoLightTitleColor }">{{ title }} </h1>
|
||||
</router-link>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import logoImg from '@/assets/logo/logo.png';
|
||||
import variables from '@/assets/styles/variables.scss';
|
||||
|
||||
export default {
|
||||
name: 'SidebarLogo',
|
||||
props: {
|
||||
collapse: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
variables() {
|
||||
return variables;
|
||||
},
|
||||
sideTheme() {
|
||||
return this.$store.state.settings.sideTheme
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
title: '福安德综合办公系统',
|
||||
logo: logoImg
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sidebarLogoFade-enter-active {
|
||||
transition: opacity 1.5s;
|
||||
}
|
||||
|
||||
.sidebarLogoFade-enter,
|
||||
.sidebarLogoFade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar-logo-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
background: #2b2f3a;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
|
||||
& .sidebar-logo-link {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
& .sidebar-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
vertical-align: middle;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
& .sidebar-title {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
line-height: 50px;
|
||||
font-size: 14px;
|
||||
font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
&.collapse {
|
||||
.sidebar-logo {
|
||||
margin-right: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
100
ruoyi-ui/src/layout/components/Sidebar/SidebarItem.vue
Normal file
100
ruoyi-ui/src/layout/components/Sidebar/SidebarItem.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div v-if="!item.hidden">
|
||||
<template v-if="hasOneShowingChild(item.children,item) && (!onlyOneChild.children||onlyOneChild.noShowingChildren)&&!item.alwaysShow">
|
||||
<app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path, onlyOneChild.query)">
|
||||
<el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{'submenu-title-noDropdown':!isNest}">
|
||||
<item :icon="onlyOneChild.meta.icon||(item.meta&&item.meta.icon)" :title="onlyOneChild.meta.title" />
|
||||
</el-menu-item>
|
||||
</app-link>
|
||||
</template>
|
||||
|
||||
<el-submenu v-else ref="subMenu" :index="resolvePath(item.path)" popper-append-to-body>
|
||||
<template slot="title">
|
||||
<item v-if="item.meta" :icon="item.meta && item.meta.icon" :title="item.meta.title" />
|
||||
</template>
|
||||
<sidebar-item
|
||||
v-for="child in item.children"
|
||||
:key="child.path + child.meta.title"
|
||||
:is-nest="true"
|
||||
:item="child"
|
||||
:base-path="resolvePath(child.path)"
|
||||
class="nest-menu"
|
||||
/>
|
||||
</el-submenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { isExternal } from '@/utils/validate'
|
||||
import path from 'path'
|
||||
import FixiOSBug from './FixiOSBug'
|
||||
import Item from './Item'
|
||||
import AppLink from './Link'
|
||||
|
||||
export default {
|
||||
name: 'SidebarItem',
|
||||
components: { Item, AppLink },
|
||||
mixins: [FixiOSBug],
|
||||
props: {
|
||||
// route object
|
||||
item: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isNest: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
basePath: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
data() {
|
||||
this.onlyOneChild = null
|
||||
return {}
|
||||
},
|
||||
methods: {
|
||||
hasOneShowingChild(children = [], parent) {
|
||||
if (!children) {
|
||||
children = [];
|
||||
}
|
||||
const showingChildren = children.filter(item => {
|
||||
if (item.hidden) {
|
||||
return false
|
||||
} else {
|
||||
// Temp set(will be used if only has one showing child)
|
||||
this.onlyOneChild = item
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
// When there is only one child router, the child router is displayed by default
|
||||
if (showingChildren.length === 1) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Show parent if there are no child router to display
|
||||
if (showingChildren.length === 0) {
|
||||
this.onlyOneChild = { ... parent, path: '', noShowingChildren: true }
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
resolvePath(routePath, routeQuery) {
|
||||
if (isExternal(routePath)) {
|
||||
return routePath
|
||||
}
|
||||
if (isExternal(this.basePath)) {
|
||||
return this.basePath
|
||||
}
|
||||
if (routeQuery) {
|
||||
let query = JSON.parse(routeQuery);
|
||||
return { path: path.resolve(this.basePath, routePath), query: query }
|
||||
}
|
||||
return path.resolve(this.basePath, routePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
57
ruoyi-ui/src/layout/components/Sidebar/index.vue
Normal file
57
ruoyi-ui/src/layout/components/Sidebar/index.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div :class="{'has-logo':showLogo}" :style="{ backgroundColor: settings.sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground }">
|
||||
<logo v-if="showLogo" :collapse="isCollapse" />
|
||||
<el-scrollbar :class="settings.sideTheme" wrap-class="scrollbar-wrapper">
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:collapse="isCollapse"
|
||||
:background-color="settings.sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground"
|
||||
:text-color="settings.sideTheme === 'theme-dark' ? variables.menuColor : variables.menuLightColor"
|
||||
:unique-opened="true"
|
||||
:active-text-color="settings.theme"
|
||||
:collapse-transition="false"
|
||||
mode="vertical"
|
||||
>
|
||||
<sidebar-item
|
||||
v-for="(route, index) in sidebarRouters"
|
||||
:key="route.path + index"
|
||||
:item="route"
|
||||
:base-path="route.path"
|
||||
/>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters, mapState } from "vuex";
|
||||
import Logo from "./Logo";
|
||||
import SidebarItem from "./SidebarItem";
|
||||
import variables from "@/assets/styles/variables.scss";
|
||||
|
||||
export default {
|
||||
components: { SidebarItem, Logo },
|
||||
computed: {
|
||||
...mapState(["settings"]),
|
||||
...mapGetters(["sidebarRouters", "sidebar"]),
|
||||
activeMenu() {
|
||||
const route = this.$route;
|
||||
const { meta, path } = route;
|
||||
// if set path, the sidebar will highlight the path you set
|
||||
if (meta.activeMenu) {
|
||||
return meta.activeMenu;
|
||||
}
|
||||
return path;
|
||||
},
|
||||
showLogo() {
|
||||
return this.$store.state.settings.sidebarLogo;
|
||||
},
|
||||
variables() {
|
||||
return variables;
|
||||
},
|
||||
isCollapse() {
|
||||
return !this.sidebar.opened;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
94
ruoyi-ui/src/layout/components/TagsView/ScrollPane.vue
Normal file
94
ruoyi-ui/src/layout/components/TagsView/ScrollPane.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<el-scrollbar ref="scrollContainer" :vertical="false" class="scroll-container" @wheel.native.prevent="handleScroll">
|
||||
<slot />
|
||||
</el-scrollbar>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const tagAndTagSpacing = 4 // tagAndTagSpacing
|
||||
|
||||
export default {
|
||||
name: 'ScrollPane',
|
||||
data() {
|
||||
return {
|
||||
left: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
scrollWrapper() {
|
||||
return this.$refs.scrollContainer.$refs.wrap
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.scrollWrapper.addEventListener('scroll', this.emitScroll, true)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.scrollWrapper.removeEventListener('scroll', this.emitScroll)
|
||||
},
|
||||
methods: {
|
||||
handleScroll(e) {
|
||||
const eventDelta = e.wheelDelta || -e.deltaY * 40
|
||||
const $scrollWrapper = this.scrollWrapper
|
||||
$scrollWrapper.scrollLeft = $scrollWrapper.scrollLeft + eventDelta / 4
|
||||
},
|
||||
emitScroll() {
|
||||
this.$emit('scroll')
|
||||
},
|
||||
moveToTarget(currentTag) {
|
||||
const $container = this.$refs.scrollContainer.$el
|
||||
const $containerWidth = $container.offsetWidth
|
||||
const $scrollWrapper = this.scrollWrapper
|
||||
const tagList = this.$parent.$refs.tag
|
||||
|
||||
let firstTag = null
|
||||
let lastTag = null
|
||||
|
||||
// find first tag and last tag
|
||||
if (tagList.length > 0) {
|
||||
firstTag = tagList[0]
|
||||
lastTag = tagList[tagList.length - 1]
|
||||
}
|
||||
|
||||
if (firstTag === currentTag) {
|
||||
$scrollWrapper.scrollLeft = 0
|
||||
} else if (lastTag === currentTag) {
|
||||
$scrollWrapper.scrollLeft = $scrollWrapper.scrollWidth - $containerWidth
|
||||
} else {
|
||||
// find preTag and nextTag
|
||||
const currentIndex = tagList.findIndex(item => item === currentTag)
|
||||
const prevTag = tagList[currentIndex - 1]
|
||||
const nextTag = tagList[currentIndex + 1]
|
||||
|
||||
// the tag's offsetLeft after of nextTag
|
||||
const afterNextTagOffsetLeft = nextTag.$el.offsetLeft + nextTag.$el.offsetWidth + tagAndTagSpacing
|
||||
|
||||
// the tag's offsetLeft before of prevTag
|
||||
const beforePrevTagOffsetLeft = prevTag.$el.offsetLeft - tagAndTagSpacing
|
||||
|
||||
if (afterNextTagOffsetLeft > $scrollWrapper.scrollLeft + $containerWidth) {
|
||||
$scrollWrapper.scrollLeft = afterNextTagOffsetLeft - $containerWidth
|
||||
} else if (beforePrevTagOffsetLeft < $scrollWrapper.scrollLeft) {
|
||||
$scrollWrapper.scrollLeft = beforePrevTagOffsetLeft
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.scroll-container {
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
::v-deep {
|
||||
.el-scrollbar__bar {
|
||||
bottom: 0px;
|
||||
}
|
||||
.el-scrollbar__wrap {
|
||||
height: 39px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
334
ruoyi-ui/src/layout/components/TagsView/index.vue
Normal file
334
ruoyi-ui/src/layout/components/TagsView/index.vue
Normal file
@@ -0,0 +1,334 @@
|
||||
<template>
|
||||
<div id="tags-view-container" class="tags-view-container">
|
||||
<scroll-pane ref="scrollPane" class="tags-view-wrapper" @scroll="handleScroll">
|
||||
<router-link v-for="tag in visitedViews" ref="tag" :key="tag.path" :class="isActive(tag) ? 'active' : ''"
|
||||
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }" tag="span" class="tags-view-item"
|
||||
:style="activeStyle(tag)" @click.middle.native="!isAffix(tag) ? closeSelectedTag(tag) : ''"
|
||||
@contextmenu.prevent.native="openMenu(tag, $event)">
|
||||
{{ tag.title }}
|
||||
<span v-if="!isAffix(tag)" class="el-icon-close" @click.prevent.stop="closeSelectedTag(tag)" />
|
||||
</router-link>
|
||||
</scroll-pane>
|
||||
<ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu">
|
||||
<li @click="refreshSelectedTag(selectedTag)"><i class="el-icon-refresh-right"></i> 刷新页面</li>
|
||||
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)"><i class="el-icon-close"></i> 关闭当前</li>
|
||||
<li @click="closeOthersTags"><i class="el-icon-circle-close"></i> 关闭其他</li>
|
||||
<li v-if="!isFirstView()" @click="closeLeftTags"><i class="el-icon-back"></i> 关闭左侧</li>
|
||||
<li v-if="!isLastView()" @click="closeRightTags"><i class="el-icon-right"></i> 关闭右侧</li>
|
||||
<li @click="closeAllTags(selectedTag)"><i class="el-icon-circle-close"></i> 全部关闭</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import path from 'path';
|
||||
import ScrollPane from './ScrollPane';
|
||||
|
||||
export default {
|
||||
components: { ScrollPane },
|
||||
data () {
|
||||
return {
|
||||
visible: false,
|
||||
top: 0,
|
||||
left: 0,
|
||||
selectedTag: {},
|
||||
affixTags: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
visitedViews () {
|
||||
return this.$store.state.tagsView.visitedViews
|
||||
},
|
||||
routes () {
|
||||
return this.$store.state.permission.routes
|
||||
},
|
||||
theme () {
|
||||
return this.$store.state.settings.theme;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
$route () {
|
||||
this.addTags()
|
||||
this.moveToCurrentTag()
|
||||
},
|
||||
visible (value) {
|
||||
if (value) {
|
||||
document.body.addEventListener('click', this.closeMenu)
|
||||
} else {
|
||||
document.body.removeEventListener('click', this.closeMenu)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.initTags()
|
||||
this.addTags()
|
||||
},
|
||||
methods: {
|
||||
isActive (route) {
|
||||
return route.path === this.$route.path
|
||||
},
|
||||
activeStyle (tag) {
|
||||
if (!this.isActive(tag)) return {};
|
||||
return {
|
||||
"background-color": this.theme,
|
||||
"border-color": this.theme
|
||||
};
|
||||
},
|
||||
isAffix (tag) {
|
||||
return tag.meta && tag.meta.affix
|
||||
},
|
||||
isFirstView () {
|
||||
try {
|
||||
return this.selectedTag.fullPath === '/index' || this.selectedTag.fullPath === this.visitedViews[1].fullPath
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
isLastView () {
|
||||
try {
|
||||
return this.selectedTag.fullPath === this.visitedViews[this.visitedViews.length - 1].fullPath
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
filterAffixTags (routes, basePath = '/') {
|
||||
let tags = []
|
||||
routes.forEach(route => {
|
||||
if (route.meta && route.meta.affix) {
|
||||
const tagPath = path.resolve(basePath, route.path)
|
||||
tags.push({
|
||||
fullPath: tagPath,
|
||||
path: tagPath,
|
||||
name: route.name,
|
||||
meta: { ...route.meta }
|
||||
})
|
||||
}
|
||||
if (route.children) {
|
||||
const tempTags = this.filterAffixTags(route.children, route.path)
|
||||
if (tempTags.length >= 1) {
|
||||
tags = [...tags, ...tempTags]
|
||||
}
|
||||
}
|
||||
})
|
||||
return tags
|
||||
},
|
||||
initTags () {
|
||||
const affixTags = this.affixTags = this.filterAffixTags(this.routes)
|
||||
for (const tag of affixTags) {
|
||||
// Must have tag name
|
||||
if (tag.name) {
|
||||
this.$store.dispatch('tagsView/addVisitedView', tag)
|
||||
}
|
||||
}
|
||||
},
|
||||
addTags () {
|
||||
const { name } = this.$route
|
||||
if (name) {
|
||||
this.$store.dispatch('tagsView/addView', this.$route)
|
||||
if (this.$route.meta.link) {
|
||||
this.$store.dispatch('tagsView/addIframeView', this.$route)
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
moveToCurrentTag () {
|
||||
const tags = this.$refs.tag
|
||||
this.$nextTick(() => {
|
||||
for (const tag of tags) {
|
||||
if (tag.to.path === this.$route.path) {
|
||||
this.$refs.scrollPane.moveToTarget(tag)
|
||||
// when query is different then update
|
||||
if (tag.to.fullPath !== this.$route.fullPath) {
|
||||
this.$store.dispatch('tagsView/updateVisitedView', this.$route)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
refreshSelectedTag (view) {
|
||||
this.$tab.refreshPage(view);
|
||||
if (this.$route.meta.link) {
|
||||
this.$store.dispatch('tagsView/delIframeView', this.$route)
|
||||
}
|
||||
},
|
||||
closeSelectedTag (view) {
|
||||
this.$tab.closePage(view).then(({ visitedViews }) => {
|
||||
if (this.isActive(view)) {
|
||||
this.toLastView(visitedViews, view)
|
||||
}
|
||||
})
|
||||
},
|
||||
closeRightTags () {
|
||||
this.$tab.closeRightPage(this.selectedTag).then(visitedViews => {
|
||||
if (!visitedViews.find(i => i.fullPath === this.$route.fullPath)) {
|
||||
this.toLastView(visitedViews)
|
||||
}
|
||||
})
|
||||
},
|
||||
closeLeftTags () {
|
||||
this.$tab.closeLeftPage(this.selectedTag).then(visitedViews => {
|
||||
if (!visitedViews.find(i => i.fullPath === this.$route.fullPath)) {
|
||||
this.toLastView(visitedViews)
|
||||
}
|
||||
})
|
||||
},
|
||||
closeOthersTags () {
|
||||
this.$router.push(this.selectedTag.fullPath).catch(() => { });
|
||||
this.$tab.closeOtherPage(this.selectedTag).then(() => {
|
||||
this.moveToCurrentTag()
|
||||
})
|
||||
},
|
||||
closeAllTags (view) {
|
||||
this.$tab.closeAllPage().then(({ visitedViews }) => {
|
||||
if (this.affixTags.some(tag => tag.path === this.$route.path)) {
|
||||
return
|
||||
}
|
||||
this.toLastView(visitedViews, view)
|
||||
})
|
||||
},
|
||||
toLastView (visitedViews, view) {
|
||||
const latestView = visitedViews.slice(-1)[0]
|
||||
if (latestView) {
|
||||
this.$router.push(latestView.fullPath)
|
||||
} else {
|
||||
// now the default is to redirect to the home page if there is no tags-view,
|
||||
// you can adjust it according to your needs.
|
||||
if (view.name === 'dashboard') {
|
||||
// to reload home page
|
||||
this.$router.replace({ path: '/redirect' + view.fullPath })
|
||||
} else {
|
||||
this.$router.push('/')
|
||||
}
|
||||
}
|
||||
},
|
||||
openMenu (tag, e) {
|
||||
const menuMinWidth = 105
|
||||
const offsetLeft = this.$el.getBoundingClientRect().left // container margin left
|
||||
const offsetWidth = this.$el.offsetWidth // container width
|
||||
const maxLeft = offsetWidth - menuMinWidth // left boundary
|
||||
const left = e.clientX - offsetLeft + 15 // 15: margin right
|
||||
|
||||
if (left > maxLeft) {
|
||||
this.left = maxLeft
|
||||
} else {
|
||||
this.left = left
|
||||
}
|
||||
|
||||
this.top = e.clientY
|
||||
this.visible = true
|
||||
this.selectedTag = tag
|
||||
},
|
||||
closeMenu () {
|
||||
this.visible = false
|
||||
},
|
||||
handleScroll () {
|
||||
this.closeMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tags-view-container {
|
||||
height: 34px;
|
||||
width: 100%;
|
||||
background: #f5f5f5;
|
||||
border-bottom: 1px solid #d8dce5;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, .12), 0 0 3px 0 rgba(0, 0, 0, .04);
|
||||
|
||||
.tags-view-wrapper {
|
||||
.tags-view-item {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
border: 1px solid #d8dce5;
|
||||
color: #495060;
|
||||
background: #fff;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
margin-left: 5px;
|
||||
margin-top: 4px;
|
||||
|
||||
&:first-of-type {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #42b983;
|
||||
color: #fff;
|
||||
border-color: #42b983;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
background: #fff;
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
margin-right: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contextmenu {
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
z-index: 3000;
|
||||
position: absolute;
|
||||
list-style-type: none;
|
||||
padding: 5px 0;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #333;
|
||||
box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, .3);
|
||||
|
||||
li {
|
||||
margin: 0;
|
||||
padding: 7px 16px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #eee;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
//reset element css of el-icon-close
|
||||
.tags-view-wrapper {
|
||||
.tags-view-item {
|
||||
.el-icon-close {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
vertical-align: 2px;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
transition: all .3s cubic-bezier(.645, .045, .355, 1);
|
||||
transform-origin: 100% 50%;
|
||||
|
||||
&:before {
|
||||
transform: scale(.6);
|
||||
display: inline-block;
|
||||
vertical-align: -3px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #b4bccc;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
5
ruoyi-ui/src/layout/components/index.js
Normal file
5
ruoyi-ui/src/layout/components/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as AppMain } from './AppMain'
|
||||
export { default as Navbar } from './Navbar'
|
||||
export { default as Settings } from './Settings'
|
||||
export { default as Sidebar } from './Sidebar/index.vue'
|
||||
export { default as TagsView } from './TagsView/index.vue'
|
||||
111
ruoyi-ui/src/layout/index.vue
Normal file
111
ruoyi-ui/src/layout/index.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div :class="classObj" class="app-wrapper" :style="{'--current-color': theme}">
|
||||
<div v-if="device==='mobile'&&sidebar.opened" class="drawer-bg" @click="handleClickOutside"/>
|
||||
<sidebar v-if="!sidebar.hide" class="sidebar-container"/>
|
||||
<div :class="{hasTagsView:needTagsView,sidebarHide:sidebar.hide}" class="main-container">
|
||||
<div :class="{'fixed-header':fixedHeader}">
|
||||
<navbar/>
|
||||
<tags-view v-if="needTagsView"/>
|
||||
</div>
|
||||
<app-main/>
|
||||
<right-panel>
|
||||
<settings/>
|
||||
</right-panel>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import RightPanel from '@/components/RightPanel'
|
||||
import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components'
|
||||
import ResizeMixin from './mixin/ResizeHandler'
|
||||
import { mapState } from 'vuex'
|
||||
import variables from '@/assets/styles/variables.scss'
|
||||
|
||||
export default {
|
||||
name: 'Layout',
|
||||
components: {
|
||||
AppMain,
|
||||
Navbar,
|
||||
RightPanel,
|
||||
Settings,
|
||||
Sidebar,
|
||||
TagsView,
|
||||
},
|
||||
mixins: [ResizeMixin],
|
||||
computed: {
|
||||
...mapState({
|
||||
theme: state => state.settings.theme,
|
||||
sideTheme: state => state.settings.sideTheme,
|
||||
sidebar: state => state.app.sidebar,
|
||||
device: state => state.app.device,
|
||||
needTagsView: state => state.settings.tagsView,
|
||||
fixedHeader: state => state.settings.fixedHeader
|
||||
}),
|
||||
classObj() {
|
||||
return {
|
||||
hideSidebar: !this.sidebar.opened,
|
||||
openSidebar: this.sidebar.opened,
|
||||
withoutAnimation: this.sidebar.withoutAnimation,
|
||||
mobile: this.device === 'mobile'
|
||||
}
|
||||
},
|
||||
variables() {
|
||||
return variables;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleClickOutside() {
|
||||
this.$store.dispatch('app/closeSideBar', { withoutAnimation: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "~@/assets/styles/mixin.scss";
|
||||
@import "~@/assets/styles/variables.scss";
|
||||
|
||||
.app-wrapper {
|
||||
@include clearfix;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
&.mobile.openSidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-bg {
|
||||
background: #000;
|
||||
opacity: 0.3;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.fixed-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 9;
|
||||
width: calc(100% - #{$base-sidebar-width});
|
||||
transition: width 0.28s;
|
||||
}
|
||||
|
||||
.hideSidebar .fixed-header {
|
||||
width: calc(100% - 54px);
|
||||
}
|
||||
|
||||
.sidebarHide .fixed-header {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile .fixed-header {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
45
ruoyi-ui/src/layout/mixin/ResizeHandler.js
Normal file
45
ruoyi-ui/src/layout/mixin/ResizeHandler.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import store from '@/store'
|
||||
|
||||
const { body } = document
|
||||
const WIDTH = 992 // refer to Bootstrap's responsive design
|
||||
|
||||
export default {
|
||||
watch: {
|
||||
$route(route) {
|
||||
if (this.device === 'mobile' && this.sidebar.opened) {
|
||||
store.dispatch('app/closeSideBar', { withoutAnimation: false })
|
||||
}
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
window.addEventListener('resize', this.$_resizeHandler)
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.$_resizeHandler)
|
||||
},
|
||||
mounted() {
|
||||
const isMobile = this.$_isMobile()
|
||||
if (isMobile) {
|
||||
store.dispatch('app/toggleDevice', 'mobile')
|
||||
store.dispatch('app/closeSideBar', { withoutAnimation: true })
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// use $_ for mixins properties
|
||||
// https://vuejs.org/v2/style-guide/index.html#Private-property-names-essential
|
||||
$_isMobile() {
|
||||
const rect = body.getBoundingClientRect()
|
||||
return rect.width - 1 < WIDTH
|
||||
},
|
||||
$_resizeHandler() {
|
||||
if (!document.hidden) {
|
||||
const isMobile = this.$_isMobile()
|
||||
store.dispatch('app/toggleDevice', isMobile ? 'mobile' : 'desktop')
|
||||
|
||||
if (isMobile) {
|
||||
store.dispatch('app/closeSideBar', { withoutAnimation: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user