feat: ✨ 布局,样式统一,集成依赖

This commit is contained in:
何嘉悦
2025-05-19 06:58:57 +08:00
parent b5cddb0041
commit c163da8e8a
37 changed files with 1429 additions and 300 deletions
+1 -1
View File
@@ -4,4 +4,4 @@
<router-view />
</template>
<style scoped></style>
<style scoped lang="scss"></style>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="37" height="14" fill="none" viewBox="0 0 37 14" class="short-cut-lh6ko_"><rect width="22.3" height="12.3" x="0.35" y="0.85" stroke="currentColor" stroke-width="0.7" rx="1.65"></rect><path fill="currentColor" d="M6.97 10.666c-1.913 0-3.11-1.416-3.11-3.682v-.01c0-2.27 1.192-3.686 3.106-3.686 1.484 0 2.642.933 2.852 2.285l-.005.01h-.884l-.005-.01C8.69 4.67 7.938 4.1 6.966 4.1c-1.353 0-2.202 1.113-2.202 2.876v.01c0 1.762.85 2.87 2.207 2.87.981 0 1.728-.502 1.948-1.313l.01-.01h.889v.01c-.235 1.289-1.348 2.124-2.847 2.124m5.885-.127c-1.084 0-1.538-.4-1.538-1.406V5.939h-.83v-.703h.83V3.874h.879v1.362h1.152v.703h-1.152v2.979c0 .62.215.87.761.87.152 0 .235-.006.391-.02v.722c-.166.03-.327.05-.493.05m1.563-.039V5.236h.85v.782h.077c.2-.552.694-.874 1.407-.874.16 0 .341.02.424.034v.825a3 3 0 0 0-.522-.049c-.81 0-1.387.513-1.387 1.284V10.5zm3.657 0V3.146h.85V10.5z"></path><rect width="12.3" height="12.3" x="24.35" y="0.85" stroke="currentColor" stroke-width="0.7" rx="1.65"></rect><path fill="currentColor" d="M28.103 10.5V3.454h.878v3.423h.079l3.085-3.423h1.104l-2.817 3.042 3.076 4.004H32.37l-2.544-3.394-.845.933V10.5z"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+22 -11
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { useClipboard } from '@vueuse/core';
import SvgIcon from '@/components/SvgIcon/index.vue';
import icons from './requireIcons';
const emits = defineEmits(['selected']);
const { copy } = useClipboard();
const name = ref('');
const iconList = ref(icons);
@@ -12,9 +14,7 @@ function filterIcons() {
if (name.value) {
let index = 0;
iconList.value.forEach((icons) => {
iconList.value[index].iconList = icons.iconList.filter(item =>
item.includes(name.value),
);
iconList.value[index].iconList = icons.iconList.filter(item => item.includes(name.value));
index++;
});
}
@@ -22,6 +22,7 @@ function filterIcons() {
function selectedIcon(name: string) {
emits('selected', name);
copy(name);
document.body.click();
}
</script>
@@ -51,16 +52,12 @@ function selectedIcon(name: string) {
:label="classify.classifyName"
>
<div class="grid-container">
<div
v-for="item of classify.iconList"
:key="item"
@click="selectedIcon(item)"
>
<div v-for="item of classify.iconList" :key="item" @click="selectedIcon(item)">
<div class="icon-item flex-center flex-col gap-3px">
<SvgIcon :name="item" />
<span class="icon_name text-overflow max-w-80px">{{
item
}}</span>
<span class="icon_name text-overflow max-w-80px">
{{ item }}
</span>
</div>
</div>
</div>
@@ -78,6 +75,7 @@ function selectedIcon(name: string) {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
}
.icon-item {
cursor: pointer;
padding: 0px 4px;
@@ -87,13 +85,16 @@ function selectedIcon(name: string) {
text-align: center;
font-size: 18px;
}
.icon-item:hover {
box-shadow: 1px 1px 10px 0 #a1a1a1;
}
.el-tab-pane {
height: 200px;
overflow: auto;
}
.icon_name {
display: none;
}
@@ -104,10 +105,13 @@ function selectedIcon(name: string) {
.icon-body {
padding: 10px;
}
.icon_name {
display: block;
}
overflow: hidden;
.grid-container {
margin-top: 12px;
position: relative;
@@ -115,7 +119,11 @@ function selectedIcon(name: string) {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
border-left: 1px solid #eee;
border-top: 1px solid #eee;
overflow-y: auto;
overflow-x: hidden;
height: 500px;
}
.icon-item {
padding: 16px 0;
margin: 0 !important;
@@ -136,14 +144,17 @@ function selectedIcon(name: string) {
.disabled {
pointer-events: none;
}
.grid {
border-top: 1px solid #eee;
}
}
.icons-container span {
font-size: 12px !important;
color: #99a9bf;
}
.icons-container svg {
font-size: 24px !important;
color: #606266;
+407
View File
@@ -0,0 +1,407 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue';
import { onClickOutside } from '@vueuse/core';
type PopoverPosition =
| 'top'
| 'top-start'
| 'top-end'
| 'bottom'
| 'bottom-start'
| 'bottom-end'
| 'left'
| 'left-start'
| 'left-end'
| 'right'
| 'right-start'
| 'right-end';
type Offset = [number, number];
const props = withDefaults(defineProps<PopoverProps>(), {
position: 'bottom',
offset: () => [8, 8],
boundary: 'viewport',
closeOnContentClick: false,
closeOnTriggerClick: false,
triggerStyle: () => ({}),
popoverStyle: () => ({}),
popoverClass: '',
});
const emits = defineEmits<{
(e: 'show'): void;
(e: 'hide'): void;
(e: 'positionChange', pos: PopoverPosition): void;
}>();
const VIEWPORT_PADDING = 16;
interface PopoverProps {
position?: PopoverPosition;
offset?: Offset;
triggerStyle?: CSSProperties;
popoverStyle?: CSSProperties;
popoverClass?: string;
boundary?: 'viewport' | HTMLElement;
closeOnContentClick?: boolean;
closeOnTriggerClick?: boolean;
}
const triggerRef = ref<HTMLElement | null>(null);
const popoverRef = ref<HTMLElement | null>(null);
const showPoperContent = ref(false);
const currentPosition = ref<PopoverPosition>(props.position);
// 新增:动画前钩子触发位置计算
function beforeEnter() {
updatePosition();
}
function getBoundaryRect() {
if (props.boundary === 'viewport') {
return {
top: 0,
left: 0,
right: window.innerWidth,
bottom: window.innerHeight,
width: window.innerWidth,
height: window.innerHeight,
x: 0,
y: 0,
toJSON: () => ({}),
};
}
return (props.boundary as HTMLElement).getBoundingClientRect();
}
function calculatePosition(
triggerRect: DOMRect,
popoverRect: DOMRect,
position: PopoverPosition,
): { top: number; left: number; origin: string } {
const [offsetX, offsetY] = props.offset!; // 解构X/Y两个方向的偏移量
const { width: tWidth, height: tHeight } = triggerRect;
const { width: pWidth, height: pHeight } = popoverRect;
const positionMap: Record<PopoverPosition, { top: number; left: number; origin: string }> = {
// 上下方向位置(top/bottom系列):Y轴用offsetY,X轴用offsetX
'top': {
top: triggerRect.top - pHeight - offsetY, // Y轴:触发元素顶部到弹框底部的距离 - Y偏移
left: triggerRect.left + tWidth / 2 - pWidth / 2 + offsetX, // X轴:水平居中 + X偏移
origin: 'bottom center',
},
'top-start': {
top: triggerRect.top - pHeight - offsetY,
left: triggerRect.left + offsetX, // X轴:触发元素左侧 + X偏移
origin: 'bottom left',
},
'top-end': {
top: triggerRect.top - pHeight - offsetY,
left: triggerRect.left + tWidth - pWidth + offsetX, // X轴:触发元素右侧 - 弹框宽度 + X偏移
origin: 'bottom right',
},
'bottom': {
top: triggerRect.bottom + offsetY, // Y轴:触发元素底部 + Y偏移
left: triggerRect.left + tWidth / 2 - pWidth / 2 + offsetX, // X轴:水平居中 + X偏移
origin: 'top center',
},
'bottom-start': {
top: triggerRect.bottom + offsetY,
left: triggerRect.left + offsetX, // X轴:触发元素左侧 + X偏移
origin: 'top left',
},
'bottom-end': {
top: triggerRect.bottom + offsetY,
left: triggerRect.left + tWidth - pWidth + offsetX, // X轴:触发元素右侧 - 弹框宽度 + X偏移
origin: 'top right',
},
// 左右方向位置(left/right系列):X轴用offsetX,Y轴用offsetY
'left': {
top: triggerRect.top + tHeight / 2 - pHeight / 2 + offsetY, // Y轴:垂直居中 + Y偏移
left: triggerRect.left - pWidth - offsetX, // X轴:触发元素左侧 - 弹框宽度 - X偏移
origin: 'right center',
},
'left-start': {
top: triggerRect.top + offsetY, // Y轴:触发元素顶部 + Y偏移
left: triggerRect.left - pWidth - offsetX,
origin: 'right top',
},
'left-end': {
top: triggerRect.top + tHeight - pHeight + offsetY, // Y轴:触发元素底部 - 弹框高度 + Y偏移
left: triggerRect.left - pWidth - offsetX,
origin: 'right bottom',
},
'right': {
top: triggerRect.top + tHeight / 2 - pHeight / 2 + offsetY, // Y轴:垂直居中 + Y偏移
left: triggerRect.right + offsetX, // X轴:触发元素右侧 + X偏移
origin: 'left center',
},
'right-start': {
top: triggerRect.top + offsetY, // Y轴:触发元素顶部 + Y偏移
left: triggerRect.right + offsetX,
origin: 'left top',
},
'right-end': {
top: triggerRect.top + tHeight - pHeight + offsetY, // Y轴:触发元素底部 - 弹框高度 + Y偏移
left: triggerRect.right + offsetX,
origin: 'left bottom',
},
};
return positionMap[position];
}
function adjustPosition(
triggerRect: DOMRect,
popoverRect: DOMRect,
boundaryRect: DOMRect,
): PopoverPosition {
const allPositions: PopoverPosition[] = [
'top',
'top-start',
'top-end',
'bottom',
'bottom-start',
'bottom-end',
'left',
'left-start',
'left-end',
'right',
'right-start',
'right-end',
];
const candidatePositions = [props.position, ...allPositions.filter(p => p !== props.position)];
for (const pos of candidatePositions) {
const { top, left } = calculatePosition(triggerRect, popoverRect, pos);
if (
top >= boundaryRect.top + VIEWPORT_PADDING
&& left >= boundaryRect.left + VIEWPORT_PADDING
&& top + popoverRect.height <= boundaryRect.bottom - VIEWPORT_PADDING
&& left + popoverRect.width <= boundaryRect.right - VIEWPORT_PADDING
) {
return pos;
}
}
return props.position;
}
function updatePosition() {
if (!triggerRef.value || !popoverRef.value)
return;
// 强制获取最新尺寸(避免缓存)
const triggerRect = triggerRef.value.getBoundingClientRect();
const popoverRect = popoverRef.value.getBoundingClientRect();
const boundaryRect = getBoundaryRect();
const adjustedPos = adjustPosition(triggerRect, popoverRect, boundaryRect);
currentPosition.value = adjustedPos;
emits('positionChange', adjustedPos);
const { top, left, origin } = calculatePosition(triggerRect, popoverRect, adjustedPos);
popoverRef.value.style.top = `${top}px`;
popoverRef.value.style.left = `${left}px`;
popoverRef.value.style.transformOrigin = origin;
}
watch(
[() => showPoperContent.value, () => props.position],
async ([newShow]) => {
if (newShow) {
// 等待弹框元素渲染完成
await nextTick();
updatePosition(); // 直接调用,无需 requestAnimationFrame
}
},
{ immediate: true },
);
onMounted(() => {
window.addEventListener('resize', updatePosition);
});
onUnmounted(() => {
window.removeEventListener('resize', updatePosition);
});
function handleTriggerClick() {
if (showPoperContent.value) {
if (props.closeOnTriggerClick)
hidePopover();
}
else {
showPoperContent.value = true;
nextTick(() => emits('show'));
}
}
function handleContentClick(e: MouseEvent) {
if (props.closeOnContentClick)
hidePopover();
e.stopPropagation();
}
function hidePopover() {
showPoperContent.value = false;
emits('hide');
}
onClickOutside(popoverRef, () => !props.closeOnTriggerClick && hidePopover(), {
ignore: [triggerRef],
});
defineExpose({
show: () => (showPoperContent.value = true),
hide: hidePopover,
});
</script>
<template>
<div
ref="triggerRef"
:style="props.triggerStyle"
role="button"
aria-haspopup="true"
:aria-expanded="showPoperContent"
@click.stop="handleTriggerClick"
>
<slot name="trigger" />
</div>
<Teleport to="body">
<Transition name="popover-fade" @before-enter="beforeEnter">
<div
v-if="showPoperContent"
ref="popoverRef"
class="popover-content"
:style="props.popoverStyle"
:class="[props.popoverClass]"
role="dialog"
aria-modal="false"
:data-popper-placement="currentPosition"
@click="handleContentClick"
>
<slot name="header" />
<slot />
<slot name="footer" />
</div>
</Transition>
</Teleport>
</template>
<style lang="scss">
/* 动画样式保持不变 */
.popover-fade-enter-active,
.popover-fade-leave-active {
transition:
opacity 0.2s ease,
transform 0.2s ease;
will-change: transform, opacity;
}
.popover-fade-enter-from,
.popover-fade-leave-to {
opacity: 0;
transform: scale(0.95);
}
.popover-fade-enter-to,
.popover-fade-leave-from {
opacity: 1;
transform: scale(1);
}
.popover-content {
position: fixed;
min-width: 120px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
padding: 12px 16px;
z-index: 1000;
&::before {
content: "";
position: absolute;
width: 0;
height: 0;
border: 6px solid transparent;
}
[data-popper-placement^="top"]::before {
top: 100%;
border-top-color: #e5e7eb;
}
[data-popper-placement="top"]::before {
left: 50%;
transform: translateX(-50%);
}
[data-popper-placement="top-start"]::before {
left: 16px;
}
[data-popper-placement="top-end"]::before {
right: 16px;
}
[data-popper-placement^="bottom"]::before {
bottom: 100%;
border-bottom-color: #e5e7eb;
}
[data-popper-placement="bottom"]::before {
left: 50%;
transform: translateX(-50%);
}
[data-popper-placement="bottom-start"]::before {
left: 16px;
}
[data-popper-placement="bottom-end"]::before {
right: 16px;
}
[data-popper-placement^="left"]::before {
left: 100%;
border-left-color: #e5e7eb;
}
[data-popper-placement="left"]::before {
top: 50%;
transform: translateY(-50%);
}
[data-popper-placement="left-start"]::before {
top: 16px;
}
[data-popper-placement="left-end"]::before {
bottom: 16px;
}
[data-popper-placement^="right"]::before {
right: 100%;
border-right-color: #e5e7eb;
}
[data-popper-placement="right"]::before {
top: 50%;
transform: translateY(-50%);
}
[data-popper-placement="right-start"]::before {
top: 16px;
}
[data-popper-placement="right-end"]::before {
bottom: 16px;
}
}
</style>
+13 -10
View File
@@ -1,5 +1,12 @@
export type LayoutType = 'vertical';
// 仿豆包折叠逻辑
export type CollapseType =
| 'alwaysCollapsed' // 始终折叠
| 'followSystem' // 跟随系统视口宽度
| 'alwaysExpanded' // 始终打开
| 'narrowExpandWideCollapse'; // 系统视口 宽小则张,宽大则收
export interface DesignConfigState {
// 系统主题
darkMode: 'light' | 'dark' | 'inverted';
@@ -13,12 +20,10 @@ export interface DesignConfigState {
pageAnimateType: string;
// 布局模式 (纵向:vertical | ... | 自己定义)
layout: LayoutType;
// 是否折叠菜单-视口宽度自动决定
// 折叠类型
collapseType: CollapseType;
// 是否折叠菜单
isCollapse: boolean;
// 是否折叠菜单-用户意愿点击决定
isCollapseManual: boolean;
// 最终是否折叠菜单,动态根据上述两种折叠条件决定
isCollapseFinal: boolean;
}
export const themeColorList: string[] = [
@@ -56,12 +61,10 @@ const design: DesignConfigState = {
pageAnimateType: 'zoom-fade',
// 布局模式 (纵向:vertical | ... | 自己定义)
layout: 'vertical',
// 是否折叠菜单-视口宽度自动决定
// 折叠类型
collapseType: 'followSystem',
// 是否折叠菜单
isCollapse: false,
// 是否折叠菜单-用户手动控制决定
isCollapseManual: false,
// 最终是否折叠菜单,动态根据上述两种折叠条件决定
isCollapseFinal: false,
};
export default design;
+6
View File
@@ -8,5 +8,11 @@ export const LOGIN_URL: string = '/login';
// 默认主题颜色
export const DEFAULT_THEME_COLOR: string = '#2992FF';
// 折叠阈值
export const COLLAPSE_THRESHOLD: number = 600;
// 左侧菜单宽度
export const SIDE_BAR_WIDTH: number = 280;
// 路由白名单地址[本地存在的路由 staticRouter.ts 中]
export const ROUTER_WHITE_LIST: string[] = ['/500'];
+61
View File
@@ -0,0 +1,61 @@
import { COLLAPSE_THRESHOLD } from '@/config/index';
import { useWindowWidthObserver } from '@/hooks/useWindowWidthObserver';
import { useDesignStore } from '@/store/modules/design';
/**
* 侧边栏折叠切换逻辑组合式函数 方便多个页面调用
* @param threshold 折叠阈值(可选,默认使用全局配置)
*/
export function useCollapseToggle(threshold: number = COLLAPSE_THRESHOLD) {
const designStore = useDesignStore();
// 获取当前视口宽度是否大于阈值,但不做响应式处理,传入一个空函数执行
const { isAboveThreshold } = useWindowWidthObserver(threshold, () => {});
/** 核心折叠切换方法 */
const changeCollapse = () => {
// 切换最终折叠状态
designStore.setCollapseFinal(!designStore.isCollapse);
if (isAboveThreshold.value) {
// 宽屏逻辑
if (designStore.isCollapse) {
designStore.setCollapseType('alwaysCollapsed');
}
else {
designStore.setCollapseType(
designStore.collapseType === 'narrowExpandWideCollapse'
? 'alwaysExpanded'
: 'followSystem',
);
}
}
else {
// 窄屏逻辑
if (designStore.isCollapse) {
designStore.setCollapseType('followSystem');
}
else {
designStore.setCollapseType(
designStore.collapseType === 'alwaysCollapsed'
? 'narrowExpandWideCollapse'
: 'alwaysExpanded',
);
}
}
};
return {
changeCollapse,
};
}
// 使用示例与特性说明
// <script setup lang="ts">
// import { useCollapseToggle } from '@/composables/useCollapseToggle';
// import { COLLAPSE_THRESHOLD } from '@/config/index'; (可传,不传入全局配置走)
// const { changeCollapseIcon } = useCollapseToggle(designStore, COLLAPSE_THRESHOLD);
// </script>
// <template>
// <!-- 其他页面的按钮 -->
// <button @click="changeCollapseIcon">切换侧边栏</button>
// </template>
+36 -9
View File
@@ -1,15 +1,17 @@
import type { MaybeRef } from 'vue';
import { onBeforeUnmount, ref, unref, watch } from 'vue';
import { COLLAPSE_THRESHOLD, SIDE_BAR_WIDTH } from '@/config/index';
import { useDesignStore } from '@/store/modules/design';
/**
* 这里逻辑是研究豆包的折叠逻辑后,设计的折叠方法
* 基于ResizeObserver的窗口宽度监听hooks(高性能实时监控)
* @param threshold 宽度阈值(默认600px,支持响应式)
* @param onChange 自定义回调(传入则覆盖默认逻辑,参数:当前视口宽度是否超过阈值)
* @returns {object} 包含卸载监听的方法及当前状态
*/
export function useWindowWidthObserver(
threshold: MaybeRef<number> = 600,
threshold: MaybeRef<number> = COLLAPSE_THRESHOLD,
onChange?: (isAboveThreshold: boolean) => void,
) {
const designStore = useDesignStore();
@@ -20,15 +22,40 @@ export function useWindowWidthObserver(
// 默认逻辑:修改全局折叠状态
const updateCollapseState = (isAbove: boolean) => {
if (!isAbove) {
// 小于阈值时 且为展开状态时候
// 跟随用户当前的意愿
designStore.setCollapseFinal(designStore.isCollapseManual);
// 如果是开,则执行展开动画表示用户意愿
// 判断当前的折叠状态
switch (designStore.collapseType) {
case 'alwaysCollapsed':
designStore.setCollapseFinal(true);
break;
case 'followSystem':
designStore.setCollapseFinal(!isAbove);
designStore.setCollapseFinal(!isAbove);
break;
case 'alwaysExpanded':
designStore.setCollapseFinal(false);
if (isAbove) {
// 大于的时候执行关闭动画
console.log('执行关闭动画');
}
else {
// 小于的时候执行打开动画
console.log('小于的时候执行打开动画');
}
break;
case 'narrowExpandWideCollapse':
designStore.setCollapseFinal(isAbove);
designStore.setCollapseFinal(isAbove);
}
else if (!designStore.isCollapseFinal && isAbove) {
// 大于阈值时 且为收起状态时
designStore.setCollapseFinal(true);
console.log('最终的折叠状态:', designStore.isCollapse);
if (!designStore.isCollapse) {
document.documentElement.style.setProperty(
`--sidebar-left-container-default-width`,
`${SIDE_BAR_WIDTH}px`,
);
}
else {
document.documentElement.style.setProperty(`--sidebar-left-container-default-width`, ``);
}
};
+35 -40
View File
@@ -4,22 +4,11 @@ import { useWindowWidthObserver } from '@/hooks/useWindowWidthObserver';
import Aside from '@/layouts/components/Aside/index.vue';
import Header from '@/layouts/components/Header/index.vue';
import Main from '@/layouts/components/Main/index.vue';
import { useDesignStore } from '@/store/modules/design';
const designStore = useDesignStore();
// 动态绑定左侧菜单animate动画
const menuAnimate = computed(() => designStore.pageAnimateType);
const menuCollapseFinal = computed(() => designStore.isCollapseFinal);
console.log('menuAnimate===>', menuAnimate);
console.log('menuCollapseFinal===>', menuCollapseFinal);
watch([menuCollapseFinal, menuAnimate], (newValue, oldValue) => {
console.log('newValue', newValue);
console.log('oldValue', oldValue);
});
console.log('每次加载全局的折叠状态', designStore.collapseType);
/** 监听窗口大小变化,折叠侧边栏 */
useWindowWidthObserver();
@@ -27,40 +16,46 @@ useWindowWidthObserver();
<template>
<el-container class="layout-container">
<el-aside
v-if="menuCollapseFinal"
class="layout-aside transition-all"
:class="menuAnimate"
>
<el-scrollbar class="layout-scrollbar">
<Aside />
</el-scrollbar>
</el-aside>
<el-container>
<el-header class="layout-header">
<Header />
</el-header>
<!-- 路由页面 -->
<Main />
<el-header class="layout-header">
<Header />
</el-header>
<el-container class="layout-container-main">
<Transition :class="designStore.pageAnimateType">
<Aside v-if="!designStore.isCollapse" class="layout-aside transition-all" />
</Transition>
<el-main class="layout-main">
<!-- 路由页面 -->
<Main />
</el-main>
</el-container>
</el-container>
</template>
<style lang="scss" scoped>
.layout-container {
width: 100vw;
height: 100vh;
.layout-aside {
// z-index: $layout-aside-z-index; // 左侧菜单层级
// padding-right: $aside-menu-padding-right; // 左侧布局右边距[用于悬浮和选择更明显]
// padding-left: $aside-menu-padding-left; // 左侧布局左边距[用于悬浮和选择更明显]
background-color: var(--el-menu-bg-color);
border-right: none;
// box-shadow: $aside-menu-box-shadow; // 左侧布局右边框阴影
}
width: 100%;
height: 100%;
.layout-header {
// height: $aside-header-height;
background-color: var(--el-header-bg-color);
padding: 0;
}
.layout-aside {
overflow: hidden;
width: var(--sidebar-left-container-default-width, 0px);
height: 100%;
position: absolute;
z-index: 10;
top: 0;
left: 0;
}
.layout-main {
padding: 0;
}
.layout-container-main {
margin-left: var(--sidebar-left-container-default-width, 0px);
}
}
@@ -68,8 +63,8 @@ useWindowWidthObserver();
.el-menu {
border-right: none;
}
.layout-scrollbar {
width: 100%;
// height: calc(100vh - $aside-header-height);
}
</style>
+145 -5
View File
@@ -1,15 +1,155 @@
<!-- Aside -->
<script setup lang="ts"></script>
<!-- Aside 侧边栏 -->
<script setup lang="ts">
import logo from '@/assets/images/logo.png';
import Collapse from '@/layouts/components/Header/components/Collapse.vue';
/* 创建会话 开始 */
function handleCreatChat() {
console.log('创建新会话');
}
/* 创建会话 结束 */
/* 会话组件 开始 */
const active = ref();
const conversationsList = ref([]);
function handleChange() {
console.log('点击了会话');
}
/* 会话组件 结束 */
</script>
<template>
<div class="aside-container">
侧边栏
<div class="aside-wrapper">
<div class="aside-header">
<div class="flex items-center gap-8px hover:cursor-pointer" @click="handleCreatChat">
<el-image :src="logo" alt="logo" fit="cover" class="logo-img" />
<span class="logo-text max-w-150px text-overflow">Elemennt-Plus-X</span>
</div>
<Collapse class="ml-auto" />
</div>
<div class="creat-chat-btn-wrapper">
<div class="creat-chat-btn" @click="handleCreatChat">
<el-icon class="add-icon">
<Plus />
</el-icon>
<span class="creat-chat-text">新对话</span>
<SvgIcon name="ctrl+k" size="37" />
</div>
</div>
<div class="aside-body">
<div v-if="conversationsList.length > 0" class="flex h-full">
<Conversations
:active="active"
:items="conversationsList"
row-key="id"
label-key="sessionTitle"
@change="handleChange"
/>
</div>
<el-empty v-else class="h-full flex-center" description="暂无对话记录" />
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.aside-container {
background-color: aqua;
height: 100vh;
height: 100%;
position: relative;
user-select: none;
box-sizing: border-box;
background-color: var(--sidebar-background-color);
border-right: 1px solid rgba(0, 0, 0, 0.08);
.aside-wrapper {
display: flex;
flex-direction: column;
height: 100%;
.aside-header {
height: 36px;
margin: 10px 12px 0px;
display: flex;
align-items: center;
.logo-img {
width: 36px;
height: 36px;
border-radius: 50%;
background-color: #fff;
overflow: hidden;
box-sizing: border-box;
padding: 4px;
img {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
}
.logo-text {
font-size: 16px;
color: rgba(0, 0, 0, 0.85);
font-weight: 700;
transform: skewX(-2deg);
}
}
.creat-chat-btn-wrapper {
padding: 0 12px;
.creat-chat-btn {
display: flex;
align-items: center;
padding: 8px 6px;
margin-top: 16px;
margin-bottom: 6px;
color: #0057ff;
background-color: rgba(0, 87, 255, 0.06);
border-radius: 12px;
border: 1px solid rgba(0, 102, 255, 0.15);
cursor: pointer;
gap: 6px;
&:hover {
background-color: rgba(0, 87, 255, 0.12);
}
.creat-chat-text {
font-size: 14px;
font-weight: 700;
line-height: 22px;
}
.add-icon {
font-size: 16px;
width: 24px;
height: 24px;
}
.svg-icon {
margin-left: auto;
height: 24px;
color: rgba(0, 87, 255, 0.3);
}
}
}
.aside-body {
display: flex;
flex-direction: column;
height: 100%;
flex: 1;
min-height: 0;
}
}
}
</style>
@@ -0,0 +1,121 @@
<!-- 头像 -->
<script setup lang="ts">
import Popover from '@/components/Popover/index.vue';
import SvgIcon from '@/components/SvgIcon/index.vue';
const src = ref('https://avatars.githubusercontent.com/u/76239030');
/* 弹出面板 开始 */
const popoverStyle = ref({
width: '200px',
padding: '4px',
height: 'fit-content',
});
const popoverRef = ref();
// 弹出面板内容
const popoverList = ref([
{
key: '1',
title: '收藏夹',
icon: 'book-mark-fill',
},
{
key: '2',
title: '设置',
icon: 'settings-4-fill',
},
{
key: '3',
divider: true,
},
{
key: '4',
title: '退出登录',
icon: 'logout-box-r-line',
},
]);
// 点击
function handleClick(item: any) {
switch (item.key) {
case '1':
console.log('点击了收藏夹');
break;
case '2':
console.log('点击了设置');
break;
case '4':
popoverRef.value.hide();
ElMessageBox.confirm('退出登录不会丢失任何数据,你仍可以登录此账号。', '确认退出登录?', {
confirmButtonText: '确认退出',
cancelButtonText: '取消',
type: 'warning',
confirmButtonClass: 'el-button--danger',
cancelButtonClass: 'el-button--info',
roundButton: true,
})
.then(() => {
// 在这里执行退出方法
ElMessage({
type: 'success',
message: '退出成功',
});
})
.catch(() => {
// ElMessage({
// type: 'info',
// message: '取消',
// });
});
break;
default:
break;
}
}
/* 弹出面板 结束 */
</script>
<template>
<div class="avatar-container">
<Popover
ref="popoverRef"
position="bottom-end"
:offset="[-10, 8]"
:trigger-style="{ cursor: 'pointer' }"
popover-class="popover-content"
:popover-style="popoverStyle"
>
<!-- 触发元素插槽 -->
<template #trigger>
<el-avatar :src="src" :size="28" fit="fit" shape="circle" />
</template>
<div class="popover-content-box">
<div v-for="item in popoverList" :key="item.key" class="popover-content-box-items h-full">
<div
v-if="!item.divider"
class="popover-content-box-item flex items-center h-full gap-8px p-8px pl-10px pr-12px rounded-lg hover:cursor-pointer hover:bg-[rgba(0,0,0,.04)]"
@click="handleClick(item)"
>
<SvgIcon :name="item.icon!" size="16" class-name="flex-none" />
<div class="popover-content-box-item-text font-size-14px text-overflow max-h-120px">
{{ item.title }}
</div>
</div>
<div v-if="item.divider" class="divder h-1px bg-gray-200 my-4px" />
</div>
</div>
</Popover>
</div>
</template>
<style scoped lang="scss">
.popover-content {
width: 520px;
height: 520px;
}
</style>
@@ -1,34 +1,35 @@
<!-- 侧边栏折叠按钮 -->
<script setup lang="ts">
import SvgIcon from '@/components/SvgIcon/index.vue';
import { SIDE_BAR_WIDTH } from '@/config/index';
import { useCollapseToggle } from '@/hooks/useCollapseToggle';
import { useDesignStore } from '@/store/modules/design';
const { changeCollapse } = useCollapseToggle();
const designStore = useDesignStore();
/** 切换图标 */
function changeCollapseIcon() {
// 定义图标切换变量(true-折叠,false-展开)
// 保存当前用户 手动 意图,并跟新最终的展开状态
const isFinal = designStore.setCollapseManual(!designStore.isCollapseFinal);
designStore.setCollapseFinal(isFinal);
function handleChangeCollapse() {
changeCollapse();
if (!designStore.isCollapse) {
document.documentElement.style.setProperty(
`--sidebar-left-container-default-width`,
`${SIDE_BAR_WIDTH}px`,
);
}
else {
document.documentElement.style.setProperty(`--sidebar-left-container-default-width`, ``);
}
}
</script>
<template>
<div
class="hover:bg-[rgba(46,50,56,.05)] hover:c-[rgba(0,0,0,0.8)] hover:cursor-pointer p-2px rounded-md flex-center inline-block c-[rgba(0,0,0,0.6)] active:bg-[rgba(46,50,56,.08)] transition-all transition-delay-10"
@click="changeCollapseIcon"
>
<SvgIcon
v-if="designStore.isCollapseFinal"
name="ms-left-panel-close-outline"
size="24"
/>
<SvgIcon
v-if="!designStore.isCollapseFinal"
name="ms-left-panel-open-outline"
size="24"
/>
<div class="collapse-container btn-icon-btn" @click="handleChangeCollapse">
<SvgIcon v-if="!designStore.isCollapse" name="ms-left-panel-close-outline" size="24" />
<SvgIcon v-if="designStore.isCollapse" name="ms-left-panel-open-outline" size="24" />
</div>
</template>
<style lang="scss" scoped></style>
<style lang="scss" scoped>
// .collapse-container {
// }
</style>
@@ -0,0 +1,15 @@
<!-- 添加新会话按钮 -->
<script setup lang="ts"></script>
<template>
<div
class="create-chat-container flex-center flex-none p-6px pl-8px pr-8px c-#0057ff b-#0057ff b-rounded-12px border-1px hover:bg-#0057ff hover:c-#fff hover:b-#fff hover:cursor-pointer border-solid"
>
<el-icon size="12" class="flex-center flex-none w-14px h-14px">
<Plus />
</el-icon>
<span class="ml-4px font-size-14px font-700">新对话</span>
</div>
</template>
<style scoped lang="scss"></style>
@@ -0,0 +1,17 @@
<!-- LoginBtn 登录按钮 -->
<script setup lang="ts">
function handleClickLogin() {
console.log('handleClickLogin');
}
</script>
<template>
<div
class="login-btn bg-#191c1f c-#fff font-size-14px rounded-8px flex-center text-overflow p-10px pl-12px pr-12px min-w-49px h-16px cursor-pointer hover:bg-#232629"
@click="handleClickLogin"
>
登录
</div>
</template>
<style scoped lang="scss"></style>
@@ -0,0 +1,54 @@
<!-- 标题编辑 -->
<script setup lang="ts">
import SvgIcon from '@/components/SvgIcon/index.vue';
function handleClickTitle() {
ElMessageBox.prompt('', '编辑对话名称', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputErrorMessage: '请输入对话名称',
inputValidator: (value) => {
if (!value) {
return false;
}
return true;
},
})
.then(({ value }) => {
ElMessage({
type: 'success',
message: `修改成功:${value}`,
});
})
.catch(() => {
// ElMessage({
// type: 'info',
// message: '取消修改',
// });
});
}
</script>
<template>
<div
class="title-editing-container hover:bg-[rgba(0,0,0,.04)] cursor-pointer rounded-md p-4px flex items-center"
@click="handleClickTitle"
>
<span class="font-size-14px text-overflow max-w-320px">标题编辑标题编辑标题编辑标题编辑标题编辑标题编辑标题编辑标题编辑</span>
<SvgIcon name="draft-line" size="14" />
</div>
</template>
<style scoped lang="scss">
.title-editing-container {
&:hover {
.svg-icon {
display: block;
}
}
.svg-icon {
display: none;
}
}
</style>
+62 -2
View File
@@ -1,10 +1,70 @@
<!-- Header 头部 -->
<script setup lang="ts">
import { SIDE_BAR_WIDTH } from '@/config/index';
import { useUserStore } from '@/store';
import Avatar from './components/Avatar.vue';
import Collapse from './components/Collapse.vue';
import CreateChat from './components/CreateChat.vue';
import LoginBtn from './components/LoginBtn.vue';
import TitleEditing from './components/TitleEditing.vue';
const userStore = useUserStore();
console.log('userStore', userStore.token);
onMounted(() => {
document.documentElement.style.setProperty(
`--sidebar-left-container-default-width`,
`${SIDE_BAR_WIDTH}px`,
);
});
</script>
<template>
<div>头部 <Collapse /></div>
<div class="header-container">
<div class="header-box relative z-10 top-0 left-0 right-0">
<div class="absolute left-0 right-0 top-0 bottom-0 flex items-center flex-row">
<!-- 左边 -->
<div class="left-box flex h-full items-center pl-20px gap-12px flex-shrink-0 flex-row">
<Collapse />
<CreateChat />
</div>
<!-- 中间 -->
<div class="middle-box flex h-full items-center gap-12px flex-1 pl-12px">
<div class="w-0.5px h-30px bg-[rgba(217,217,217)]" />
<TitleEditing />
</div>
<!-- 右边 -->
<div class="right-box flex h-full items-center pr-20px flex-shrink-0 mr-auto flex-row">
<!-- <Avatar v-if="userStore.token" /> -->
<!-- <LoginBtn v-else /> -->
<Avatar />
<LoginBtn />
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>
<style scoped lang="scss">
.header-container {
width: 100%;
height: fit-content;
display: flex;
flex-direction: column;
flex-shrink: 0;
.header-box {
height: var(--header-container-default-heigth);
width: 100%;
width: calc(
100% - var(--sidebar-left-container-default-width, 0px) - var(
--sidebar-right-container-default-width,
0px
)
);
margin: 0 var(--sidebar-right-container-default-width, 0px) 0
var(--sidebar-left-container-default-width, 0px);
}
}
</style>
+83
View File
@@ -0,0 +1,83 @@
<!-- 对话首页 -->
<script setup lang="ts">
const senderValue = ref('');
const isSelect = ref(false);
function handleSend() {
console.log(senderValue.value);
}
</script>
<template>
<div class="chat-home-container">
<div class="chat-home-wrap">
<div class="chat-home-welecome">
<div
class="welecome-text min-h-45px text-center mt-0px mr-auto ml-auto mb-32px font-size-32px text-align-center font-600"
>
早上好,Hjy。
</div>
</div>
<Sender
v-model="senderValue"
class="chat-home-sender"
:auto-size="{
maxRows: 9,
minRows: 3,
}"
variant="updown"
clearable
allow-speech
@submit="handleSend"
>
<template #prefix>
<div class="flex items-center gap-8px">
<div
class="flex items-center gap-4px px-12px py-8px rounded-15px cursor-pointer font-size-12px border-1px border-gray border-solid hover:bg-[rgba(0,0,0,.04)]"
>
<el-icon>
<Paperclip />
</el-icon>
</div>
<div
:class="{ isSelect }"
class="flex items-center gap-4px px-10px py-8px rounded-15px cursor-pointer font-size-12px border-1px border-gray border-solid hover:bg-[rgba(0,0,0,.04)]"
@click="isSelect = !isSelect"
>
<el-icon>
<ElementPlus />
</el-icon>
<span>深度思考</span>
</div>
</div>
</template>
</Sender>
</div>
</div>
</template>
<style scoped lang="scss">
.chat-home-container {
width: 100%;
display: flex;
position: relative;
flex-direction: column;
align-items: center;
justify-content: center;
height: calc(100vh - var(--header-container-default-heigth));
.chat-home-wrap {
width: 100%;
max-width: 800px;
min-height: 450px;
display: flex;
flex-direction: column;
align-items: center;
// background-color: antiquewhite;
.chat-home-sender {
width: 100%;
}
}
}
</style>
+1 -5
View File
@@ -3,10 +3,8 @@ import { BubbleList, Sender } from 'vue-element-plus-x';
import { useRoute, useRouter } from 'vue-router';
import { createSession } from '@/api';
import { send } from '@/api/chat';
import IconSelect from '@/components/IconSelect/index.vue';
import { ModelEnum } from '@/constants/enums';
import { useUserStore } from '@/store';
import { useChatStore } from '@/store/modules/chat';
const route = useRoute();
@@ -85,9 +83,7 @@ async function handleSend() {
</script>
<template>
<div>
<IconSelect />
<div class="chat-container">
<BubbleList :list="chatList">
<template #content="{ item }">
{{ item.content }}
+9 -3
View File
@@ -1,10 +1,11 @@
import type { RouteRecordRaw } from 'vue-router';
import { createRouter, createWebHashHistory } from 'vue-router';
import { createRouter, createWebHistory } from 'vue-router';
import { jwtGuard } from './permissions';
const routes: Readonly<RouteRecordRaw>[] = [
{
path: '/',
redirect: '/chat',
component: () => import('@/layouts/index.vue'),
children: [
{
@@ -14,9 +15,14 @@ const routes: Readonly<RouteRecordRaw>[] = [
},
{
path: ':id',
name: 'chat',
name: '/chat',
component: () => import('@/pages/chat/index.vue'),
},
{
path: '/chat/home',
name: 'chatHome',
component: () => import('@/pages/chat/home/index.vue'),
},
],
},
{
@@ -27,7 +33,7 @@ const routes: Readonly<RouteRecordRaw>[] = [
] as const;
const router = createRouter({
history: createWebHashHistory(),
history: createWebHistory(),
routes,
});
+11 -22
View File
@@ -1,4 +1,4 @@
import type { LayoutType } from '@/config/design';
import type { CollapseType, LayoutType } from '@/config/design';
import { defineStore } from 'pinia';
import designSetting from '@/config/design';
@@ -9,9 +9,8 @@ const {
isPageAnimate,
pageAnimateType: rePageAnimateType,
layout: reLayout,
isCollapse: reIsCollapse,
isCollapseManual: reIsCollapseManual,
isCollapseFinal: reIsCollapseFinal,
collapseType: reCollapseType,
isCollapse: reisCollapse,
} = designSetting;
export const useDesignStore = defineStore(
@@ -35,25 +34,17 @@ export const useDesignStore = defineStore(
// layout.value = layoutType;
// };
// 更据视口宽度和阈值-是否展开左侧菜单
const isCollapse = ref<boolean>(reIsCollapse);
const setCollapse = (collapse: boolean) => {
isCollapse.value = collapse;
};
// 是否手动点击展开左侧菜单
const isCollapseManual = ref<boolean>(reIsCollapseManual);
const setCollapseManual = (collapseManual: boolean): boolean => {
isCollapseManual.value = collapseManual;
return collapseManual;
// 折叠状态
const collapseType = ref<CollapseType>(reCollapseType);
const setCollapseType = (type: CollapseType) => {
collapseType.value = type;
};
// 最终是否展开左侧菜单
const isCollapseFinal = ref<boolean>(reIsCollapseFinal);
const isCollapse = ref<boolean>(reisCollapse);
const setCollapseFinal = (collapseFinal: boolean) => {
console.log('最终的折叠状态', collapseFinal);
isCollapseFinal.value = collapseFinal;
isCollapse.value = collapseFinal;
};
return {
@@ -65,11 +56,9 @@ export const useDesignStore = defineStore(
pageAnimateType,
setPageAnimateType,
layout,
collapseType,
setCollapseType,
isCollapse,
setCollapse,
isCollapseManual,
setCollapseManual,
isCollapseFinal,
setCollapseFinal,
};
},
+35
View File
@@ -0,0 +1,35 @@
// 公共的图表按钮样式
.btn-icon-btn {
align-items: center;
justify-content: center;
display: inline-block;
border: 1px solid transparent;
border-radius: 30%;
padding: 4px;
cursor: pointer;
transition: all 0.3s ease;
// 鼠标移入
&:hover {
background-color: rgb(0, 0, 0, .05);
.svg-icon {
color: rgb(0, 0, 0, .6);
}
}
// 鼠标按下
&:active {
background-color: rgb(0, 0, 0, .08);
.svg-icon {
color: rgb(0, 0, 0, .7);
}
}
// 图标
.svg-icon {
user-select: none;
color: rgb(0, 0, 0, .5);
}
}
+1
View File
@@ -1,3 +1,4 @@
@use './btn-style.scss';
@use 'reset-css';
@use './element-plus.scss';
+9
View File
@@ -0,0 +1,9 @@
:root {
/* 头部高度 */
--header-container-default-heigth: 56px;
/* 侧边栏背景色 */
--sidebar-background-color: #f3f4f6;
}