web-权限、文章
This commit is contained in:
1
schoolNewsServ/.gitignore
vendored
1
schoolNewsServ/.gitignore
vendored
@@ -7,6 +7,7 @@ HELP.md
|
|||||||
.mvn/wrapper/maven-wrapper.jar
|
.mvn/wrapper/maven-wrapper.jar
|
||||||
!**/src/main/**/target/
|
!**/src/main/**/target/
|
||||||
!**/src/test/**/target/
|
!**/src/test/**/target/
|
||||||
|
!**/upload/**
|
||||||
|
|
||||||
### Logs ###
|
### Logs ###
|
||||||
**/logs/
|
**/logs/
|
||||||
|
|||||||
@@ -61,20 +61,20 @@ school-news:
|
|||||||
max-login-attempts: 5
|
max-login-attempts: 5
|
||||||
lockout-duration: 30 # 锁定30分钟
|
lockout-duration: 30 # 锁定30分钟
|
||||||
|
|
||||||
# 免登录白名单(需要包含 context-path)
|
# 免登录白名单
|
||||||
white-list:
|
white-list:
|
||||||
- "/schoolNewsServ/auth/login"
|
- "/auth/login"
|
||||||
- "/schoolNewsServ/auth/logout"
|
- "/auth/logout"
|
||||||
- "/schoolNewsServ/auth/captcha"
|
- "/auth/captcha"
|
||||||
- "/schoolNewsServ/auth/health"
|
- "/auth/health"
|
||||||
- "/schoolNewsServ/actuator/.*"
|
- "/actuator/**"
|
||||||
- "/schoolNewsServ/swagger-ui/.*"
|
- "/swagger-ui/**"
|
||||||
- "/schoolNewsServ/v3/api-docs/.*"
|
- "/v3/api-docs/**"
|
||||||
- "/schoolNewsServ/favicon.ico"
|
- "/favicon.ico"
|
||||||
- "/schoolNewsServ/error"
|
- "/error"
|
||||||
- "/schoolNewsServ/public/.*"
|
- "/public/**"
|
||||||
- "/schoolNewsServ/static/.*"
|
- "/static/**"
|
||||||
- "/schoolNewsServ/file/download/.*"
|
- "/file/download/**"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
61
schoolNewsWeb/package-lock.json
generated
61
schoolNewsWeb/package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"core-js": "^3.8.3",
|
"core-js": "^3.8.3",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"element-plus": "^2.11.4",
|
"element-plus": "^2.11.4",
|
||||||
|
"quill": "^2.0.3",
|
||||||
"register-service-worker": "^1.7.2",
|
"register-service-worker": "^1.7.2",
|
||||||
"vue": "^3.5.22",
|
"vue": "^3.5.22",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
@@ -4671,6 +4672,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -4678,6 +4685,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-diff": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
@@ -5940,6 +5953,12 @@
|
|||||||
"lodash-es": "*"
|
"lodash-es": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.clonedeep": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.debounce": {
|
"node_modules/lodash.debounce": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||||
@@ -5947,6 +5966,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.isequal": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||||
|
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
@@ -6214,6 +6240,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parchment": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/parchment/-/parchment-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -6382,6 +6414,35 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/quill": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/quill/-/quill-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"parchment": "^3.0.0",
|
||||||
|
"quill-delta": "^5.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"npm": ">=8.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/quill-delta": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/quill-delta/-/quill-delta-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-diff": "^1.3.0",
|
||||||
|
"lodash.clonedeep": "^4.5.0",
|
||||||
|
"lodash.isequal": "^4.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/randombytes": {
|
"node_modules/randombytes": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/randombytes/-/randombytes-2.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/randombytes/-/randombytes-2.1.0.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"core-js": "^3.8.3",
|
"core-js": "^3.8.3",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"element-plus": "^2.11.4",
|
"element-plus": "^2.11.4",
|
||||||
|
"quill": "^2.0.3",
|
||||||
"register-service-worker": "^1.7.2",
|
"register-service-worker": "^1.7.2",
|
||||||
"vue": "^3.5.22",
|
"vue": "^3.5.22",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
|
|||||||
@@ -6,30 +6,14 @@
|
|||||||
|
|
||||||
import { api } from '@/apis/index';
|
import { api } from '@/apis/index';
|
||||||
import type { SysUser, SysUserInfo, UserVO, UserDeptRoleVO, SysUserDeptRole, ResultDomain } from '@/types';
|
import type { SysUser, SysUserInfo, UserVO, UserDeptRoleVO, SysUserDeptRole, ResultDomain } from '@/types';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户API服务
|
* 用户API服务
|
||||||
*/
|
*/
|
||||||
export const userApi = {
|
export const userApi = {
|
||||||
/**
|
|
||||||
* 获取当前用户信息
|
|
||||||
* @returns Promise<ResultDomain<UserVO>>
|
|
||||||
*/
|
|
||||||
async getCurrentUser(): Promise<ResultDomain<UserVO>> {
|
|
||||||
const response = await api.get<UserVO>('/users/current');
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新当前用户信息
|
|
||||||
* @param user 用户信息
|
|
||||||
* @returns Promise<ResultDomain<boolean>>
|
|
||||||
*/
|
|
||||||
async updateCurrentUser(user: SysUser): Promise<ResultDomain<boolean>> {
|
|
||||||
const response = await api.put<boolean>('/users/current', user);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新用户详细信息
|
* 更新用户详细信息
|
||||||
* @param userInfo 用户详细信息
|
* @param userInfo 用户详细信息
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import type {
|
|||||||
UserCenterStatistics,
|
UserCenterStatistics,
|
||||||
LearningChartData,
|
LearningChartData,
|
||||||
ResourceLearningStats,
|
ResourceLearningStats,
|
||||||
ResultDomain
|
ResultDomain,
|
||||||
|
UserVO
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,8 +21,8 @@ export const userProfileApi = {
|
|||||||
* 获取个人信息
|
* 获取个人信息
|
||||||
* @returns Promise<ResultDomain<any>>
|
* @returns Promise<ResultDomain<any>>
|
||||||
*/
|
*/
|
||||||
async getUserProfile(): Promise<ResultDomain<any>> {
|
async getUserProfile(): Promise<ResultDomain<UserVO>> {
|
||||||
const response = await api.get<any>('/usercenter/profile/info');
|
const response = await api.get<UserVO>('/usercenter/profile/info');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,174 +1,76 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside class="floating-sidebar" :class="{ collapsed: collapsed, [type]: true }">
|
<aside class="floating-sidebar">
|
||||||
<!-- 折叠按钮 -->
|
|
||||||
<div class="sidebar-toggle-btn" @click="$emit('toggle')">
|
|
||||||
<i class="toggle-icon">{{ collapsed ? '▶' : '◀' }}</i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 侧边栏内容 -->
|
<!-- 侧边栏内容 -->
|
||||||
<div class="sidebar-content" v-if="!collapsed">
|
<div class="sidebar-content">
|
||||||
<!-- 标题 -->
|
|
||||||
<div class="sidebar-header" v-if="title">
|
|
||||||
<h3 class="sidebar-title">{{ title }}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 菜单列表 -->
|
<!-- 菜单列表 -->
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<div
|
<div
|
||||||
v-for="menu in menus"
|
v-for="menu in menus"
|
||||||
:key="menu.menuID"
|
:key="menu.menuID || menu.url"
|
||||||
class="sidebar-item"
|
class="sidebar-item"
|
||||||
:class="{ active: isActive(menu), 'has-children': hasChildren(menu) }"
|
:class="{ active: isActive(menu) }"
|
||||||
|
@click="handleClick(menu)"
|
||||||
>
|
>
|
||||||
<div class="sidebar-link" @click="handleClick(menu)">
|
<div class="sidebar-link">
|
||||||
<span class="link-text">{{ menu.name }}</span>
|
<span class="link-text">{{ menu.name }}</span>
|
||||||
<i v-if="hasChildren(menu)" class="arrow-icon">▼</i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 子菜单 -->
|
|
||||||
<div v-if="hasChildren(menu) && isExpanded(menu)" class="sidebar-submenu">
|
|
||||||
<div
|
|
||||||
v-for="child in menu.children"
|
|
||||||
:key="child.menuID"
|
|
||||||
class="submenu-item"
|
|
||||||
:class="{ active: isActive(child) }"
|
|
||||||
@click="handleClick(child)"
|
|
||||||
>
|
|
||||||
<span class="submenu-text">{{ child.name }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 折叠状态的图标 -->
|
|
||||||
<div class="sidebar-icons" v-else>
|
|
||||||
<div
|
|
||||||
v-for="menu in menus"
|
|
||||||
:key="menu.menuID"
|
|
||||||
class="icon-item"
|
|
||||||
:class="{ active: isActive(menu) }"
|
|
||||||
:title="menu.name"
|
|
||||||
@click="handleClick(menu)"
|
|
||||||
>
|
|
||||||
<span class="icon-text">{{ menu.name?.charAt(0) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
|
||||||
import type { SysMenu } from '@/types';
|
import type { SysMenu } from '@/types';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
interface Props {
|
interface Props {
|
||||||
menus: SysMenu[];
|
menus: SysMenu[];
|
||||||
collapsed?: boolean;
|
|
||||||
title?: string;
|
|
||||||
type?: 'nav' | 'sidebar';
|
|
||||||
activePath?: string;
|
activePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {});
|
||||||
collapsed: false,
|
|
||||||
type: 'sidebar'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Emits
|
// Emits
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'toggle': [];
|
|
||||||
'menu-click': [menu: SysMenu];
|
'menu-click': [menu: SysMenu];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// 展开的菜单ID列表
|
const router = useRouter();
|
||||||
const expandedMenus = ref<Set<string>>(new Set());
|
const route = useRoute();
|
||||||
|
|
||||||
// 检查菜单是否有子菜单
|
|
||||||
function hasChildren(menu: SysMenu): boolean {
|
|
||||||
return !!(menu.children && menu.children.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查菜单是否激活
|
// 检查菜单是否激活
|
||||||
function isActive(menu: SysMenu): boolean {
|
function isActive(menu: SysMenu): boolean {
|
||||||
if (!menu.url) return false;
|
if (props.activePath) {
|
||||||
return props.activePath === menu.url;
|
return props.activePath === menu.url;
|
||||||
}
|
}
|
||||||
|
if (!menu.url) return false;
|
||||||
// 检查菜单是否展开
|
return route.path === menu.url;
|
||||||
function isExpanded(menu: SysMenu): boolean {
|
|
||||||
return menu.menuID ? expandedMenus.value.has(menu.menuID) : false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理点击
|
// 处理点击
|
||||||
function handleClick(menu: SysMenu) {
|
function handleClick(menu: SysMenu) {
|
||||||
if (hasChildren(menu)) {
|
|
||||||
// 切换展开状态
|
|
||||||
if (menu.menuID) {
|
|
||||||
if (expandedMenus.value.has(menu.menuID)) {
|
|
||||||
expandedMenus.value.delete(menu.menuID);
|
|
||||||
} else {
|
|
||||||
expandedMenus.value.add(menu.menuID);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 触发点击事件
|
// 触发点击事件
|
||||||
emit('menu-click', menu);
|
emit('menu-click', menu);
|
||||||
|
|
||||||
|
// 如果有URL,进行路由跳转
|
||||||
|
if (menu.url) {
|
||||||
|
router.push(menu.url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.floating-sidebar {
|
.floating-sidebar {
|
||||||
background: white;
|
width: 180px;
|
||||||
border-radius: 4px;
|
background: #FFFFFF;
|
||||||
|
border-radius: 10px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
transition: all 0.3s ease;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&.sidebar {
|
|
||||||
width: 260px;
|
|
||||||
|
|
||||||
&.collapsed {
|
|
||||||
width: 64px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.nav {
|
|
||||||
width: 200px;
|
|
||||||
|
|
||||||
&.collapsed {
|
|
||||||
width: 56px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-toggle-btn {
|
|
||||||
width: 24px;
|
|
||||||
height: 48px;
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #e8e8e8;
|
|
||||||
border-radius: 0 12px 12px 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
z-index: 10;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #f0f2f5;
|
|
||||||
border-color: #C62828;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-icon {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-content {
|
.sidebar-content {
|
||||||
@@ -178,35 +80,26 @@ function handleClick(menu: SysMenu) {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header {
|
|
||||||
padding: 20px 16px 16px;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
|
|
||||||
.sidebar-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #141F38;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-nav {
|
.sidebar-nav {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px 0;
|
padding: 20px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
/* 滚动条样式 */
|
/* 滚动条样式 */
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar-track {
|
&::-webkit-scrollbar-track {
|
||||||
background: #f5f5f5;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
&::-webkit-scrollbar-thumb {
|
||||||
background: #ddd;
|
background: #ddd;
|
||||||
border-radius: 3px;
|
border-radius: 2px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #bbb;
|
background: #bbb;
|
||||||
@@ -215,143 +108,66 @@ function handleClick(menu: SysMenu) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-item {
|
.sidebar-item {
|
||||||
margin: 4px 8px;
|
position: relative;
|
||||||
border-radius: 4px;
|
height: 54px;
|
||||||
overflow: hidden;
|
display: flex;
|
||||||
transition: all 0.3s;
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 4px;
|
||||||
|
height: 20px;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: #fff1f0;
|
&::before {
|
||||||
|
background: #C62828;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-link {
|
.sidebar-link {
|
||||||
color: #C62828;
|
color: #C62828;
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #f5f5f5;
|
.sidebar-link {
|
||||||
|
color: #C62828;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-link {
|
.sidebar-link {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 12px 16px;
|
|
||||||
color: #333;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
.link-text {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
|
||||||
|
|
||||||
.arrow-icon {
|
|
||||||
font-size: 10px;
|
|
||||||
font-style: normal;
|
|
||||||
transition: transform 0.3s;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #C62828;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-submenu {
|
|
||||||
background: #fafafa;
|
|
||||||
margin: 0 8px 4px;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submenu-item {
|
|
||||||
padding: 10px 16px 10px 32px;
|
|
||||||
color: #666;
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #f0f0f0;
|
|
||||||
color: #C62828;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: #fff1f0;
|
|
||||||
color: #C62828;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submenu-text {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-icons {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 16px 8px;
|
|
||||||
gap: 8px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-item {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 4px;
|
height: 100%;
|
||||||
cursor: pointer;
|
padding: 0 16px;
|
||||||
transition: all 0.3s;
|
color: #334155;
|
||||||
background: #f5f5f5;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #e8e8e8;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: #C62828;
|
|
||||||
|
|
||||||
.icon-text {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-text {
|
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #666;
|
font-family: 'PingFang SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
transition: color 0.2s;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
|
.link-text {
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 响应式设计 */
|
/* 响应式设计 */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.floating-sidebar {
|
.floating-sidebar {
|
||||||
&.sidebar {
|
width: 160px;
|
||||||
width: 200px;
|
|
||||||
|
|
||||||
&.collapsed {
|
|
||||||
width: 56px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.nav {
|
|
||||||
width: 180px;
|
|
||||||
|
|
||||||
&.collapsed {
|
|
||||||
width: 50px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -97,15 +97,15 @@ const navigationMenus = computed(() => {
|
|||||||
}
|
}
|
||||||
return menu.type === MenuType.NAVIGATION;
|
return menu.type === MenuType.NAVIGATION;
|
||||||
});
|
});
|
||||||
console.log('导航菜单数据:', menus);
|
// console.log('导航菜单数据:', menus);
|
||||||
menus.forEach((menu: SysMenu) => {
|
// menus.forEach((menu: SysMenu) => {
|
||||||
console.log(`菜单 ${menu.name}:`, {
|
// console.log(`菜单 ${menu.name}:`, {
|
||||||
menuID: menu.menuID,
|
// menuID: menu.menuID,
|
||||||
parentID: menu.parentID,
|
// parentID: menu.parentID,
|
||||||
children: menu.children,
|
// children: menu.children,
|
||||||
childrenCount: menu.children?.length || 0
|
// childrenCount: menu.children?.length || 0
|
||||||
});
|
// });
|
||||||
});
|
// });
|
||||||
return menus;
|
return menus;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,11 +117,11 @@ function hasNavigationChildren(menu: SysMenu): boolean {
|
|||||||
// 获取导航类型的子菜单
|
// 获取导航类型的子菜单
|
||||||
function getNavigationChildren(menu: SysMenu): SysMenu[] {
|
function getNavigationChildren(menu: SysMenu): SysMenu[] {
|
||||||
if (!menu.children) {
|
if (!menu.children) {
|
||||||
console.log(`菜单 ${menu.name} 没有子菜单`);
|
// console.log(`菜单 ${menu.name} 没有子菜单`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const children = menu.children.filter(child => child.type === MenuType.NAVIGATION);
|
const children = menu.children.filter(child => child.type === MenuType.NAVIGATION);
|
||||||
console.log(`菜单 ${menu.name} 的子菜单:`, children);
|
// console.log(`菜单 ${menu.name} 的子菜单:`, children);
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
358
schoolNewsWeb/src/components/file/FileUpload.vue
Normal file
358
schoolNewsWeb/src/components/file/FileUpload.vue
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="title"
|
||||||
|
width="600px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<div class="upload-container">
|
||||||
|
<div
|
||||||
|
class="upload-area"
|
||||||
|
:class="{ 'is-dragover': isDragover, 'is-disabled': uploading }"
|
||||||
|
@click="handleClickUpload"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
@dragover.prevent="handleDragOver"
|
||||||
|
@dragleave.prevent="handleDragLeave"
|
||||||
|
>
|
||||||
|
<div class="upload-icon">📁</div>
|
||||||
|
<div class="upload-text">
|
||||||
|
将文件拖到此处,或<span class="link-text">点击上传</span>
|
||||||
|
</div>
|
||||||
|
<div class="upload-tip">
|
||||||
|
{{ tip || `支持 ${accept || '所有'} 格式,单个文件不超过 ${maxSize}MB` }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 隐藏的文件输入框 -->
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
:accept="accept"
|
||||||
|
:multiple="multiple"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
style="display: none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 文件列表 -->
|
||||||
|
<div v-if="selectedFiles.length > 0" class="file-list">
|
||||||
|
<div v-for="(file, index) in selectedFiles" :key="index" class="file-item">
|
||||||
|
<span class="file-name">{{ file.name }}</span>
|
||||||
|
<span class="file-size">{{ formatFileSize(file.size) }}</span>
|
||||||
|
<el-button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
@click="removeFile(index)"
|
||||||
|
:disabled="uploading"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="handleClose" :disabled="uploading">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="handleUpload"
|
||||||
|
:loading="uploading"
|
||||||
|
:disabled="selectedFiles.length === 0"
|
||||||
|
>
|
||||||
|
{{ uploading ? '上传中...' : '确定上传' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { ElDialog, ElButton, ElMessage } from 'element-plus';
|
||||||
|
import { fileApi } from '@/apis/system/file';
|
||||||
|
import type { SysFile } from '@/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: boolean;
|
||||||
|
title?: string;
|
||||||
|
accept?: string;
|
||||||
|
maxSize?: number; // MB
|
||||||
|
multiple?: boolean;
|
||||||
|
module?: string;
|
||||||
|
businessId?: string;
|
||||||
|
tip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
modelValue: false,
|
||||||
|
title: '上传文件',
|
||||||
|
accept: '',
|
||||||
|
maxSize: 10,
|
||||||
|
multiple: false,
|
||||||
|
module: 'common',
|
||||||
|
tip: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean];
|
||||||
|
'success': [files: SysFile[]];
|
||||||
|
'error': [error: any];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const fileInputRef = ref<HTMLInputElement>();
|
||||||
|
const selectedFiles = ref<File[]>([]);
|
||||||
|
const uploading = ref(false);
|
||||||
|
const isDragover = ref(false);
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val) => emit('update:modelValue', val)
|
||||||
|
});
|
||||||
|
|
||||||
|
// 点击上传区域
|
||||||
|
function handleClickUpload() {
|
||||||
|
if (uploading.value) return;
|
||||||
|
fileInputRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件选择
|
||||||
|
function handleFileSelect(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (input.files && input.files.length > 0) {
|
||||||
|
addFiles(Array.from(input.files));
|
||||||
|
// 清空 input,允许重复选择同一文件
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖拽相关
|
||||||
|
function handleDragOver() {
|
||||||
|
isDragover.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave() {
|
||||||
|
isDragover.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(event: DragEvent) {
|
||||||
|
isDragover.value = false;
|
||||||
|
if (uploading.value) return;
|
||||||
|
|
||||||
|
const files = event.dataTransfer?.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
addFiles(Array.from(files));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加文件
|
||||||
|
function addFiles(files: File[]) {
|
||||||
|
files.forEach(file => {
|
||||||
|
// 验证文件大小
|
||||||
|
if (file.size / 1024 / 1024 > props.maxSize) {
|
||||||
|
ElMessage.error(`文件 ${file.name} 大小超过 ${props.maxSize}MB`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件类型
|
||||||
|
if (props.accept && !isValidFileType(file, props.accept)) {
|
||||||
|
ElMessage.error(`文件 ${file.name} 类型不符合要求`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否重复
|
||||||
|
if (selectedFiles.value.some(f => f.name === file.name && f.size === file.size)) {
|
||||||
|
ElMessage.warning(`文件 ${file.name} 已添加`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果不允许多选,清空之前的文件
|
||||||
|
if (!props.multiple) {
|
||||||
|
selectedFiles.value = [file];
|
||||||
|
} else {
|
||||||
|
selectedFiles.value.push(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件类型
|
||||||
|
function isValidFileType(file: File, accept: string): boolean {
|
||||||
|
if (!accept) return true;
|
||||||
|
|
||||||
|
const acceptTypes = accept.split(',').map(t => t.trim());
|
||||||
|
return acceptTypes.some(type => {
|
||||||
|
if (type.startsWith('.')) {
|
||||||
|
return file.name.toLowerCase().endsWith(type.toLowerCase());
|
||||||
|
} else if (type.endsWith('/*')) {
|
||||||
|
return file.type.startsWith(type.replace('/*', ''));
|
||||||
|
} else {
|
||||||
|
return file.type === type;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除文件
|
||||||
|
function removeFile(index: number) {
|
||||||
|
selectedFiles.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化文件大小
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传文件
|
||||||
|
async function handleUpload() {
|
||||||
|
if (selectedFiles.value.length === 0) {
|
||||||
|
ElMessage.warning('请选择文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploading.value = true;
|
||||||
|
const uploadedFilesList: SysFile[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 逐个上传文件
|
||||||
|
for (const file of selectedFiles.value) {
|
||||||
|
const result = await fileApi.uploadFile({
|
||||||
|
file: file,
|
||||||
|
module: props.module,
|
||||||
|
businessId: props.businessId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.code === 200 && result.data) {
|
||||||
|
uploadedFilesList.push(result.data);
|
||||||
|
ElMessage.success(`${file.name} 上传成功`);
|
||||||
|
} else {
|
||||||
|
ElMessage.error(`${file.name} 上传失败: ${result.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有文件上传完成
|
||||||
|
if (uploadedFilesList.length > 0) {
|
||||||
|
emit('success', uploadedFilesList);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('上传失败:', error);
|
||||||
|
ElMessage.error('上传失败: ' + (error.message || '未知错误'));
|
||||||
|
emit('error', error);
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
// 上传完成后关闭对话框
|
||||||
|
if (uploadedFilesList.length > 0) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
function handleClose() {
|
||||||
|
visible.value = false;
|
||||||
|
selectedFiles.value = [];
|
||||||
|
isDragover.value = false;
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开对话框
|
||||||
|
function open() {
|
||||||
|
visible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
open
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.upload-container {
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area {
|
||||||
|
border: 2px dashed #dcdfe6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
background: #fafafa;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #409eff;
|
||||||
|
background: #f0f9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-dragover {
|
||||||
|
border-color: #409eff;
|
||||||
|
background: #e6f7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
font-size: 67px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-text {
|
||||||
|
color: #606266;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.link-text {
|
||||||
|
color: #409eff;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-tip {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
margin-top: 20px;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
flex: 1;
|
||||||
|
color: #606266;
|
||||||
|
font-size: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
2
schoolNewsWeb/src/components/file/index.ts
Normal file
2
schoolNewsWeb/src/components/file/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as FileUpload } from './FileUpload.vue';
|
||||||
|
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
// 导出 base 基础组件
|
// 导出 base 基础组件
|
||||||
export * from './base';
|
export * from './base';
|
||||||
|
|
||||||
|
// 导出 text 富文本组件
|
||||||
|
export * from './text';
|
||||||
|
|
||||||
|
// 导出 file 文件组件
|
||||||
|
export * from './file';
|
||||||
|
|
||||||
|
|||||||
187
schoolNewsWeb/src/components/text/README.md
Normal file
187
schoolNewsWeb/src/components/text/README.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# RichTextComponent - 富文本编辑器组件
|
||||||
|
|
||||||
|
基于 Quill 的 Vue 3 富文本编辑器组件。
|
||||||
|
|
||||||
|
## 安装依赖
|
||||||
|
|
||||||
|
首先需要安装 Quill 依赖:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install quill
|
||||||
|
# 或
|
||||||
|
yarn add quill
|
||||||
|
```
|
||||||
|
|
||||||
|
## 基础使用
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<RichTextComponent
|
||||||
|
v-model="content"
|
||||||
|
placeholder="请输入内容..."
|
||||||
|
height="300px"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { RichTextComponent } from '@/components/text';
|
||||||
|
|
||||||
|
const content = ref('<p>初始内容</p>');
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
| 属性 | 类型 | 默认值 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| modelValue | string | '' | 绑定值(HTML格式) |
|
||||||
|
| placeholder | string | '请输入内容...' | 占位文本 |
|
||||||
|
| height | string | '300px' | 编辑器高度 |
|
||||||
|
| disabled | boolean | false | 是否禁用 |
|
||||||
|
| readOnly | boolean | false | 是否只读 |
|
||||||
|
| maxLength | number | 0 | 最大字数限制(0表示无限制) |
|
||||||
|
| showWordCount | boolean | false | 是否显示字数统计 |
|
||||||
|
| error | boolean | false | 是否显示错误状态 |
|
||||||
|
| errorMessage | string | '' | 错误提示文本 |
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
| 事件名 | 参数 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| update:modelValue | (value: string) | 内容变化时触发 |
|
||||||
|
| change | (value: string) | 内容变化时触发 |
|
||||||
|
| blur | () | 失去焦点时触发 |
|
||||||
|
| focus | () | 获得焦点时触发 |
|
||||||
|
|
||||||
|
## 方法
|
||||||
|
|
||||||
|
通过 ref 可以调用以下方法:
|
||||||
|
|
||||||
|
| 方法名 | 参数 | 返回值 | 说明 |
|
||||||
|
|--------|------|--------|------|
|
||||||
|
| getText | - | string | 获取纯文本内容 |
|
||||||
|
| getHTML | - | string | 获取HTML内容 |
|
||||||
|
| clear | - | void | 清空内容 |
|
||||||
|
| setContent | (content: string) | void | 设置内容 |
|
||||||
|
| focus | - | void | 聚焦编辑器 |
|
||||||
|
| blur | - | void | 失焦编辑器 |
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
### 带字数统计
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<RichTextComponent
|
||||||
|
v-model="content"
|
||||||
|
:max-length="500"
|
||||||
|
show-word-count
|
||||||
|
placeholder="最多输入500字..."
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 只读模式
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<RichTextComponent
|
||||||
|
v-model="content"
|
||||||
|
read-only
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 禁用状态
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<RichTextComponent
|
||||||
|
v-model="content"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误状态
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<RichTextComponent
|
||||||
|
v-model="content"
|
||||||
|
error
|
||||||
|
error-message="内容不能为空"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用 ref 调用方法
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<RichTextComponent ref="editorRef" v-model="content" />
|
||||||
|
<button @click="handleGetText">获取文本</button>
|
||||||
|
<button @click="handleClear">清空</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { RichTextComponent } from '@/components/text';
|
||||||
|
|
||||||
|
const editorRef = ref();
|
||||||
|
const content = ref('');
|
||||||
|
|
||||||
|
function handleGetText() {
|
||||||
|
const text = editorRef.value?.getText();
|
||||||
|
console.log('纯文本:', text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClear() {
|
||||||
|
editorRef.value?.clear();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 富文本功能
|
||||||
|
|
||||||
|
- ✅ 标题(H1-H3)
|
||||||
|
- ✅ 字体大小
|
||||||
|
- ✅ 加粗、斜体、下划线、删除线
|
||||||
|
- ✅ 文字颜色、背景颜色
|
||||||
|
- ✅ 有序列表、无序列表
|
||||||
|
- ✅ 对齐方式(左、中、右、两端对齐)
|
||||||
|
- ✅ 插入链接、图片、视频
|
||||||
|
- ✅ 代码块、引用
|
||||||
|
- ✅ 清除格式
|
||||||
|
|
||||||
|
### 其他特性
|
||||||
|
|
||||||
|
- ✅ 字数统计
|
||||||
|
- ✅ 字数限制
|
||||||
|
- ✅ 只读模式
|
||||||
|
- ✅ 禁用状态
|
||||||
|
- ✅ 错误状态显示
|
||||||
|
- ✅ 自定义高度
|
||||||
|
- ✅ 响应式设计
|
||||||
|
|
||||||
|
## 样式定制
|
||||||
|
|
||||||
|
组件使用了 Quill 的 Snow 主题,并进行了一些定制。如需进一步定制样式,可以通过以下方式:
|
||||||
|
|
||||||
|
```scss
|
||||||
|
// 在你的样式文件中
|
||||||
|
:deep(.ql-editor) {
|
||||||
|
// 定制编辑器内容区域样式
|
||||||
|
font-family: '你的字体';
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ql-toolbar) {
|
||||||
|
// 定制工具栏样式
|
||||||
|
background: #your-color;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. 确保已安装 `quill` 依赖
|
||||||
|
2. 组件导入了 Quill 的样式文件,无需额外导入
|
||||||
|
3. v-model 绑定的是 HTML 格式的内容
|
||||||
|
4. 如需纯文本,使用 `getText()` 方法
|
||||||
|
5. 图片上传需要配置 Quill 的图片处理器(未来版本会添加)
|
||||||
|
|
||||||
605
schoolNewsWeb/src/components/text/RichTextComponent.vue
Normal file
605
schoolNewsWeb/src/components/text/RichTextComponent.vue
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rich-text-component">
|
||||||
|
<div class="rich-text-editor" :class="{ 'is-disabled': disabled, 'is-error': error }">
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<div ref="toolbarRef" class="editor-toolbar">
|
||||||
|
<!-- 字体样式 -->
|
||||||
|
<span class="ql-formats">
|
||||||
|
<select class="ql-header" title="标题">
|
||||||
|
<option value="1">标题1</option>
|
||||||
|
<option value="2">标题2</option>
|
||||||
|
<option value="3">标题3</option>
|
||||||
|
<option value="">正文</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 字体大小 -->
|
||||||
|
<select class="ql-size" title="字体大小">
|
||||||
|
<option value="small">小</option>
|
||||||
|
<option selected>正常</option>
|
||||||
|
<option value="large">大</option>
|
||||||
|
<option value="huge">特大</option>
|
||||||
|
</select>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- 加粗、斜体、下划线 -->
|
||||||
|
<span class="ql-formats">
|
||||||
|
<button type="button" class="ql-bold" title="加粗"></button>
|
||||||
|
<button type="button" class="ql-italic" title="斜体"></button>
|
||||||
|
<button type="button" class="ql-underline" title="下划线"></button>
|
||||||
|
<button type="button" class="ql-strike" title="删除线"></button>
|
||||||
|
<button type="button" class="ql-code" title="行内代码"></button>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- 文字颜色和背景色 -->
|
||||||
|
<span class="ql-formats">
|
||||||
|
<select class="ql-color" title="文字颜色"></select>
|
||||||
|
<select class="ql-background" title="背景颜色"></select>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- 列表 -->
|
||||||
|
<span class="ql-formats">
|
||||||
|
<button type="button" class="ql-list" value="ordered" title="有序列表"></button>
|
||||||
|
<button type="button" class="ql-list" value="bullet" title="无序列表"></button>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- 对齐方式 -->
|
||||||
|
<span class="ql-formats">
|
||||||
|
<select class="ql-align" title="对齐方式">
|
||||||
|
<option selected></option>
|
||||||
|
<option value="center">居中</option>
|
||||||
|
<option value="right">右对齐</option>
|
||||||
|
<option value="justify">两端对齐</option>
|
||||||
|
</select>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- 链接、图片、视频 -->
|
||||||
|
<span class="ql-formats">
|
||||||
|
<button type="button" class="ql-link" title="插入链接"></button>
|
||||||
|
<button type="button" class="ql-image" title="插入图片"></button>
|
||||||
|
<button type="button" class="ql-video" title="插入视频"></button>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- 代码块、引用 -->
|
||||||
|
<span class="ql-formats">
|
||||||
|
<button type="button" class="ql-code-block" title="代码块"></button>
|
||||||
|
<button type="button" class="ql-blockquote" title="引用"></button>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- 清除格式 -->
|
||||||
|
<span class="ql-formats">
|
||||||
|
<button type="button" class="ql-clean" title="清除格式"></button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 编辑器内容区域 -->
|
||||||
|
<div
|
||||||
|
ref="editorRef"
|
||||||
|
class="editor-content"
|
||||||
|
:style="{ height: height }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 字符统计 -->
|
||||||
|
<div v-if="showWordCount" class="word-count">
|
||||||
|
字数:{{ wordCount }} / {{ maxLength || '无限制' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误提示 -->
|
||||||
|
<div v-if="error" class="error-message">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文件上传对话框 -->
|
||||||
|
<FileUpload
|
||||||
|
v-model="uploadDialogVisible"
|
||||||
|
:title="uploadType === 'image' ? '上传图片' : '上传视频'"
|
||||||
|
:accept="uploadType === 'image' ? 'image/*' : 'video/*'"
|
||||||
|
:module="uploadModule"
|
||||||
|
:max-size="uploadType === 'image' ? 5 : 100"
|
||||||
|
@success="handleUploadSuccess"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue';
|
||||||
|
import Quill from 'quill';
|
||||||
|
import { FileUpload } from '@/components/file';
|
||||||
|
import type { SysFile } from '@/types';
|
||||||
|
import { FILE_DOWNLOAD_URL } from '@/config';
|
||||||
|
import { ImageResize } from '@/utils/quill-resize';
|
||||||
|
// Quill 样式已在 main.ts 中全局引入
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
height?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
maxLength?: number;
|
||||||
|
showWordCount?: boolean;
|
||||||
|
error?: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
uploadModule?: string; // 上传文件的模块名
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
modelValue: '',
|
||||||
|
placeholder: '请输入内容...',
|
||||||
|
height: '300px',
|
||||||
|
disabled: false,
|
||||||
|
readOnly: false,
|
||||||
|
maxLength: 0,
|
||||||
|
showWordCount: false,
|
||||||
|
error: false,
|
||||||
|
errorMessage: '',
|
||||||
|
uploadModule: 'article'
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string];
|
||||||
|
'change': [value: string];
|
||||||
|
'blur': [];
|
||||||
|
'focus': [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const toolbarRef = ref<HTMLElement>();
|
||||||
|
const editorRef = ref<HTMLElement>();
|
||||||
|
let quillInstance: Quill | null = null;
|
||||||
|
|
||||||
|
// 文件上传相关
|
||||||
|
const uploadDialogVisible = ref(false);
|
||||||
|
const uploadType = ref<'image' | 'video'>('image');
|
||||||
|
const uploadModule = computed(() => props.uploadModule);
|
||||||
|
let currentUploadRange: any = null; // 保存当前光标位置
|
||||||
|
|
||||||
|
// 字符统计
|
||||||
|
const wordCount = computed(() => {
|
||||||
|
if (!quillInstance) return 0;
|
||||||
|
return quillInstance.getText().trim().length;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initQuill();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (quillInstance) {
|
||||||
|
quillInstance = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听外部值变化
|
||||||
|
watch(() => props.modelValue, (newValue) => {
|
||||||
|
if (quillInstance && newValue !== quillInstance.root.innerHTML) {
|
||||||
|
quillInstance.root.innerHTML = newValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听禁用状态
|
||||||
|
watch(() => props.disabled, (newValue) => {
|
||||||
|
if (quillInstance) {
|
||||||
|
quillInstance.enable(!newValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听只读状态
|
||||||
|
watch(() => props.readOnly, (newValue) => {
|
||||||
|
if (quillInstance) {
|
||||||
|
quillInstance.enable(!newValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function initQuill() {
|
||||||
|
if (!editorRef.value || !toolbarRef.value) return;
|
||||||
|
|
||||||
|
// 自定义视频 Blot(支持本地视频文件)
|
||||||
|
const BlockEmbed: any = Quill.import('blots/block/embed');
|
||||||
|
|
||||||
|
class VideoBlot extends BlockEmbed {
|
||||||
|
static blotName = 'customVideo';
|
||||||
|
static tagName = 'video';
|
||||||
|
|
||||||
|
static create(value: string) {
|
||||||
|
const node = super.create() as HTMLVideoElement;
|
||||||
|
node.setAttribute('src', value);
|
||||||
|
node.setAttribute('controls', 'true');
|
||||||
|
node.setAttribute('style', 'max-width: 100%; display: block; margin: 12px auto;');
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
static value(node: HTMLVideoElement) {
|
||||||
|
return node.getAttribute('src');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Quill.register(VideoBlot);
|
||||||
|
|
||||||
|
// 配置选项
|
||||||
|
const options = {
|
||||||
|
theme: 'snow',
|
||||||
|
modules: {
|
||||||
|
toolbar: {
|
||||||
|
// 1. 指定工具栏 DOM 容器
|
||||||
|
container: toolbarRef.value,
|
||||||
|
// 2. 自定义处理器
|
||||||
|
handlers: {
|
||||||
|
// 自定义图片上传处理器
|
||||||
|
image: function() {
|
||||||
|
handleImageUpload();
|
||||||
|
},
|
||||||
|
// 自定义视频上传处理器
|
||||||
|
video: function() {
|
||||||
|
handleVideoUpload();
|
||||||
|
},
|
||||||
|
// 清除格式处理器
|
||||||
|
clean: function() {
|
||||||
|
if (quillInstance) {
|
||||||
|
const range = quillInstance.getSelection();
|
||||||
|
if (range) {
|
||||||
|
quillInstance.removeFormat(range.index, range.length);
|
||||||
|
} else {
|
||||||
|
quillInstance.removeFormat(0, quillInstance.getLength());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clipboard: {
|
||||||
|
matchVisual: false
|
||||||
|
},
|
||||||
|
// 启用图片/视频缩放模块
|
||||||
|
imageResize: ImageResize
|
||||||
|
},
|
||||||
|
placeholder: props.placeholder,
|
||||||
|
readOnly: props.readOnly || props.disabled
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建编辑器实例
|
||||||
|
quillInstance = new Quill(editorRef.value, options);
|
||||||
|
|
||||||
|
// 设置初始内容
|
||||||
|
if (props.modelValue) {
|
||||||
|
quillInstance.root.innerHTML = props.modelValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听内容变化
|
||||||
|
quillInstance.on('text-change', () => {
|
||||||
|
if (!quillInstance) return;
|
||||||
|
|
||||||
|
const html = quillInstance.root.innerHTML;
|
||||||
|
const text = quillInstance.getText().trim();
|
||||||
|
|
||||||
|
// 检查字数限制
|
||||||
|
if (props.maxLength && text.length > props.maxLength) {
|
||||||
|
quillInstance.deleteText(props.maxLength, text.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('update:modelValue', html);
|
||||||
|
emit('change', html);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听图片/视频尺寸变化
|
||||||
|
quillInstance.root.addEventListener('input', () => {
|
||||||
|
console.log('📝 input 事件触发,更新内容');
|
||||||
|
if (!quillInstance) return;
|
||||||
|
|
||||||
|
const html = quillInstance.root.innerHTML;
|
||||||
|
emit('update:modelValue', html);
|
||||||
|
emit('change', html);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听焦点事件
|
||||||
|
quillInstance.on('selection-change', (range: any) => {
|
||||||
|
if (range) {
|
||||||
|
emit('focus');
|
||||||
|
} else {
|
||||||
|
emit('blur');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取纯文本内容
|
||||||
|
function getText(): string {
|
||||||
|
return quillInstance?.getText() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取HTML内容
|
||||||
|
function getHTML(): string {
|
||||||
|
return quillInstance?.root.innerHTML || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空内容
|
||||||
|
function clear(): void {
|
||||||
|
quillInstance?.setText('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置内容
|
||||||
|
function setContent(content: string): void {
|
||||||
|
if (quillInstance) {
|
||||||
|
quillInstance.root.innerHTML = content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 聚焦
|
||||||
|
function focus(): void {
|
||||||
|
quillInstance?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 失焦
|
||||||
|
function blur(): void {
|
||||||
|
quillInstance?.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理图片上传
|
||||||
|
function handleImageUpload() {
|
||||||
|
if (!quillInstance) return;
|
||||||
|
|
||||||
|
// 保存当前光标位置
|
||||||
|
currentUploadRange = quillInstance.getSelection();
|
||||||
|
|
||||||
|
// 设置上传类型并打开上传对话框
|
||||||
|
uploadType.value = 'image';
|
||||||
|
uploadDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理视频上传
|
||||||
|
function handleVideoUpload() {
|
||||||
|
if (!quillInstance) return;
|
||||||
|
|
||||||
|
// 保存当前光标位置
|
||||||
|
currentUploadRange = quillInstance.getSelection();
|
||||||
|
|
||||||
|
// 设置上传类型并打开上传对话框
|
||||||
|
uploadType.value = 'video';
|
||||||
|
uploadDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理上传成功
|
||||||
|
function handleUploadSuccess(files: SysFile[]) {
|
||||||
|
if (!quillInstance || !files || files.length === 0) return;
|
||||||
|
|
||||||
|
files.forEach(file => {
|
||||||
|
// 拼接下载URL
|
||||||
|
const downloadUrl = FILE_DOWNLOAD_URL + file.fileID;
|
||||||
|
|
||||||
|
// 获取插入位置(使用保存的光标位置,如果没有则使用当前光标)
|
||||||
|
const range = currentUploadRange || quillInstance!.getSelection() || { index: quillInstance!.getLength() };
|
||||||
|
|
||||||
|
// 根据类型插入内容
|
||||||
|
if (uploadType.value === 'image') {
|
||||||
|
// 插入图片
|
||||||
|
quillInstance!.insertEmbed(range.index, 'image', downloadUrl);
|
||||||
|
// 移动光标到图片后面
|
||||||
|
quillInstance!.setSelection(range.index + 1);
|
||||||
|
} else if (uploadType.value === 'video') {
|
||||||
|
// 插入自定义视频(使用 customVideo 而不是默认的 video)
|
||||||
|
quillInstance!.insertEmbed(range.index, 'customVideo', downloadUrl);
|
||||||
|
// 移动光标到视频后面
|
||||||
|
quillInstance!.setSelection(range.index + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清空光标位置
|
||||||
|
currentUploadRange = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露方法给父组件
|
||||||
|
defineExpose({
|
||||||
|
getText,
|
||||||
|
getHTML,
|
||||||
|
clear,
|
||||||
|
setContent,
|
||||||
|
focus,
|
||||||
|
blur
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
// 富文本内容样式(全局,可复用)
|
||||||
|
.rich-text-content,
|
||||||
|
.ql-editor {
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #303133;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
margin: 16px 0 12px 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
padding-left: 1.5em;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 4px solid #C62828;
|
||||||
|
padding-left: 16px;
|
||||||
|
margin: 12px 0;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 代码块样式
|
||||||
|
pre {
|
||||||
|
background: #282c34;
|
||||||
|
color: #abb2bf;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 12px 0;
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 行内代码样式(不在 pre 中的 code)
|
||||||
|
code:not(pre code) {
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #e83e8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #C62828;
|
||||||
|
text-decoration: underline;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #A82020;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频容器样式
|
||||||
|
iframe, video {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
margin: 12px auto; // 默认居中
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quill 视频包装器
|
||||||
|
.ql-video {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支持对齐方式
|
||||||
|
.ql-align-center {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
iframe, video, .ql-video {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-align-right {
|
||||||
|
text-align: right;
|
||||||
|
|
||||||
|
iframe, video, .ql-video {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-align-left {
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
iframe, video, .ql-video {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ql-align-justify {
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.rich-text-component {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-editor {
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #c0c4cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-disabled {
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
cursor: not-allowed;
|
||||||
|
|
||||||
|
:deep(.ql-toolbar),
|
||||||
|
:deep(.ql-container) {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-error {
|
||||||
|
border-color: #f56c6c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar {
|
||||||
|
border-bottom: 1px solid #dcdfe6;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
|
:deep(.ql-formats) {
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(button) {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin: 2px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #C62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ql-active {
|
||||||
|
color: #C62828;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(select) {
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content {
|
||||||
|
border-radius: 0 0 4px 4px;
|
||||||
|
|
||||||
|
:deep(.ql-editor) {
|
||||||
|
min-height: 100%;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&.ql-blank::before {
|
||||||
|
color: #c0c4cc;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-count {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
161
schoolNewsWeb/src/components/text/RichTextExample.vue
Normal file
161
schoolNewsWeb/src/components/text/RichTextExample.vue
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rich-text-example">
|
||||||
|
<h2>富文本编辑器示例</h2>
|
||||||
|
|
||||||
|
<div class="example-section">
|
||||||
|
<h3>基础使用</h3>
|
||||||
|
<RichTextComponent
|
||||||
|
v-model="content1"
|
||||||
|
placeholder="请输入内容..."
|
||||||
|
height="300px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="preview">
|
||||||
|
<h4>预览:</h4>
|
||||||
|
<div v-html="content1" class="preview-content"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="example-section">
|
||||||
|
<h3>带字数统计</h3>
|
||||||
|
<RichTextComponent
|
||||||
|
v-model="content2"
|
||||||
|
placeholder="最多输入500字..."
|
||||||
|
height="200px"
|
||||||
|
:max-length="500"
|
||||||
|
show-word-count
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="example-section">
|
||||||
|
<h3>只读模式</h3>
|
||||||
|
<RichTextComponent
|
||||||
|
v-model="content3"
|
||||||
|
height="150px"
|
||||||
|
read-only
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="example-section">
|
||||||
|
<h3>禁用状态</h3>
|
||||||
|
<RichTextComponent
|
||||||
|
v-model="content4"
|
||||||
|
height="150px"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="example-section">
|
||||||
|
<h3>错误状态</h3>
|
||||||
|
<RichTextComponent
|
||||||
|
v-model="content5"
|
||||||
|
height="150px"
|
||||||
|
error
|
||||||
|
error-message="内容不能为空"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="example-section">
|
||||||
|
<h3>使用 ref 调用方法</h3>
|
||||||
|
<RichTextComponent
|
||||||
|
ref="editorRef"
|
||||||
|
v-model="content6"
|
||||||
|
height="200px"
|
||||||
|
/>
|
||||||
|
<div class="button-group">
|
||||||
|
<el-button @click="getText">获取纯文本</el-button>
|
||||||
|
<el-button @click="getHTML">获取HTML</el-button>
|
||||||
|
<el-button @click="clearContent">清空内容</el-button>
|
||||||
|
<el-button @click="setContent">设置内容</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { ElButton, ElMessage } from 'element-plus';
|
||||||
|
import RichTextComponent from './RichTextComponent.vue';
|
||||||
|
|
||||||
|
const content1 = ref('<p>这是一段<strong>富文本</strong>内容</p>');
|
||||||
|
const content2 = ref('');
|
||||||
|
const content3 = ref('<p>这是只读内容,无法编辑</p>');
|
||||||
|
const content4 = ref('<p>这是禁用状态</p>');
|
||||||
|
const content5 = ref('');
|
||||||
|
const content6 = ref('<p>测试内容</p>');
|
||||||
|
|
||||||
|
const editorRef = ref();
|
||||||
|
|
||||||
|
function getText() {
|
||||||
|
const text = editorRef.value?.getText();
|
||||||
|
ElMessage.success(`纯文本内容:${text}`);
|
||||||
|
console.log('纯文本:', text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHTML() {
|
||||||
|
const html = editorRef.value?.getHTML();
|
||||||
|
ElMessage.success('HTML已输出到控制台');
|
||||||
|
console.log('HTML:', html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearContent() {
|
||||||
|
editorRef.value?.clear();
|
||||||
|
ElMessage.success('内容已清空');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setContent() {
|
||||||
|
const newContent = '<h2>新标题</h2><p>这是通过方法设置的内容</p><ul><li>列表项1</li><li>列表项2</li></ul>';
|
||||||
|
editorRef.value?.setContent(newContent);
|
||||||
|
ElMessage.success('内容已设置');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.rich-text-example {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-section {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #606266;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f5f7fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
color: #606266;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-content {
|
||||||
|
padding: 12px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
2
schoolNewsWeb/src/components/text/index.ts
Normal file
2
schoolNewsWeb/src/components/text/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as RichTextComponent } from './RichTextComponent.vue';
|
||||||
|
|
||||||
56
schoolNewsWeb/src/config/index.ts
Normal file
56
schoolNewsWeb/src/config/index.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* @description 应用配置
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-10-18
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 开发环境和生产环境的配置
|
||||||
|
const isDev = import.meta.env.DEV;
|
||||||
|
|
||||||
|
// API 基础路径
|
||||||
|
export const API_BASE_URL = isDev
|
||||||
|
? 'http://127.0.0.1:8081/schoolNewsServ'
|
||||||
|
: '/schoolNewsServ';
|
||||||
|
|
||||||
|
// 文件下载路径
|
||||||
|
export const FILE_DOWNLOAD_URL = `${API_BASE_URL}/file/download/`;
|
||||||
|
|
||||||
|
// 应用配置
|
||||||
|
export const APP_CONFIG = {
|
||||||
|
// 应用标题
|
||||||
|
title: '校园新闻管理系统',
|
||||||
|
|
||||||
|
// 基础路径
|
||||||
|
baseUrl: '/schoolNewsWeb/',
|
||||||
|
|
||||||
|
// API 配置
|
||||||
|
api: {
|
||||||
|
baseUrl: API_BASE_URL,
|
||||||
|
timeout: 30000
|
||||||
|
},
|
||||||
|
|
||||||
|
// 文件配置
|
||||||
|
file: {
|
||||||
|
downloadUrl: FILE_DOWNLOAD_URL,
|
||||||
|
uploadUrl: `${API_BASE_URL}/file/upload`,
|
||||||
|
maxSize: {
|
||||||
|
image: 5, // MB
|
||||||
|
video: 100, // MB
|
||||||
|
document: 10 // MB
|
||||||
|
},
|
||||||
|
acceptTypes: {
|
||||||
|
image: 'image/*',
|
||||||
|
video: 'video/*',
|
||||||
|
document: '.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Token 配置
|
||||||
|
token: {
|
||||||
|
key: 'token',
|
||||||
|
refreshThreshold: 5 * 60 * 1000 // 提前5分钟刷新
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default APP_CONFIG;
|
||||||
|
|
||||||
@@ -8,6 +8,9 @@ import store from "./store";
|
|||||||
import { setupRouterGuards, setupTokenRefresh } from "@/utils/permission";
|
import { setupRouterGuards, setupTokenRefresh } from "@/utils/permission";
|
||||||
import { setupPermissionDirectives } from "@/directives/permission";
|
import { setupPermissionDirectives } from "@/directives/permission";
|
||||||
|
|
||||||
|
// 引入 Quill 富文本编辑器样式(全局)
|
||||||
|
import "quill/dist/quill.snow.css";
|
||||||
|
|
||||||
// 初始化应用
|
// 初始化应用
|
||||||
async function initApp() {
|
async function initApp() {
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|||||||
@@ -123,11 +123,12 @@ export const routes: Array<RouteRecordRaw> = [
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// 捕获所有未匹配的路由
|
// 捕获所有未匹配的路由(这个应该在所有动态路由添加后再匹配)
|
||||||
{
|
// 注意:404路由会在动态路由生成后重新添加
|
||||||
path: "/:pathMatch(.*)*",
|
// {
|
||||||
redirect: "/404",
|
// path: "/:pathMatch(.*)*",
|
||||||
},
|
// redirect: "/404",
|
||||||
|
// },
|
||||||
];
|
];
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|||||||
@@ -216,11 +216,11 @@ const authModule: Module<AuthState, any> = {
|
|||||||
// 清除认证信息
|
// 清除认证信息
|
||||||
commit('CLEAR_AUTH');
|
commit('CLEAR_AUTH');
|
||||||
|
|
||||||
|
// 跳转到登录页(必须在重置路由之前)
|
||||||
|
await router.push('/login');
|
||||||
|
|
||||||
// 重置路由
|
// 重置路由
|
||||||
resetRouter();
|
resetRouter();
|
||||||
|
|
||||||
// 跳转到登录页
|
|
||||||
router.push('/login');
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -279,6 +279,14 @@ const authModule: Module<AuthState, any> = {
|
|||||||
router.addRoute(route);
|
router.addRoute(route);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 添加404路由(必须在所有动态路由之后)
|
||||||
|
// 只有登录后才会添加这个路由,未登录会跳转到登录页
|
||||||
|
router.addRoute({
|
||||||
|
path: "/:pathMatch(.*)*",
|
||||||
|
name: 'NotFoundAfterLogin',
|
||||||
|
redirect: "/404",
|
||||||
|
});
|
||||||
|
|
||||||
// 标记路由已加载
|
// 标记路由已加载
|
||||||
commit('SET_ROUTES_LOADED', true);
|
commit('SET_ROUTES_LOADED', true);
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ export interface UserVO extends BaseDTO {
|
|||||||
avatar?: string;
|
avatar?: string;
|
||||||
/** 性别 0-未知 1-男 2-女 */
|
/** 性别 0-未知 1-男 2-女 */
|
||||||
gender?: number;
|
gender?: number;
|
||||||
|
/** 学习等级 */
|
||||||
|
level?: number;
|
||||||
|
/** 部门名称 */
|
||||||
|
deptName?: string;
|
||||||
|
/** 角色名称 */
|
||||||
|
roleName?: string;
|
||||||
/** 出生日期 */
|
/** 出生日期 */
|
||||||
birthday?: string;
|
birthday?: string;
|
||||||
/** 个人简介 */
|
/** 个人简介 */
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ const WHITE_LIST = [
|
|||||||
'/register',
|
'/register',
|
||||||
'/forgot-password',
|
'/forgot-password',
|
||||||
'/home',
|
'/home',
|
||||||
'/404',
|
|
||||||
'/403',
|
'/403',
|
||||||
|
'/404', // 404页面允许访问(但未登录时不会被路由到这里)
|
||||||
'/500'
|
'/500'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
298
schoolNewsWeb/src/utils/quill-resize.ts
Normal file
298
schoolNewsWeb/src/utils/quill-resize.ts
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
/**
|
||||||
|
* @description Quill 图片/视频缩放功能
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-10-18
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Quill from 'quill';
|
||||||
|
|
||||||
|
interface ResizeOptions {
|
||||||
|
modules?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ImageResize {
|
||||||
|
quill: any;
|
||||||
|
options: ResizeOptions;
|
||||||
|
overlay: HTMLElement | null = null;
|
||||||
|
img: HTMLImageElement | HTMLVideoElement | null = null;
|
||||||
|
handle: HTMLElement | null = null;
|
||||||
|
|
||||||
|
constructor(quill: any, options: ResizeOptions = {}) {
|
||||||
|
this.quill = quill;
|
||||||
|
this.options = options;
|
||||||
|
|
||||||
|
console.log('🔄 ImageResize 模块初始化');
|
||||||
|
|
||||||
|
// 等待编辑器完全初始化
|
||||||
|
setTimeout(() => {
|
||||||
|
// 监听编辑器点击事件
|
||||||
|
this.quill.root.addEventListener('click', this.handleClick.bind(this));
|
||||||
|
|
||||||
|
// 点击编辑器外部时隐藏 resize 控件
|
||||||
|
document.addEventListener('click', this.handleDocumentClick.bind(this));
|
||||||
|
|
||||||
|
console.log('✅ ImageResize 事件监听器已添加');
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClick(e: MouseEvent) {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
|
||||||
|
console.log('🖱️ 点击事件:', target.tagName, target);
|
||||||
|
|
||||||
|
// 检查是否点击了图片或视频
|
||||||
|
if (target.tagName === 'IMG' || target.tagName === 'VIDEO') {
|
||||||
|
console.log('📷 检测到图片/视频点击,显示缩放控件');
|
||||||
|
this.showResizer(target as HTMLImageElement | HTMLVideoElement);
|
||||||
|
} else if (!this.overlay || !this.overlay.contains(target)) {
|
||||||
|
console.log('❌ 隐藏缩放控件');
|
||||||
|
this.hideResizer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDocumentClick(e: MouseEvent) {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
|
||||||
|
// 如果点击的不是编辑器内的元素,隐藏 resizer
|
||||||
|
if (!this.quill.root.contains(target) && this.overlay) {
|
||||||
|
this.hideResizer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showResizer(element: HTMLImageElement | HTMLVideoElement) {
|
||||||
|
this.img = element;
|
||||||
|
|
||||||
|
console.log('🎯 显示缩放控件,元素:', element);
|
||||||
|
|
||||||
|
// 创建遮罩层
|
||||||
|
if (!this.overlay) {
|
||||||
|
this.createOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.overlay) return;
|
||||||
|
|
||||||
|
// 更新遮罩层位置和大小
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const containerRect = this.quill.root.getBoundingClientRect();
|
||||||
|
|
||||||
|
console.log('📐 元素尺寸:', {
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
left: rect.left,
|
||||||
|
top: rect.top
|
||||||
|
});
|
||||||
|
|
||||||
|
this.overlay.style.display = 'block';
|
||||||
|
this.overlay.style.left = `${rect.left - containerRect.left + this.quill.root.scrollLeft}px`;
|
||||||
|
this.overlay.style.top = `${rect.top - containerRect.top + this.quill.root.scrollTop}px`;
|
||||||
|
this.overlay.style.width = `${rect.width}px`;
|
||||||
|
this.overlay.style.height = `${rect.height}px`;
|
||||||
|
|
||||||
|
console.log('✅ 缩放控件已显示');
|
||||||
|
}
|
||||||
|
|
||||||
|
hideResizer() {
|
||||||
|
if (this.overlay) {
|
||||||
|
this.overlay.style.display = 'none';
|
||||||
|
}
|
||||||
|
this.img = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
createOverlay() {
|
||||||
|
// 创建遮罩层
|
||||||
|
this.overlay = document.createElement('div');
|
||||||
|
this.overlay.className = 'quill-resize-overlay';
|
||||||
|
this.overlay.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
border: 2px solid #409eff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: none;
|
||||||
|
z-index: 100;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 创建8个拉伸手柄
|
||||||
|
const positions = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
|
||||||
|
positions.forEach(pos => {
|
||||||
|
const handle = document.createElement('div');
|
||||||
|
handle.className = `quill-resize-handle quill-resize-handle-${pos}`;
|
||||||
|
handle.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: #409eff;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: ${this.getCursor(pos)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 设置位置
|
||||||
|
this.setHandlePosition(handle, pos);
|
||||||
|
|
||||||
|
// 添加拖拽事件
|
||||||
|
handle.addEventListener('mousedown', (e) => this.handleMouseDown(e, pos));
|
||||||
|
|
||||||
|
this.overlay!.appendChild(handle);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.quill.root.parentNode.style.position = 'relative';
|
||||||
|
this.quill.root.parentNode.appendChild(this.overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHandlePosition(handle: HTMLElement, position: string) {
|
||||||
|
const offset = '-6px'; // handle 宽度的一半
|
||||||
|
|
||||||
|
switch (position) {
|
||||||
|
case 'nw':
|
||||||
|
handle.style.top = offset;
|
||||||
|
handle.style.left = offset;
|
||||||
|
break;
|
||||||
|
case 'n':
|
||||||
|
handle.style.top = offset;
|
||||||
|
handle.style.left = '50%';
|
||||||
|
handle.style.marginLeft = offset;
|
||||||
|
break;
|
||||||
|
case 'ne':
|
||||||
|
handle.style.top = offset;
|
||||||
|
handle.style.right = offset;
|
||||||
|
break;
|
||||||
|
case 'e':
|
||||||
|
handle.style.top = '50%';
|
||||||
|
handle.style.right = offset;
|
||||||
|
handle.style.marginTop = offset;
|
||||||
|
break;
|
||||||
|
case 'se':
|
||||||
|
handle.style.bottom = offset;
|
||||||
|
handle.style.right = offset;
|
||||||
|
break;
|
||||||
|
case 's':
|
||||||
|
handle.style.bottom = offset;
|
||||||
|
handle.style.left = '50%';
|
||||||
|
handle.style.marginLeft = offset;
|
||||||
|
break;
|
||||||
|
case 'sw':
|
||||||
|
handle.style.bottom = offset;
|
||||||
|
handle.style.left = offset;
|
||||||
|
break;
|
||||||
|
case 'w':
|
||||||
|
handle.style.top = '50%';
|
||||||
|
handle.style.left = offset;
|
||||||
|
handle.style.marginTop = offset;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCursor(position: string): string {
|
||||||
|
const cursors: Record<string, string> = {
|
||||||
|
'nw': 'nw-resize',
|
||||||
|
'n': 'n-resize',
|
||||||
|
'ne': 'ne-resize',
|
||||||
|
'e': 'e-resize',
|
||||||
|
'se': 'se-resize',
|
||||||
|
's': 's-resize',
|
||||||
|
'sw': 'sw-resize',
|
||||||
|
'w': 'w-resize'
|
||||||
|
};
|
||||||
|
return cursors[position] || 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseDown(e: MouseEvent, position: string) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!this.img || !this.overlay) return;
|
||||||
|
|
||||||
|
const startX = e.clientX;
|
||||||
|
const startY = e.clientY;
|
||||||
|
const startWidth = this.img.offsetWidth;
|
||||||
|
const startHeight = this.img.offsetHeight;
|
||||||
|
const aspectRatio = startWidth / startHeight;
|
||||||
|
|
||||||
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
|
if (!this.img || !this.overlay) return;
|
||||||
|
|
||||||
|
const deltaX = moveEvent.clientX - startX;
|
||||||
|
const deltaY = moveEvent.clientY - startY;
|
||||||
|
|
||||||
|
let newWidth = startWidth;
|
||||||
|
let newHeight = startHeight;
|
||||||
|
|
||||||
|
// 根据拉伸方向计算新尺寸
|
||||||
|
if (position.includes('e')) {
|
||||||
|
newWidth = startWidth + deltaX;
|
||||||
|
} else if (position.includes('w')) {
|
||||||
|
newWidth = startWidth - deltaX;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (position.includes('s')) {
|
||||||
|
newHeight = startHeight + deltaY;
|
||||||
|
} else if (position.includes('n')) {
|
||||||
|
newHeight = startHeight - deltaY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保持纵横比(对于对角拉伸)
|
||||||
|
if (position.length === 2) {
|
||||||
|
newHeight = newWidth / aspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制最小尺寸
|
||||||
|
if (newWidth < 50) newWidth = 50;
|
||||||
|
if (newHeight < 50) newHeight = 50;
|
||||||
|
|
||||||
|
// 应用新尺寸(同时设置 style 和属性)
|
||||||
|
this.img.style.width = `${newWidth}px`;
|
||||||
|
this.img.style.height = `${newHeight}px`;
|
||||||
|
this.img.setAttribute('width', `${newWidth}`);
|
||||||
|
this.img.setAttribute('height', `${newHeight}`);
|
||||||
|
|
||||||
|
|
||||||
|
// 更新遮罩层
|
||||||
|
this.overlay.style.width = `${newWidth}px`;
|
||||||
|
this.overlay.style.height = `${newHeight}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
// 强制触发 Quill 的 text-change 事件
|
||||||
|
// 使用 Quill 的内部方法
|
||||||
|
console.log('🔄 尝试触发 text-change 事件');
|
||||||
|
if (this.quill.emitter) {
|
||||||
|
this.quill.emitter.emit('text-change', {
|
||||||
|
oldRange: null,
|
||||||
|
range: null,
|
||||||
|
source: 'user'
|
||||||
|
});
|
||||||
|
console.log('✅ text-change 事件已触发');
|
||||||
|
} else {
|
||||||
|
console.log('❌ quill.emitter 不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 备用方案:直接触发 DOM 事件
|
||||||
|
console.log('🔄 尝试触发 input 事件');
|
||||||
|
const event = new Event('input', { bubbles: true });
|
||||||
|
this.quill.root.dispatchEvent(event);
|
||||||
|
console.log('✅ input 事件已触发');
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.overlay) {
|
||||||
|
this.overlay.remove();
|
||||||
|
}
|
||||||
|
document.removeEventListener('click', this.handleDocumentClick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册为 Quill 模块
|
||||||
|
export function registerImageResize() {
|
||||||
|
Quill.register('modules/imageResize', ImageResize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认导出类
|
||||||
|
export default ImageResize;
|
||||||
|
|
||||||
@@ -34,21 +34,73 @@ export function generateRoutes(menus: SysMenu[]): RouteRecordRaw[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [];
|
const routes: RouteRecordRaw[] = [];
|
||||||
|
const pageRoutes: RouteRecordRaw[] = [];
|
||||||
|
|
||||||
// 构建菜单树
|
// 构建菜单树
|
||||||
const menuTree = buildMenuTree(menus);
|
const menuTree = buildMenuTree(menus);
|
||||||
|
|
||||||
// 生成路由
|
// 生成路由
|
||||||
menuTree.forEach(menu => {
|
menuTree.forEach(menu => {
|
||||||
|
if(menu.type === MenuType.PAGE) {
|
||||||
|
console.log(`[路由生成] 生成独立PAGE路由: ${menu.name} -> ${menu.url}`);
|
||||||
|
}
|
||||||
const route = generateRouteFromMenu(menu);
|
const route = generateRouteFromMenu(menu);
|
||||||
|
|
||||||
if (route) {
|
if (route) {
|
||||||
routes.push(route);
|
routes.push(route);
|
||||||
|
|
||||||
|
// 递归提取所有 PAGE 类型的子菜单
|
||||||
|
extractPageChildren(route, pageRoutes);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 将 PAGE 类型的路由添加到路由列表
|
||||||
|
routes.push(...pageRoutes);
|
||||||
|
|
||||||
return routes;
|
return routes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归提取路由中的 PAGE 类型子菜单
|
||||||
|
*/
|
||||||
|
function extractPageChildren(route: any, pageRoutes: RouteRecordRaw[]) {
|
||||||
|
// 检查当前路由是否有 PAGE 类型的子菜单
|
||||||
|
if (route.meta?.pageChildren && Array.isArray(route.meta.pageChildren)) {
|
||||||
|
console.log(`[路由生成] 父路由 ${route.name} 包含 ${route.meta.pageChildren.length} 个PAGE子菜单`);
|
||||||
|
route.meta.pageChildren.forEach((pageMenu: SysMenu) => {
|
||||||
|
console.log(`[路由生成] 开始生成PAGE路由:`, {
|
||||||
|
name: pageMenu.name,
|
||||||
|
menuID: pageMenu.menuID,
|
||||||
|
url: pageMenu.url,
|
||||||
|
component: pageMenu.component,
|
||||||
|
layout: (pageMenu as any).layout,
|
||||||
|
type: pageMenu.type
|
||||||
|
});
|
||||||
|
const pageRoute = generateRouteFromMenu(pageMenu, true); // 作为顶层路由生成
|
||||||
|
if (pageRoute) {
|
||||||
|
console.log(`[路由生成] 生成独立PAGE路由成功:`, {
|
||||||
|
name: pageMenu.name,
|
||||||
|
path: pageRoute.path,
|
||||||
|
component: pageRoute.component,
|
||||||
|
hasChildren: !!pageRoute.children
|
||||||
|
});
|
||||||
|
pageRoutes.push(pageRoute);
|
||||||
|
} else {
|
||||||
|
console.error(`[路由生成] 生成独立PAGE路由失败: ${pageMenu.name}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 清理临时数据
|
||||||
|
delete route.meta.pageChildren;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归检查子路由
|
||||||
|
if (route.children && Array.isArray(route.children)) {
|
||||||
|
route.children.forEach((childRoute: any) => {
|
||||||
|
extractPageChildren(childRoute, pageRoutes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据单个菜单生成路由
|
* 根据单个菜单生成路由
|
||||||
* @param menu 菜单对象
|
* @param menu 菜单对象
|
||||||
@@ -101,9 +153,11 @@ function generateRouteFromMenu(menu: SysMenu, isTopLevel = true): RouteRecordRaw
|
|||||||
} else {
|
} else {
|
||||||
// 没有子菜单,也没有指定布局,使用具体的页面组件
|
// 没有子菜单,也没有指定布局,使用具体的页面组件
|
||||||
if (menu.component) {
|
if (menu.component) {
|
||||||
|
console.log(`[路由生成] 加载组件: ${menu.name} -> ${menu.component}`);
|
||||||
route.component = getComponent(menu.component);
|
route.component = getComponent(menu.component);
|
||||||
} else {
|
} else {
|
||||||
// 非顶层菜单没有组件时,使用简单的占位组件
|
// 非顶层菜单没有组件时,使用简单的占位组件
|
||||||
|
console.log(`[路由生成] ${menu.name} 没有组件,使用BlankLayout`);
|
||||||
route.component = getComponent('BlankLayout');
|
route.component = getComponent('BlankLayout');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,16 +174,39 @@ function generateRouteFromMenu(menu: SysMenu, isTopLevel = true): RouteRecordRaw
|
|||||||
} else if (hasChildren) {
|
} else if (hasChildren) {
|
||||||
// 处理有子菜单的情况
|
// 处理有子菜单的情况
|
||||||
route.children = [];
|
route.children = [];
|
||||||
|
|
||||||
|
// 分离 PAGE 类型的子菜单和普通子菜单
|
||||||
|
const pageChildren: SysMenu[] = [];
|
||||||
|
const normalChildren: SysMenu[] = [];
|
||||||
|
|
||||||
menu.children!.forEach(child => {
|
menu.children!.forEach(child => {
|
||||||
|
if (child.type === MenuType.PAGE) {
|
||||||
|
// PAGE 类型的菜单作为独立路由,不作为子路由
|
||||||
|
console.log(`[路由生成] 发现PAGE类型子菜单: ${child.name} (${child.menuID}), 父菜单: ${menu.name}`);
|
||||||
|
pageChildren.push(child);
|
||||||
|
} else {
|
||||||
|
normalChildren.push(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 只将普通子菜单加入 children
|
||||||
|
normalChildren.forEach(child => {
|
||||||
const childRoute = generateRouteFromMenu(child, false);
|
const childRoute = generateRouteFromMenu(child, false);
|
||||||
if (childRoute) {
|
if (childRoute) {
|
||||||
route.children!.push(childRoute);
|
route.children!.push(childRoute);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PAGE 类型的菜单需要在外层单独处理(不管是哪一层的菜单)
|
||||||
|
if (pageChildren.length > 0) {
|
||||||
|
// 将 PAGE 类型的子菜单保存到路由的 meta 中,稍后在外层生成
|
||||||
|
console.log(`[路由生成] 保存 ${pageChildren.length} 个PAGE子菜单到 ${menu.name} 的meta中`);
|
||||||
|
route.meta.pageChildren = pageChildren;
|
||||||
|
}
|
||||||
|
|
||||||
// 如果没有设置重定向,自动重定向到第一个有URL的子菜单
|
// 如果没有设置重定向,自动重定向到第一个有URL的子菜单
|
||||||
if (!route.redirect && route.children.length > 0) {
|
if (!route.redirect && route.children.length > 0) {
|
||||||
const firstChildWithUrl = findFirstMenuWithUrl(menu.children!);
|
const firstChildWithUrl = findFirstMenuWithUrl(normalChildren);
|
||||||
if (firstChildWithUrl?.url) {
|
if (firstChildWithUrl?.url) {
|
||||||
route.redirect = firstChildWithUrl.url;
|
route.redirect = firstChildWithUrl.url;
|
||||||
}
|
}
|
||||||
@@ -193,19 +270,25 @@ function getComponent(componentName: string) {
|
|||||||
componentPath += '.vue';
|
componentPath += '.vue';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[路由生成] 组件路径转换: ${componentName} -> ${componentPath}`);
|
||||||
|
|
||||||
// 动态导入组件
|
// 动态导入组件
|
||||||
return () => {
|
return () => {
|
||||||
try {
|
console.log(`[组件加载] 开始加载: ${componentPath}`);
|
||||||
// 使用动态导入,Vite 会自动处理路径解析
|
return import(/* @vite-ignore */ componentPath)
|
||||||
return import(/* @vite-ignore */ componentPath);
|
.then(module => {
|
||||||
} catch (error) {
|
console.log(`[组件加载] 成功: ${componentPath}`, module);
|
||||||
console.warn(`组件加载失败: ${componentPath}`, error);
|
return module;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(`[组件加载] 失败: ${componentPath}`, error);
|
||||||
// 返回404组件
|
// 返回404组件
|
||||||
return import('@/views/error/404.vue').catch(() =>
|
return import('@/views/error/404.vue').catch(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
template: `<div class="component-error">
|
template: `<div class="component-error">
|
||||||
<h3>组件加载失败</h3>
|
<h3>组件加载失败</h3>
|
||||||
<p>无法加载组件: ${componentPath}</p>
|
<p>无法加载组件: ${componentPath}</p>
|
||||||
|
<p>原始组件名: ${componentName}</p>
|
||||||
<p>错误: ${error instanceof Error ? error.message : String(error)}</p>
|
<p>错误: ${error instanceof Error ? error.message : String(error)}</p>
|
||||||
</div>`,
|
</div>`,
|
||||||
style: `
|
style: `
|
||||||
@@ -219,7 +302,7 @@ function getComponent(componentName: string) {
|
|||||||
`
|
`
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
21
schoolNewsWeb/src/utils/routeUtils.ts
Normal file
21
schoolNewsWeb/src/utils/routeUtils.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { RouteRecordRaw, RouteLocationNormalized } from 'vue-router';
|
||||||
|
|
||||||
|
export function getParentChildrenRoutes(route: RouteLocationNormalized): RouteRecordRaw[] {
|
||||||
|
// 判断是否有父节点(至少需要2个匹配的路由)
|
||||||
|
if (route.matched.length < 2) {
|
||||||
|
console.log('没有父节点,route.matched 长度不足');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取倒数第二个匹配的路由(父路由)
|
||||||
|
const parentRoute = route.matched[route.matched.length - 2];
|
||||||
|
|
||||||
|
// 检查父路由是否有子路由
|
||||||
|
if (!parentRoute?.children || parentRoute.children.length === 0) {
|
||||||
|
console.log('父路由没有子路由');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回有 title 的子路由
|
||||||
|
return parentRoute.children.filter((child: RouteRecordRaw) => child.meta?.title);
|
||||||
|
}
|
||||||
@@ -46,7 +46,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { ElButton, ElInput, ElTable, ElTableColumn, ElTag, ElPagination, ElMessage } from 'element-plus';
|
import { ElButton, ElInput, ElTable, ElTableColumn, ElTag, ElPagination, ElMessage } from 'element-plus';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
const searchKeyword = ref('');
|
const searchKeyword = ref('');
|
||||||
const currentPage = ref(1);
|
const currentPage = ref(1);
|
||||||
const pageSize = ref(10);
|
const pageSize = ref(10);
|
||||||
@@ -62,7 +64,15 @@ function loadArticles() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showCreateDialog() {
|
function showCreateDialog() {
|
||||||
// TODO: 显示创建文章对话框
|
// 尝试跳转
|
||||||
|
router.push('/article/add')
|
||||||
|
.then(() => {
|
||||||
|
console.log('路由跳转成功!');
|
||||||
|
console.log('跳转后路由:', router.currentRoute.value.fullPath);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('路由跳转失败:', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDataCollection() {
|
function handleDataCollection() {
|
||||||
|
|||||||
422
schoolNewsWeb/src/views/article/ArticleAddView.vue
Normal file
422
schoolNewsWeb/src/views/article/ArticleAddView.vue
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
<template>
|
||||||
|
<div class="article-add-view">
|
||||||
|
<div class="page-header">
|
||||||
|
<el-button @click="handleBack" :icon="ArrowLeft">返回</el-button>
|
||||||
|
<h1 class="page-title">{{ isEdit ? '编辑文章' : '创建文章' }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="article-form">
|
||||||
|
<el-form ref="formRef" :model="articleForm" :rules="rules" label-width="100px" label-position="top">
|
||||||
|
<!-- 标题 -->
|
||||||
|
<el-form-item label="文章标题" prop="title">
|
||||||
|
<el-input v-model="articleForm.title" placeholder="请输入文章标题" maxlength="100" show-word-limit />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 分类和标签 -->
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="文章分类" prop="category">
|
||||||
|
<el-select v-model="articleForm.category" placeholder="请选择分类" style="width: 100%">
|
||||||
|
<el-option label="新闻资讯" value="news" />
|
||||||
|
<el-option label="技术文章" value="tech" />
|
||||||
|
<el-option label="学习资料" value="study" />
|
||||||
|
<el-option label="通知公告" value="notice" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="标签" prop="tags">
|
||||||
|
<el-select v-model="articleForm.tags" multiple placeholder="请选择标签" style="width: 100%">
|
||||||
|
<el-option label="重要" value="important" />
|
||||||
|
<el-option label="推荐" value="recommend" />
|
||||||
|
<el-option label="热门" value="hot" />
|
||||||
|
<el-option label="原创" value="original" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 摘要 -->
|
||||||
|
<el-form-item label="文章摘要" prop="summary">
|
||||||
|
<el-input v-model="articleForm.summary" type="textarea" :rows="3" placeholder="请输入文章摘要(选填)"
|
||||||
|
maxlength="200" show-word-limit />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 封面图 -->
|
||||||
|
<el-form-item label="封面图片">
|
||||||
|
<el-upload class="cover-uploader" :show-file-list="false" :on-success="handleCoverSuccess"
|
||||||
|
:before-upload="beforeCoverUpload" action="#">
|
||||||
|
<img v-if="articleForm.cover" :src="articleForm.cover" class="cover" />
|
||||||
|
<el-icon v-else class="cover-uploader-icon">
|
||||||
|
<Plus />
|
||||||
|
</el-icon>
|
||||||
|
</el-upload>
|
||||||
|
<div class="upload-tip">建议尺寸:800x450px,支持jpg、png格式</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 文章内容 -->
|
||||||
|
<el-form-item label="文章内容" prop="content">
|
||||||
|
<RichTextComponent ref="editorRef" v-model="articleForm.content" height="500px"
|
||||||
|
placeholder="请输入文章内容..." />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 发布设置 -->
|
||||||
|
<el-form-item label="发布设置">
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-checkbox v-model="articleForm.allowComment">允许评论</el-checkbox>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-checkbox v-model="articleForm.isTop">置顶文章</el-checkbox>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-checkbox v-model="articleForm.isRecommend">推荐文章</el-checkbox>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handlePublish" :loading="publishing">
|
||||||
|
{{ isEdit ? '保存修改' : '立即发布' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleSaveDraft" :loading="savingDraft">
|
||||||
|
保存草稿
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handlePreview">
|
||||||
|
预览
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleBack">
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 预览对话框 -->
|
||||||
|
<el-dialog v-model="previewVisible" title="文章预览" width="900px" :close-on-click-modal="false">
|
||||||
|
<div class="article-preview">
|
||||||
|
<h1 class="preview-title">{{ articleForm.title }}</h1>
|
||||||
|
<div class="preview-meta">
|
||||||
|
<span>分类:{{ getCategoryLabel(articleForm.category) }}</span>
|
||||||
|
<span v-if="articleForm.tags.length">
|
||||||
|
标签:{{ articleForm.tags.join(', ') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-summary" v-if="articleForm.summary">
|
||||||
|
{{ articleForm.summary }}
|
||||||
|
</div>
|
||||||
|
<img v-if="articleForm.cover" :src="articleForm.cover" class="preview-cover" />
|
||||||
|
<div class="preview-content ql-editor" v-html="articleForm.content"></div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import {
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElInput,
|
||||||
|
ElSelect,
|
||||||
|
ElOption,
|
||||||
|
ElButton,
|
||||||
|
ElRow,
|
||||||
|
ElCol,
|
||||||
|
ElCheckbox,
|
||||||
|
ElUpload,
|
||||||
|
ElIcon,
|
||||||
|
ElMessage,
|
||||||
|
ElDialog
|
||||||
|
} from 'element-plus';
|
||||||
|
import { ArrowLeft, Plus } from '@element-plus/icons-vue';
|
||||||
|
import { RichTextComponent } from '@/components/text';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const formRef = ref();
|
||||||
|
const editorRef = ref();
|
||||||
|
const publishing = ref(false);
|
||||||
|
const savingDraft = ref(false);
|
||||||
|
const previewVisible = ref(false);
|
||||||
|
|
||||||
|
// 是否编辑模式
|
||||||
|
const isEdit = ref(false);
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const articleForm = reactive({
|
||||||
|
title: '',
|
||||||
|
category: '',
|
||||||
|
tags: [] as string[],
|
||||||
|
summary: '',
|
||||||
|
cover: '',
|
||||||
|
content: '',
|
||||||
|
allowComment: true,
|
||||||
|
isTop: false,
|
||||||
|
isRecommend: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表单验证规则
|
||||||
|
const rules = {
|
||||||
|
title: [
|
||||||
|
{ required: true, message: '请输入文章标题', trigger: 'blur' },
|
||||||
|
{ min: 5, max: 100, message: '标题长度在 5 到 100 个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
category: [
|
||||||
|
{ required: true, message: '请选择文章分类', trigger: 'change' }
|
||||||
|
],
|
||||||
|
content: [
|
||||||
|
{ required: true, message: '请输入文章内容', trigger: 'blur' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 检查是否是编辑模式
|
||||||
|
const id = route.query.id;
|
||||||
|
if (id) {
|
||||||
|
isEdit.value = true;
|
||||||
|
loadArticle(id as string);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 加载文章数据(编辑模式)
|
||||||
|
function loadArticle(id: string) {
|
||||||
|
// TODO: 调用API加载文章数据
|
||||||
|
console.log('加载文章:', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回
|
||||||
|
function handleBack() {
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发布文章
|
||||||
|
async function handlePublish() {
|
||||||
|
try {
|
||||||
|
await formRef.value?.validate();
|
||||||
|
|
||||||
|
publishing.value = true;
|
||||||
|
|
||||||
|
// TODO: 调用API发布文章
|
||||||
|
console.log('发布文章:', articleForm);
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
ElMessage.success(isEdit.value ? '修改成功' : '发布成功');
|
||||||
|
router.push('/admin/manage/resource/articles');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发布失败:', error);
|
||||||
|
} finally {
|
||||||
|
publishing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存草稿
|
||||||
|
async function handleSaveDraft() {
|
||||||
|
savingDraft.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: 调用API保存草稿
|
||||||
|
console.log('保存草稿:', articleForm);
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
ElMessage.success('草稿已保存');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存失败:', error);
|
||||||
|
ElMessage.error('保存失败');
|
||||||
|
} finally {
|
||||||
|
savingDraft.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预览
|
||||||
|
function handlePreview() {
|
||||||
|
console.log(articleForm.content);
|
||||||
|
if (!articleForm.title) {
|
||||||
|
ElMessage.warning('请先输入文章标题');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previewVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 封面上传成功
|
||||||
|
function handleCoverSuccess(response: any) {
|
||||||
|
// TODO: 处理上传成功的响应
|
||||||
|
articleForm.cover = response.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传前验证
|
||||||
|
function beforeCoverUpload(file: File) {
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
const isLt2M = file.size / 1024 / 1024 < 2;
|
||||||
|
|
||||||
|
if (!isImage) {
|
||||||
|
ElMessage.error('只能上传图片文件!');
|
||||||
|
}
|
||||||
|
if (!isLt2M) {
|
||||||
|
ElMessage.error('图片大小不能超过 2MB!');
|
||||||
|
}
|
||||||
|
return isImage && isLt2M;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取分类标签
|
||||||
|
function getCategoryLabel(value: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
news: '新闻资讯',
|
||||||
|
tech: '技术文章',
|
||||||
|
study: '学习资料',
|
||||||
|
notice: '通知公告'
|
||||||
|
};
|
||||||
|
return map[value] || value;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.article-add-view {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-form {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 32px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-uploader {
|
||||||
|
:deep(.el-upload) {
|
||||||
|
border: 1px dashed #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-uploader-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #8c939d;
|
||||||
|
width: 178px;
|
||||||
|
height: 178px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 178px;
|
||||||
|
height: 178px;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-preview {
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
:deep(.ql-code-block-container) {
|
||||||
|
margin: 12px 0; // 上下间距
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ql-code-block) {
|
||||||
|
background: #282c34; // 代码块背景色(类似深色主题)
|
||||||
|
color: #abb2bf; // 代码文字颜色
|
||||||
|
padding: 12px; // 内边距
|
||||||
|
border-radius: 4px; // 圆角
|
||||||
|
overflow-x: auto; // 横向滚动
|
||||||
|
font-family: 'Courier New', monospace; // 等宽字体
|
||||||
|
white-space: pre; // 保留空格和换行
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-summary {
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #606266;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-cover {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 400px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-content {
|
||||||
|
// ql-editor 类会自动应用 Quill 的默认样式
|
||||||
|
// 这里只添加必要的自定义样式覆盖
|
||||||
|
|
||||||
|
// 图片和视频样式(保留用户设置的尺寸)
|
||||||
|
:deep(img[width]),
|
||||||
|
:deep(video[width]),
|
||||||
|
:deep(img[style*="width"]),
|
||||||
|
:deep(video[style*="width"]) {
|
||||||
|
// 如果有 width 属性或 style 中包含 width,使用用户设置的尺寸
|
||||||
|
max-width: 100%;
|
||||||
|
// 不强制设置 height: auto,保留用户设置的固定尺寸
|
||||||
|
display: block;
|
||||||
|
margin: 12px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有 width 属性的图片和视频使用默认样式
|
||||||
|
:deep(img:not([width]):not([style*="width"])),
|
||||||
|
:deep(video:not([width]):not([style*="width"])),
|
||||||
|
:deep(iframe) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
margin: 12px auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
189
schoolNewsWeb/src/views/editor/README.md
Normal file
189
schoolNewsWeb/src/views/editor/README.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# 富文本编辑器页面
|
||||||
|
|
||||||
|
完整的富文本编辑器功能页面,包含编辑、预览、导出等功能。
|
||||||
|
|
||||||
|
## 路由配置
|
||||||
|
|
||||||
|
在路由文件中添加以下配置:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
path: '/editor',
|
||||||
|
name: 'RichTextEditor',
|
||||||
|
component: () => import('@/views/editor/RichTextEditorView.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '富文本编辑器',
|
||||||
|
requiresAuth: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 📝 编辑功能
|
||||||
|
- ✅ 完整的富文本编辑器
|
||||||
|
- ✅ 实时字数统计
|
||||||
|
- ✅ 字数限制设置
|
||||||
|
- ✅ 自动保存(每30秒)
|
||||||
|
- ✅ 手动保存到本地存储
|
||||||
|
|
||||||
|
### 👁️ 预览功能
|
||||||
|
- ✅ 实时预览编辑内容
|
||||||
|
- ✅ 复制HTML代码
|
||||||
|
- ✅ 弹窗预览模式
|
||||||
|
|
||||||
|
### 💾 导出功能
|
||||||
|
- ✅ 导出为HTML
|
||||||
|
- ✅ 导出为纯文本
|
||||||
|
- ✅ 导出为Markdown
|
||||||
|
- ✅ 自定义文件名
|
||||||
|
|
||||||
|
### 🎨 界面特性
|
||||||
|
- ✅ 响应式设计
|
||||||
|
- ✅ 美观的卡片布局
|
||||||
|
- ✅ 功能介绍卡片
|
||||||
|
- ✅ 移动端适配
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
### 1. 基础编辑
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<RichTextEditorView />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import RichTextEditorView from '@/views/editor/RichTextEditorView.vue';
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 集成到系统菜单
|
||||||
|
|
||||||
|
在菜单配置中添加:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"menuID": "editor",
|
||||||
|
"name": "富文本编辑器",
|
||||||
|
"url": "/editor",
|
||||||
|
"icon": "Edit",
|
||||||
|
"type": 1,
|
||||||
|
"orderNum": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快捷键
|
||||||
|
|
||||||
|
| 快捷键 | 功能 |
|
||||||
|
|--------|------|
|
||||||
|
| Ctrl/Cmd + S | 保存 |
|
||||||
|
| Ctrl/Cmd + B | 加粗 |
|
||||||
|
| Ctrl/Cmd + I | 斜体 |
|
||||||
|
| Ctrl/Cmd + U | 下划线 |
|
||||||
|
| Ctrl/Cmd + Z | 撤销 |
|
||||||
|
| Ctrl/Cmd + Y | 重做 |
|
||||||
|
|
||||||
|
## 本地存储
|
||||||
|
|
||||||
|
页面使用localStorage保存内容:
|
||||||
|
|
||||||
|
- `rich_text_content`: 手动保存的内容
|
||||||
|
- `rich_text_content_auto`: 自动保存的内容
|
||||||
|
- `rich_text_saved_at`: 保存时间戳
|
||||||
|
|
||||||
|
## 自定义配置
|
||||||
|
|
||||||
|
### 修改编辑器高度
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const editorHeight = ref('600px'); // 默认500px
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改自动保存间隔
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 默认30秒
|
||||||
|
autoSaveTimer = window.setInterval(() => {
|
||||||
|
// ...
|
||||||
|
}, 60000); // 改为60秒
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改字数限制
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const maxLength = ref(10000); // 默认5000
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **依赖安装**:确保已安装 `quill` 依赖
|
||||||
|
2. **图片上传**:默认使用base64,大图片可能影响性能
|
||||||
|
3. **浏览器兼容**:现代浏览器支持,IE需要polyfill
|
||||||
|
4. **数据持久化**:localStorage有存储限制(通常5-10MB)
|
||||||
|
|
||||||
|
## 扩展功能建议
|
||||||
|
|
||||||
|
### 1. 图片上传到服务器
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 配置Quill图片上传处理器
|
||||||
|
const quill = new Quill(editor, {
|
||||||
|
modules: {
|
||||||
|
toolbar: {
|
||||||
|
handlers: {
|
||||||
|
image: imageHandler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function imageHandler() {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.setAttribute('type', 'file');
|
||||||
|
input.setAttribute('accept', 'image/*');
|
||||||
|
input.click();
|
||||||
|
|
||||||
|
input.onchange = async () => {
|
||||||
|
const file = input.files[0];
|
||||||
|
// 上传到服务器
|
||||||
|
const url = await uploadImage(file);
|
||||||
|
const range = quill.getSelection();
|
||||||
|
quill.insertEmbed(range.index, 'image', url);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 协同编辑
|
||||||
|
|
||||||
|
可以集成WebSocket实现多人协同编辑功能。
|
||||||
|
|
||||||
|
### 3. 版本历史
|
||||||
|
|
||||||
|
保存多个版本的内容,支持版本回退。
|
||||||
|
|
||||||
|
### 4. 模板功能
|
||||||
|
|
||||||
|
预设多种文档模板,快速开始编辑。
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 问题1:编辑器无法显示
|
||||||
|
|
||||||
|
**原因**:Quill依赖未安装
|
||||||
|
**解决**:运行 `npm install quill`
|
||||||
|
|
||||||
|
### 问题2:样式异常
|
||||||
|
|
||||||
|
**原因**:Quill CSS未加载
|
||||||
|
**解决**:确保组件中导入了 `quill/dist/quill.snow.css`
|
||||||
|
|
||||||
|
### 问题3:内容丢失
|
||||||
|
|
||||||
|
**原因**:浏览器清除了localStorage
|
||||||
|
**解决**:使用服务器存储或定期导出备份
|
||||||
|
|
||||||
511
schoolNewsWeb/src/views/editor/RichTextEditorView.vue
Normal file
511
schoolNewsWeb/src/views/editor/RichTextEditorView.vue
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rich-text-editor-view">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">富文本编辑器</h1>
|
||||||
|
<p class="page-description">强大的在线富文本编辑工具,支持多种格式和样式</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-container">
|
||||||
|
<!-- 编辑器工具栏 -->
|
||||||
|
<div class="editor-actions">
|
||||||
|
<div class="action-group">
|
||||||
|
<el-button type="primary" @click="handleSave">
|
||||||
|
<el-icon><Document /></el-icon>
|
||||||
|
保存
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handlePreview">
|
||||||
|
<el-icon><View /></el-icon>
|
||||||
|
预览
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleExport">
|
||||||
|
<el-icon><Download /></el-icon>
|
||||||
|
导出
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="handleClear">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
清空
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-group">
|
||||||
|
<el-switch
|
||||||
|
v-model="showWordCount"
|
||||||
|
active-text="显示字数"
|
||||||
|
style="margin-right: 16px;"
|
||||||
|
/>
|
||||||
|
<el-input-number
|
||||||
|
v-model="maxLength"
|
||||||
|
:min="0"
|
||||||
|
:max="10000"
|
||||||
|
:step="100"
|
||||||
|
placeholder="字数限制"
|
||||||
|
style="width: 150px;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 富文本编辑器 -->
|
||||||
|
<div class="editor-wrapper">
|
||||||
|
<RichTextComponent
|
||||||
|
ref="editorRef"
|
||||||
|
v-model="content"
|
||||||
|
:height="editorHeight"
|
||||||
|
:max-length="maxLength > 0 ? maxLength : 0"
|
||||||
|
:show-word-count="showWordCount"
|
||||||
|
placeholder="在这里开始编写内容..."
|
||||||
|
@change="handleContentChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 编辑器信息 -->
|
||||||
|
<div class="editor-info">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">字符数:</span>
|
||||||
|
<span class="info-value">{{ textLength }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">最后修改:</span>
|
||||||
|
<span class="info-value">{{ lastModified }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 预览对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="previewVisible"
|
||||||
|
title="内容预览"
|
||||||
|
width="800px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<div class="preview-content" v-html="content"></div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="previewVisible = false">关闭</el-button>
|
||||||
|
<el-button type="primary" @click="handleCopyHtml">复制HTML</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 导出对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="exportVisible"
|
||||||
|
title="导出内容"
|
||||||
|
width="600px"
|
||||||
|
>
|
||||||
|
<el-form label-width="100px">
|
||||||
|
<el-form-item label="导出格式">
|
||||||
|
<el-radio-group v-model="exportFormat">
|
||||||
|
<el-radio label="html">HTML</el-radio>
|
||||||
|
<el-radio label="text">纯文本</el-radio>
|
||||||
|
<el-radio label="markdown">Markdown</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="文件名">
|
||||||
|
<el-input v-model="exportFilename" placeholder="请输入文件名">
|
||||||
|
<template #append>.{{ exportFormat }}</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="exportVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="confirmExport">确认导出</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 快捷功能卡片 -->
|
||||||
|
<div class="feature-cards">
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="card-icon">📝</div>
|
||||||
|
<h3>丰富格式</h3>
|
||||||
|
<p>支持标题、列表、引用、代码块等多种格式</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="card-icon">🎨</div>
|
||||||
|
<h3>样式定制</h3>
|
||||||
|
<p>自定义文字颜色、背景色、对齐方式等样式</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="card-icon">📊</div>
|
||||||
|
<h3>插入媒体</h3>
|
||||||
|
<p>支持插入图片、视频、链接等多媒体内容</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="card-icon">💾</div>
|
||||||
|
<h3>实时保存</h3>
|
||||||
|
<p>自动保存编辑内容,防止意外丢失</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||||
|
import {
|
||||||
|
ElButton,
|
||||||
|
ElDialog,
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElInput,
|
||||||
|
ElInputNumber,
|
||||||
|
ElRadioGroup,
|
||||||
|
ElRadio,
|
||||||
|
ElSwitch,
|
||||||
|
ElMessage,
|
||||||
|
ElMessageBox,
|
||||||
|
ElIcon
|
||||||
|
} from 'element-plus';
|
||||||
|
import { Document, View, Download, Delete } from '@element-plus/icons-vue';
|
||||||
|
import { RichTextComponent } from '@/components/text';
|
||||||
|
|
||||||
|
// 编辑器引用
|
||||||
|
const editorRef = ref();
|
||||||
|
|
||||||
|
// 编辑器内容
|
||||||
|
const content = ref(`
|
||||||
|
<h2>欢迎使用富文本编辑器</h2>
|
||||||
|
<p>这是一个功能强大的在线富文本编辑器,支持以下功能:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>多种文本格式</strong>:支持标题、段落、列表等</li>
|
||||||
|
<li><strong>样式定制</strong>:文字颜色、背景色、对齐方式</li>
|
||||||
|
<li><strong>插入媒体</strong>:图片、视频、链接</li>
|
||||||
|
<li><strong>代码支持</strong>:代码块和行内代码</li>
|
||||||
|
</ul>
|
||||||
|
<p>开始编辑您的内容吧!</p>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 编辑器设置
|
||||||
|
const editorHeight = ref('500px');
|
||||||
|
const showWordCount = ref(true);
|
||||||
|
const maxLength = ref(5000);
|
||||||
|
|
||||||
|
// 对话框状态
|
||||||
|
const previewVisible = ref(false);
|
||||||
|
const exportVisible = ref(false);
|
||||||
|
|
||||||
|
// 导出设置
|
||||||
|
const exportFormat = ref('html');
|
||||||
|
const exportFilename = ref('document');
|
||||||
|
|
||||||
|
// 自动保存定时器
|
||||||
|
let autoSaveTimer: number | null = null;
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const textLength = computed(() => {
|
||||||
|
return editorRef.value?.getText()?.trim().length || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastModified = computed(() => {
|
||||||
|
return new Date().toLocaleString('zh-CN');
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 启动自动保存
|
||||||
|
startAutoSave();
|
||||||
|
|
||||||
|
// 尝试恢复上次的内容
|
||||||
|
loadSavedContent();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
// 清除自动保存定时器
|
||||||
|
if (autoSaveTimer) {
|
||||||
|
clearInterval(autoSaveTimer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 内容变化处理
|
||||||
|
function handleContentChange(value: string) {
|
||||||
|
console.log('内容已更新');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存
|
||||||
|
function handleSave() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('rich_text_content', content.value);
|
||||||
|
localStorage.setItem('rich_text_saved_at', new Date().toISOString());
|
||||||
|
ElMessage.success('保存成功');
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('保存失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动保存
|
||||||
|
function startAutoSave() {
|
||||||
|
autoSaveTimer = window.setInterval(() => {
|
||||||
|
if (content.value) {
|
||||||
|
localStorage.setItem('rich_text_content_auto', content.value);
|
||||||
|
}
|
||||||
|
}, 30000); // 每30秒自动保存
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载保存的内容
|
||||||
|
function loadSavedContent() {
|
||||||
|
const saved = localStorage.getItem('rich_text_content');
|
||||||
|
if (saved) {
|
||||||
|
// 可以选择是否自动加载
|
||||||
|
// content.value = saved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预览
|
||||||
|
function handlePreview() {
|
||||||
|
previewVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制HTML
|
||||||
|
function handleCopyHtml() {
|
||||||
|
navigator.clipboard.writeText(content.value).then(() => {
|
||||||
|
ElMessage.success('HTML已复制到剪贴板');
|
||||||
|
}).catch(() => {
|
||||||
|
ElMessage.error('复制失败');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出
|
||||||
|
function handleExport() {
|
||||||
|
exportVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认导出
|
||||||
|
function confirmExport() {
|
||||||
|
let exportContent = '';
|
||||||
|
let mimeType = 'text/html';
|
||||||
|
|
||||||
|
switch (exportFormat.value) {
|
||||||
|
case 'html':
|
||||||
|
exportContent = content.value;
|
||||||
|
mimeType = 'text/html';
|
||||||
|
break;
|
||||||
|
case 'text':
|
||||||
|
exportContent = editorRef.value?.getText() || '';
|
||||||
|
mimeType = 'text/plain';
|
||||||
|
break;
|
||||||
|
case 'markdown':
|
||||||
|
// 简单的HTML转Markdown(可以使用第三方库如turndown来实现)
|
||||||
|
exportContent = htmlToMarkdown(content.value);
|
||||||
|
mimeType = 'text/markdown';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建下载链接
|
||||||
|
const blob = new Blob([exportContent], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${exportFilename.value || 'document'}.${exportFormat.value}`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
ElMessage.success('导出成功');
|
||||||
|
exportVisible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单的HTML转Markdown(基础版本)
|
||||||
|
function htmlToMarkdown(html: string): string {
|
||||||
|
let markdown = html;
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
markdown = markdown.replace(/<h1>(.*?)<\/h1>/g, '# $1\n\n');
|
||||||
|
markdown = markdown.replace(/<h2>(.*?)<\/h2>/g, '## $1\n\n');
|
||||||
|
markdown = markdown.replace(/<h3>(.*?)<\/h3>/g, '### $1\n\n');
|
||||||
|
|
||||||
|
// 加粗、斜体
|
||||||
|
markdown = markdown.replace(/<strong>(.*?)<\/strong>/g, '**$1**');
|
||||||
|
markdown = markdown.replace(/<em>(.*?)<\/em>/g, '*$1*');
|
||||||
|
|
||||||
|
// 链接
|
||||||
|
markdown = markdown.replace(/<a href="(.*?)">(.*?)<\/a>/g, '[$2]($1)');
|
||||||
|
|
||||||
|
// 列表
|
||||||
|
markdown = markdown.replace(/<li>(.*?)<\/li>/g, '- $1\n');
|
||||||
|
markdown = markdown.replace(/<ul>(.*?)<\/ul>/gs, '$1\n');
|
||||||
|
markdown = markdown.replace(/<ol>(.*?)<\/ol>/gs, '$1\n');
|
||||||
|
|
||||||
|
// 段落
|
||||||
|
markdown = markdown.replace(/<p>(.*?)<\/p>/g, '$1\n\n');
|
||||||
|
|
||||||
|
// 移除其他HTML标签
|
||||||
|
markdown = markdown.replace(/<[^>]+>/g, '');
|
||||||
|
|
||||||
|
return markdown.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空
|
||||||
|
function handleClear() {
|
||||||
|
ElMessageBox.confirm(
|
||||||
|
'确定要清空所有内容吗?此操作不可恢复。',
|
||||||
|
'确认清空',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
}
|
||||||
|
).then(() => {
|
||||||
|
editorRef.value?.clear();
|
||||||
|
ElMessage.success('已清空');
|
||||||
|
}).catch(() => {
|
||||||
|
// 取消操作
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.rich-text-editor-view {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-description {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #909399;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
|
||||||
|
.action-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #ebeef5;
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
color: #909399;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
color: #606266;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-content {
|
||||||
|
padding: 20px;
|
||||||
|
background: #f5f7fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
:deep(h1), :deep(h2), :deep(h3) {
|
||||||
|
margin-top: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(p) {
|
||||||
|
margin: 8px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(ul), :deep(ol) {
|
||||||
|
margin: 12px 0;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon {
|
||||||
|
font-size: 36px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #606266;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.rich-text-editor-view {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.action-group {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-cards {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<el-form :model="userForm" label-width="120px" class="info-form">
|
<el-form :model="userForm" label-width="120px" class="info-form">
|
||||||
<el-form-item label="头像">
|
<el-form-item label="头像">
|
||||||
<div class="avatar-upload">
|
<div class="avatar-upload">
|
||||||
<img :src="userForm.avatar" alt="头像" class="avatar-preview" />
|
<img :src="userForm.avatar || defaultAvatar" alt="头像" class="avatar-preview" />
|
||||||
<el-button size="small" @click="handleAvatarUpload">更换头像</el-button>
|
<el-button size="small" @click="handleAvatarUpload">更换头像</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -56,6 +56,9 @@
|
|||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { ElForm, ElFormItem, ElInput, ElButton, ElRadio, ElRadioGroup, ElMessage } from 'element-plus';
|
import { ElForm, ElFormItem, ElInput, ElButton, ElRadio, ElRadioGroup, ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
// 默认头像
|
||||||
|
const defaultAvatar = new URL('@/assets/imgs/default-avatar.png', import.meta.url).href;
|
||||||
|
|
||||||
const userForm = ref({
|
const userForm = ref({
|
||||||
avatar: '',
|
avatar: '',
|
||||||
username: '',
|
username: '',
|
||||||
@@ -69,8 +72,23 @@ const userForm = ref({
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// TODO: 加载用户信息
|
// TODO: 加载用户信息
|
||||||
|
loadUserInfo();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function loadUserInfo() {
|
||||||
|
// 模拟数据
|
||||||
|
userForm.value = {
|
||||||
|
avatar: '',
|
||||||
|
username: '平台用户bc7a1b',
|
||||||
|
realName: '张三',
|
||||||
|
gender: 1,
|
||||||
|
phone: '15268425987',
|
||||||
|
email: 'zhangsan@example.com',
|
||||||
|
deptName: '机械学院',
|
||||||
|
bio: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function handleAvatarUpload() {
|
function handleAvatarUpload() {
|
||||||
// TODO: 上传头像
|
// TODO: 上传头像
|
||||||
ElMessage.info('上传头像功能开发中');
|
ElMessage.info('上传头像功能开发中');
|
||||||
@@ -82,7 +100,8 @@ function handleSave() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleCancel() {
|
function handleCancel() {
|
||||||
// TODO: 重置表单
|
// 重置表单
|
||||||
|
loadUserInfo();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -109,4 +128,3 @@ function handleCancel() {
|
|||||||
border: 2px solid #e0e0e0;
|
border: 2px solid #e0e0e0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -1,55 +1,87 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="profile-page">
|
<div class="user-center-page">
|
||||||
<div class="profile-container">
|
<div class="user-card-wrapper">
|
||||||
<h1 class="page-title">我导中心</h1>
|
<UserCard/>
|
||||||
|
</div>
|
||||||
<el-tabs v-model="activeTab" class="profile-tabs">
|
<div class="content-wrapper">
|
||||||
<el-tab-pane label="个人信息" name="info">
|
<div class="sidebar-wrapper">
|
||||||
<PersonalInfo />
|
<FloatingSidebar :menus="menus" />
|
||||||
</el-tab-pane>
|
</div>
|
||||||
<el-tab-pane label="账号设置" name="settings">
|
<div class="main-content">
|
||||||
<AccountSettings />
|
<router-view/>
|
||||||
</el-tab-pane>
|
</div>
|
||||||
</el-tabs>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { ElTabs, ElTabPane } from 'element-plus';
|
import { useRoute } from 'vue-router';
|
||||||
import PersonalInfo from './PersonalInfoView.vue';
|
import { FloatingSidebar } from '@/components/base';
|
||||||
import AccountSettings from './AccountSettingsView.vue';
|
import { UserCard } from '@/views/user-center/components';
|
||||||
|
import { getParentChildrenRoutes } from '@/utils/routeUtils';
|
||||||
|
import type { SysMenu } from '@/types/menu';
|
||||||
|
|
||||||
const activeTab = ref('info');
|
const route = useRoute();
|
||||||
|
|
||||||
|
// 从当前路由配置中获取子路由,转换为菜单格式
|
||||||
|
const menus = computed(() => {
|
||||||
|
// 使用工具函数获取父路由的子路由
|
||||||
|
const childRoutes = getParentChildrenRoutes(route);
|
||||||
|
|
||||||
|
if (childRoutes.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取父路由路径(用于拼接相对路径)
|
||||||
|
const parentRoute = route.matched[route.matched.length - 2];
|
||||||
|
|
||||||
|
// 将子路由转换为菜单格式
|
||||||
|
return childRoutes
|
||||||
|
.map((child: any) => ({
|
||||||
|
menuID: child.name as string || child.path,
|
||||||
|
name: child.meta?.title as string,
|
||||||
|
url: child.path.startsWith('/') ? child.path : `${parentRoute.path}/${child.path}`,
|
||||||
|
icon: child.meta?.icon as string,
|
||||||
|
orderNum: child.meta?.orderNum as number || 0,
|
||||||
|
} as SysMenu))
|
||||||
|
.sort((a: any, b: any) => (a.orderNum || 0) - (b.orderNum || 0));
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.profile-page {
|
.user-center-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-container {
|
.user-card-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
|
width: 100%;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-wrapper {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
background: white;
|
background: white;
|
||||||
padding: 40px;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
padding: 20px;
|
||||||
|
|
||||||
.page-title {
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #141F38;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-tabs {
|
|
||||||
:deep(.el-tabs__nav-wrap) {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -17,62 +17,35 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useStore } from 'vuex';
|
|
||||||
import { FloatingSidebar } from '@/components/base';
|
import { FloatingSidebar } from '@/components/base';
|
||||||
import UserCard from './components/UserCard.vue';
|
import { UserCard } from '@/views/user-center/components';
|
||||||
import { MenuType } from '@/types/enums';
|
import { getParentChildrenRoutes } from '@/utils/routeUtils';
|
||||||
import type { SysMenu } from '@/types/menu';
|
import type { SysMenu } from '@/types/menu';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const store = useStore();
|
|
||||||
|
|
||||||
// 从 store 中获取当前用户的菜单列表
|
// 从当前路由配置中获取子路由,转换为菜单格式
|
||||||
const allMenus = computed(() => store.state.auth.menus as SysMenu[]);
|
|
||||||
|
|
||||||
// 递归筛选出类型为 SIDEBAR 的菜单项(包含子菜单)
|
|
||||||
const filterSidebarMenus = (menuList: SysMenu[]): SysMenu[] => {
|
|
||||||
if (!menuList || menuList.length === 0) return [];
|
|
||||||
|
|
||||||
return menuList
|
|
||||||
.filter(menu => menu.type === MenuType.SIDEBAR)
|
|
||||||
.map(menu => {
|
|
||||||
// 如果有子菜单,递归处理
|
|
||||||
if (menu.children && menu.children.length > 0) {
|
|
||||||
return {
|
|
||||||
...menu,
|
|
||||||
children: filterSidebarMenus(menu.children)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return menu;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 查找当前路由对应的菜单项
|
|
||||||
const findCurrentMenu = (menus: SysMenu[], path: string): SysMenu | null => {
|
|
||||||
for (const menu of menus) {
|
|
||||||
if (menu.url === path) {
|
|
||||||
return menu;
|
|
||||||
}
|
|
||||||
if (menu.children && menu.children.length > 0) {
|
|
||||||
const found = findCurrentMenu(menu.children, path);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取当前路由对应的菜单及其子菜单(只显示 SIDEBAR 类型)
|
|
||||||
const menus = computed(() => {
|
const menus = computed(() => {
|
||||||
const currentPath = route.path;
|
// 使用工具函数获取父路由的子路由
|
||||||
const currentMenu = findCurrentMenu(allMenus.value, currentPath);
|
const childRoutes = getParentChildrenRoutes(route);
|
||||||
|
|
||||||
if (currentMenu && currentMenu.children) {
|
if (childRoutes.length === 0) {
|
||||||
// 递归筛选出 type === MenuType.SIDEBAR 的子菜单
|
return [];
|
||||||
return filterSidebarMenus(currentMenu.children);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果没有找到当前菜单,返回所有 SIDEBAR 类型的菜单
|
// 获取父路由路径(用于拼接相对路径)
|
||||||
return filterSidebarMenus(allMenus.value);
|
const parentRoute = route.matched[route.matched.length - 2];
|
||||||
|
|
||||||
|
// 将子路由转换为菜单格式
|
||||||
|
return childRoutes
|
||||||
|
.map((child: any) => ({
|
||||||
|
menuID: child.name as string || child.path,
|
||||||
|
name: child.meta?.title as string,
|
||||||
|
url: child.path.startsWith('/') ? child.path : `${parentRoute.path}/${child.path}`,
|
||||||
|
icon: child.meta?.icon as string,
|
||||||
|
orderNum: child.meta?.orderNum as number || 0,
|
||||||
|
} as SysMenu))
|
||||||
|
.sort((a: any, b: any) => (a.orderNum || 0) - (b.orderNum || 0));
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
<!-- 头像 -->
|
<!-- 头像 -->
|
||||||
<div class="avatar-wrapper">
|
<div class="avatar-wrapper">
|
||||||
<img
|
<img
|
||||||
:src="userInfo?.avatar || defaultAvatar"
|
:src="userInfo?.avatar && userInfo.avatar!='default' ? userInfo.avatar : defaultAvatar"
|
||||||
:alt="userInfo?.nickname || userInfo?.username"
|
:alt="userInfo?.username"
|
||||||
class="avatar"
|
class="avatar"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -23,21 +23,21 @@
|
|||||||
<div class="user-details">
|
<div class="user-details">
|
||||||
<!-- 用户名和性别 -->
|
<!-- 用户名和性别 -->
|
||||||
<div class="user-name-row">
|
<div class="user-name-row">
|
||||||
<span class="username">{{ userInfo?.nickname || userInfo?.username || '未设置昵称' }}</span>
|
<span class="username">{{ userInfo?.username || '未设置昵称' }}</span>
|
||||||
<div class="gender-tag" v-if="userInfo?.gender && genderIcon">
|
<div class="gender-tag" v-if="userInfo?.gender">
|
||||||
<img :src="genderIcon" :alt="genderText" class="gender-icon" />
|
<img :src="userInfo?.gender === 1 ? maleIcon : femaleIcon" :alt="userInfo?.gender === 1 ? '男' : '女'" class="gender-icon" />
|
||||||
<span class="gender-text">{{ genderText }}</span>
|
<span class="gender-text">{{ userInfo?.gender === 1 ? '男' : '女' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 详细信息 -->
|
<!-- 详细信息 -->
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<span class="info-item">所属部门:{{ departmentName || '未分配部门' }}</span>
|
<span class="info-item">所属部门:{{ userInfo?.deptName || '未分配部门' }}</span>
|
||||||
<span class="info-item">联系方式:{{ userInfo?.phone || '未设置' }}</span>
|
<span class="info-item" v-if="userInfo?.phone">手机号:{{ userInfo?.phone || '未设置' }}</span>
|
||||||
|
<span class="info-item" v-if="userInfo?.email">邮箱:{{ userInfo?.email || '未设置' }}</span>
|
||||||
<div class="level-item">
|
<div class="level-item">
|
||||||
<span class="info-label">学习等级:</span>
|
<span class="info-label">学习等级:</span>
|
||||||
<img :src="arrowDownIcon" alt="等级" class="level-icon" />
|
<img :src="levelIcon" alt="等级" class="level-icon" />
|
||||||
<span class="level-text">等级</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,58 +49,45 @@
|
|||||||
|
|
||||||
<script setup lang="ts" name="UserCard">
|
<script setup lang="ts" name="UserCard">
|
||||||
import { computed, ref, onMounted } from 'vue';
|
import { computed, ref, onMounted } from 'vue';
|
||||||
import { useStore } from 'vuex';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { UserVO } from '@/types';
|
import { UserVO } from '@/types';
|
||||||
import {userApi} from '@/apis/system/user'
|
import {userProfileApi} from '@/apis/usercenter/profile'
|
||||||
import defaultAvatarImg from '@/assets/imgs/default-avatar.png';
|
import defaultAvatarImg from '@/assets/imgs/default-avatar.png';
|
||||||
import maleIcon from '@/assets/imgs/male.svg';
|
import maleIcon from '@/assets/imgs/male.svg';
|
||||||
import femaleIcon from '@/assets/imgs/female.svg';
|
import femaleIcon from '@/assets/imgs/female.svg';
|
||||||
import arrowDownIcon from '@/assets/imgs/arrow-down.svg';
|
import V1Icon from '@/assets/imgs/v1.svg';
|
||||||
|
import V2Icon from '@/assets/imgs/v2.svg';
|
||||||
|
import V3Icon from '@/assets/imgs/v3.svg';
|
||||||
|
import V4Icon from '@/assets/imgs/v4.svg';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
const store = useStore();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const userInfo = ref<UserVO>();
|
||||||
|
|
||||||
// 默认头像
|
// 默认头像
|
||||||
const defaultAvatar = defaultAvatarImg;
|
const defaultAvatar = defaultAvatarImg;
|
||||||
|
|
||||||
// 从 store 获取用户信息
|
const levelIcon = computed(() => {
|
||||||
const loginDomain = computed(() => store.state.auth.loginDomain);
|
switch(userInfo.value?.level){
|
||||||
const userInfo = ref<UserVO>();
|
case 1: return V1Icon;
|
||||||
|
case 2: return V2Icon;
|
||||||
// 获取部门名称
|
case 3: return V3Icon;
|
||||||
const departmentName = computed(() => {
|
case 4: return V4Icon;
|
||||||
const roles = loginDomain.value?.roles || [];
|
|
||||||
if (roles.length > 0 && roles[0].dept) {
|
|
||||||
return roles[0].dept.name;
|
|
||||||
}
|
}
|
||||||
return '';
|
return V1Icon;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 性别文本
|
function handleEdit() {
|
||||||
const genderText = computed(() => {
|
router.push('/profile');
|
||||||
const gender = userInfo.value?.gender;
|
}
|
||||||
if (gender === 1) return '男';
|
|
||||||
if (gender === 2) return '女';
|
|
||||||
return '';
|
|
||||||
});
|
|
||||||
|
|
||||||
// 性别图标
|
|
||||||
const genderIcon = computed(() => {
|
|
||||||
const gender = userInfo.value?.gender;
|
|
||||||
if (gender === 1) return maleIcon;
|
|
||||||
if (gender === 2) return femaleIcon;
|
|
||||||
return '';
|
|
||||||
});
|
|
||||||
|
|
||||||
// 编辑资料
|
|
||||||
const handleEdit = () => {
|
|
||||||
router.push('/profile/personal-info');
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const res = await userApi.getCurrentUser();
|
const res = await userProfileApi.getUserProfile();
|
||||||
|
if(res.success){
|
||||||
userInfo.value = res.data;
|
userInfo.value = res.data;
|
||||||
|
}else{
|
||||||
|
ElMessage.error(res.message || '获取用户信息失败');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
1
schoolNewsWeb/src/views/user-center/components/index.ts
Normal file
1
schoolNewsWeb/src/views/user-center/components/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as UserCard } from './UserCard.vue';
|
||||||
@@ -19,6 +19,9 @@ export default defineConfig({
|
|||||||
|
|
||||||
// 基础路径
|
// 基础路径
|
||||||
base: '/schoolNewsWeb/',
|
base: '/schoolNewsWeb/',
|
||||||
|
file: {
|
||||||
|
downloadUrl: "http://127.0.0.1:8081/schoolNewsServ/file/download/"
|
||||||
|
},
|
||||||
|
|
||||||
// 输出目录
|
// 输出目录
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
@@ -2177,11 +2177,21 @@ esutils@^2.0.2:
|
|||||||
resolved "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz"
|
resolved "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz"
|
||||||
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
|
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
|
||||||
|
|
||||||
|
eventemitter3@^5.0.1:
|
||||||
|
version "5.0.1"
|
||||||
|
resolved "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.1.tgz"
|
||||||
|
integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==
|
||||||
|
|
||||||
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
|
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
|
||||||
version "3.1.3"
|
version "3.1.3"
|
||||||
resolved "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"
|
resolved "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"
|
||||||
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
|
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
|
||||||
|
|
||||||
|
fast-diff@^1.3.0:
|
||||||
|
version "1.3.0"
|
||||||
|
resolved "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.3.0.tgz"
|
||||||
|
integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==
|
||||||
|
|
||||||
fast-glob@^3.2.9, fast-glob@^3.3.2:
|
fast-glob@^3.2.9, fast-glob@^3.3.2:
|
||||||
version "3.3.3"
|
version "3.3.3"
|
||||||
resolved "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz"
|
resolved "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz"
|
||||||
@@ -2840,11 +2850,21 @@ lodash-unified@^1.0.3:
|
|||||||
resolved "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz"
|
resolved "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz"
|
||||||
integrity sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==
|
integrity sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==
|
||||||
|
|
||||||
|
lodash.clonedeep@^4.5.0:
|
||||||
|
version "4.5.0"
|
||||||
|
resolved "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz"
|
||||||
|
integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==
|
||||||
|
|
||||||
lodash.debounce@^4.0.8:
|
lodash.debounce@^4.0.8:
|
||||||
version "4.0.8"
|
version "4.0.8"
|
||||||
resolved "https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz"
|
resolved "https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz"
|
||||||
integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
|
integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
|
||||||
|
|
||||||
|
lodash.isequal@^4.5.0:
|
||||||
|
version "4.5.0"
|
||||||
|
resolved "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz"
|
||||||
|
integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==
|
||||||
|
|
||||||
lodash.merge@^4.6.2:
|
lodash.merge@^4.6.2:
|
||||||
version "4.6.2"
|
version "4.6.2"
|
||||||
resolved "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz"
|
resolved "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz"
|
||||||
@@ -3027,6 +3047,11 @@ own-keys@^1.0.1:
|
|||||||
object-keys "^1.1.1"
|
object-keys "^1.1.1"
|
||||||
safe-push-apply "^1.0.0"
|
safe-push-apply "^1.0.0"
|
||||||
|
|
||||||
|
parchment@^3.0.0:
|
||||||
|
version "3.0.0"
|
||||||
|
resolved "https://registry.npmmirror.com/parchment/-/parchment-3.0.0.tgz"
|
||||||
|
integrity sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==
|
||||||
|
|
||||||
parent-module@^1.0.0:
|
parent-module@^1.0.0:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz"
|
resolved "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz"
|
||||||
@@ -3126,6 +3151,25 @@ queue-microtask@^1.2.2:
|
|||||||
resolved "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz"
|
resolved "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz"
|
||||||
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
|
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
|
||||||
|
|
||||||
|
quill-delta@^5.1.0:
|
||||||
|
version "5.1.0"
|
||||||
|
resolved "https://registry.npmmirror.com/quill-delta/-/quill-delta-5.1.0.tgz"
|
||||||
|
integrity sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==
|
||||||
|
dependencies:
|
||||||
|
fast-diff "^1.3.0"
|
||||||
|
lodash.clonedeep "^4.5.0"
|
||||||
|
lodash.isequal "^4.5.0"
|
||||||
|
|
||||||
|
quill@^2.0.3:
|
||||||
|
version "2.0.3"
|
||||||
|
resolved "https://registry.npmmirror.com/quill/-/quill-2.0.3.tgz"
|
||||||
|
integrity sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==
|
||||||
|
dependencies:
|
||||||
|
eventemitter3 "^5.0.1"
|
||||||
|
lodash-es "^4.17.21"
|
||||||
|
parchment "^3.0.0"
|
||||||
|
quill-delta "^5.1.0"
|
||||||
|
|
||||||
randombytes@^2.1.0:
|
randombytes@^2.1.0:
|
||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.npmmirror.com/randombytes/-/randombytes-2.1.0.tgz"
|
resolved "https://registry.npmmirror.com/randombytes/-/randombytes-2.1.0.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user