整合前端

This commit is contained in:
砂糖
2026-04-13 17:04:38 +08:00
parent 69609a2cb1
commit 5d4794c9bd
915 changed files with 144259 additions and 0 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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)
}
}
}
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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'

View 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>

View 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 })
}
}
}
}
}