feat: html格式转图片网页工具

master
LCJ-MinYa 10 months ago
parent affd1ea9ee
commit 347f59895a

@ -34,6 +34,7 @@
"echarts": "^5.5.1",
"element-plus": "^2.8.0",
"github-markdown-css": "^5.8.1",
"html2canvas": "^1.4.1",
"js-cookie": "^3.0.5",
"localforage": "^1.10.0",
"marked": "^15.0.7",

@ -47,6 +47,9 @@ importers:
github-markdown-css:
specifier: ^5.8.1
version: 5.8.1
html2canvas:
specifier: ^1.4.1
version: 1.4.1
js-cookie:
specifier: ^3.0.5
version: 3.0.5
@ -1269,6 +1272,10 @@ packages:
balanced-match@2.0.0:
resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==}
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
@ -1457,6 +1464,9 @@ packages:
resolution: {integrity: sha512-c+N0v6wbKVxTu5gOBBFkr9BEdBWaqqjQeiJ8QvSRIJOf+UxlJh930m8e6/WNeODIK0mYLFkoONrnj16i2EcvfQ==}
engines: {node: '>=12 || >=16'}
css-line-break@2.1.0:
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
css-select@4.3.0:
resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==}
@ -1993,6 +2003,10 @@ packages:
resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==}
engines: {node: '>=8'}
html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'}
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
@ -3242,6 +3256,9 @@ packages:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
text-segmentation@1.0.3:
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
@ -3357,6 +3374,9 @@ packages:
util@0.10.4:
resolution: {integrity: sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==}
utrie@1.0.2:
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
@ -4650,6 +4670,8 @@ snapshots:
balanced-match@2.0.0: {}
base64-arraybuffer@1.0.2: {}
binary-extensions@2.3.0: {}
boolbase@1.0.0: {}
@ -4850,6 +4872,10 @@ snapshots:
css-functions-list@3.2.2: {}
css-line-break@2.1.0:
dependencies:
utrie: 1.0.2
css-select@4.3.0:
dependencies:
boolbase: 1.0.0
@ -5528,6 +5554,11 @@ snapshots:
html-tags@3.3.1: {}
html2canvas@1.4.1:
dependencies:
css-line-break: 2.1.0
text-segmentation: 1.0.3
htmlparser2@8.0.2:
dependencies:
domelementtype: 2.3.0
@ -6755,6 +6786,10 @@ snapshots:
yallist: 4.0.0
optional: true
text-segmentation@1.0.3:
dependencies:
utrie: 1.0.2
text-table@0.2.0: {}
thenify-all@1.6.0:
@ -6885,6 +6920,10 @@ snapshots:
dependencies:
inherits: 2.0.3
utrie@1.0.2:
dependencies:
base64-arraybuffer: 1.0.2
uuid@8.3.2: {}
vite-plugin-cdn-import@1.0.1(rollup@4.21.0)(vite@5.4.1(@types/node@20.16.1)(sass@1.77.8)):

@ -1,12 +1,12 @@
// import "@/utils/sso";
import Cookies from "js-cookie";
import { getConfig } from "@/config";
import NProgress from "@/utils/progress";
import { buildHierarchyTree } from "@/utils/tree";
import remainingRouter from "./modules/remaining";
import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
import { usePermissionStoreHook } from "@/store/modules/permission";
import { isUrl, openLink, storageLocal, isAllEmpty } from "@pureadmin/utils";
import Cookies from 'js-cookie';
import { getConfig } from '@/config';
import NProgress from '@/utils/progress';
import { buildHierarchyTree } from '@/utils/tree';
import remainingRouter from './modules/remaining';
import { useMultiTagsStoreHook } from '@/store/modules/multiTags';
import { usePermissionStoreHook } from '@/store/modules/permission';
import { isUrl, openLink, storageLocal, isAllEmpty } from '@pureadmin/utils';
import {
ascending,
getTopMenu,
@ -16,36 +16,23 @@ import {
findRouteByPath,
handleAliveRoute,
formatTwoStageRoutes,
formatFlatteningRoutes
} from "./utils";
import {
type Router,
createRouter,
type RouteRecordRaw,
type RouteComponent
} from "vue-router";
import {
type DataInfo,
userKey,
removeToken,
multipleTabsKey
} from "@/utils/auth";
formatFlatteningRoutes,
} from './utils';
import { type Router, createRouter, type RouteRecordRaw, type RouteComponent } from 'vue-router';
import { type DataInfo, userKey, removeToken, multipleTabsKey } from '@/utils/auth';
/** src/router/modules .ts remaining.ts
* https://github.com/mrmlnc/fast-glob#basic-syntax
* https://cn.vitejs.dev/guide/features.html#negative-patterns
*/
const modules: Record<string, any> = import.meta.glob(
["./modules/**/*.ts", "!./modules/**/remaining.ts"],
{
eager: true
}
);
const modules: Record<string, any> = import.meta.glob(['./modules/**/*.ts', '!./modules/**/remaining.ts'], {
eager: true,
});
/** 原始静态路由(未做任何处理) */
const routes = [];
Object.keys(modules).forEach(key => {
Object.keys(modules).forEach((key) => {
routes.push(modules[key].default);
});
@ -55,12 +42,10 @@ export const constantRoutes: Array<RouteRecordRaw> = formatTwoStageRoutes(
);
/** 用于渲染菜单,保持原始层级 */
export const constantMenus: Array<RouteComponent> = ascending(
routes.flat(Infinity)
).concat(...remainingRouter);
export const constantMenus: Array<RouteComponent> = ascending(routes.flat(Infinity)).concat(...remainingRouter);
/** 不参与菜单的路由 */
export const remainingPaths = Object.keys(remainingRouter).map(v => {
export const remainingPaths = Object.keys(remainingRouter).map((v) => {
return remainingRouter[v].path;
});
@ -70,46 +55,41 @@ export const router: Router = createRouter({
routes: constantRoutes.concat(...(remainingRouter as any)),
strict: true,
scrollBehavior(to, from, savedPosition) {
return new Promise(resolve => {
return new Promise((resolve) => {
if (savedPosition) {
return savedPosition;
} else {
if (from.meta.saveSrollTop) {
const top: number =
document.documentElement.scrollTop || document.body.scrollTop;
const top: number = document.documentElement.scrollTop || document.body.scrollTop;
resolve({ left: 0, top });
}
}
});
}
},
});
/** 重置路由 */
export function resetRouter() {
router.getRoutes().forEach(route => {
router.getRoutes().forEach((route) => {
const { name, meta } = route;
if (name && router.hasRoute(name) && meta?.backstage) {
router.removeRoute(name);
router.options.routes = formatTwoStageRoutes(
formatFlatteningRoutes(
buildHierarchyTree(ascending(routes.flat(Infinity)))
)
);
router.options.routes = formatTwoStageRoutes(formatFlatteningRoutes(buildHierarchyTree(ascending(routes.flat(Infinity)))));
}
});
usePermissionStoreHook().clearAllCachePage();
}
/** 路由白名单 */
const whiteList = ["/login"];
const whiteList = ['/login', '/htmlToImg'];
const { VITE_HIDE_HOME } = import.meta.env;
router.beforeEach((to: ToRouteType, _from, next) => {
if (to.meta?.keepAlive) {
handleAliveRoute(to, "add");
handleAliveRoute(to, 'add');
// 页面整体刷新和点击标签页刷新
if (_from.name === undefined || _from.name === "Redirect") {
if (_from.name === undefined || _from.name === 'Redirect') {
handleAliveRoute(to);
}
}
@ -117,8 +97,8 @@ router.beforeEach((to: ToRouteType, _from, next) => {
NProgress.start();
const externalLink = isUrl(to?.name as string);
if (!externalLink) {
to.matched.some(item => {
if (!item.meta.title) return "";
to.matched.some((item) => {
if (!item.meta.title) return '';
const Title = getConfig().Title;
if (Title) document.title = `${item.meta.title} | ${Title}`;
else document.title = item.meta.title as string;
@ -126,16 +106,16 @@ router.beforeEach((to: ToRouteType, _from, next) => {
}
/** 如果已经登录并存在登录信息后不能跳转到路由白名单,而是继续保持在当前页面 */
function toCorrectRoute() {
whiteList.includes(to.fullPath) ? next(_from.fullPath) : next();
whiteList.includes(to.fullPath) && to.fullPath === '/login' ? next(_from.fullPath) : next();
}
if (Cookies.get(multipleTabsKey) && userInfo) {
// 无权限跳转403页面
if (to.meta?.roles && !isOneOfArray(to.meta?.roles, userInfo?.roles)) {
next({ path: "/error/403" });
next({ path: '/error/403' });
}
// 开启隐藏首页后在浏览器地址栏手动输入首页welcome路由则跳转到404页面
if (VITE_HIDE_HOME === "true" && to.fullPath === "/welcome") {
next({ path: "/error/404" });
if (VITE_HIDE_HOME === 'true' && to.fullPath === '/welcome') {
next({ path: '/error/404' });
}
if (_from?.name) {
// name为超链接
@ -147,34 +127,28 @@ router.beforeEach((to: ToRouteType, _from, next) => {
}
} else {
// 刷新
if (
usePermissionStoreHook().wholeMenus.length === 0 &&
to.path !== "/login"
) {
if (usePermissionStoreHook().wholeMenus.length === 0 && to.path !== '/login') {
initRouter().then((router: Router) => {
if (!useMultiTagsStoreHook().getMultiTagsCache) {
const { path } = to;
const route = findRouteByPath(
path,
router.options.routes[0].children
);
const route = findRouteByPath(path, router.options.routes[0].children);
getTopMenu(true);
// query、params模式路由传参数的标签页不在此处处理
if (route && route.meta?.title) {
if (isAllEmpty(route.parentId) && route.meta?.backstage) {
// 此处为动态顶级路由(目录)
const { path, name, meta } = route.children[0];
useMultiTagsStoreHook().handleTags("push", {
useMultiTagsStoreHook().handleTags('push', {
path,
name,
meta
meta,
});
} else {
const { path, name, meta } = route;
useMultiTagsStoreHook().handleTags("push", {
useMultiTagsStoreHook().handleTags('push', {
path,
name,
meta
meta,
});
}
}
@ -186,12 +160,12 @@ router.beforeEach((to: ToRouteType, _from, next) => {
toCorrectRoute();
}
} else {
if (to.path !== "/login") {
if (to.path !== '/login') {
if (whiteList.indexOf(to.path) !== -1) {
next();
} else {
removeToken();
next({ path: "/login" });
next({ path: '/login' });
}
} else {
next();

@ -37,4 +37,14 @@ export default [
rank: 103,
},
},
{
path: '/htmlToImg',
name: 'HtmlToImg',
component: () => import('@/views/utils/htmlToImg.vue'),
meta: {
title: 'HTML转图片',
showLink: false,
rank: 104,
},
},
] satisfies Array<RouteConfigsTable>;

@ -0,0 +1,207 @@
<template>
<div class="flex justify-center">
<div class="container flex min-h-screen p-8">
<div class="flex flex-col w-[40%] pr-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">HTML 转图片工具</h1>
<p class="mt-2 text-gray-600">将您的 HTML 代码转换为高质量图片</p>
</div>
<div class="flex-1 flex flex-col">
<div class="flex items-center justify-between mb-4">
<label class="text-sm font-medium text-gray-700">输入 HTML 代码</label>
<div class="flex items-center space-x-2">
<!-- <el-button
type="primary"
text
class="text-sm whitespace-nowrap !rounded-button"
@click="importFile"
>
<el-icon class="mr-1"><Upload /></el-icon>
</el-button> -->
<el-button
text
class="text-sm text-gray-500 whitespace-nowrap !rounded-button"
@click="clearContent"
>
<el-icon class="mr-1"><Delete /></el-icon>
</el-button>
</div>
</div>
<el-input
v-model="htmlContent"
type="textarea"
:rows="10"
class="flex-1"
placeholder="在此处粘贴您的 HTML 代码..."
resize="none"
/>
<div class="mt-6 flex items-center justify-end">
<!-- <div class="flex items-center space-x-4">
<div class="flex items-center">
<label class="text-sm text-gray-600 mr-2">宽度</label>
<el-input-number
v-model="width"
:min="1"
:max="3840"
class="w-[100px]"
controls-position="right"
/>
</div>
<div class="flex items-center">
<label class="text-sm text-gray-600 mr-2">高度</label>
<el-input-number
v-model="height"
:min="1"
:max="2160"
class="w-[100px]"
controls-position="right"
/>
</div>
</div> -->
<el-button
type="primary"
class="whitespace-nowrap !rounded-button"
@click="preview"
>
<el-icon class="mr-2"><View /></el-icon>
</el-button>
</div>
</div>
</div>
<div class="flex flex-col w-[60%] bg-gray-50 rounded-lg p-8">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-medium text-gray-900">预览效果</h2>
<div class="flex items-center space-x-4">
<el-button
class="whitespace-nowrap !rounded-button"
@click="toggleFullscreen"
>
<el-icon class="mr-1"><FullScreen /></el-icon>
</el-button>
<el-button
type="success"
class="whitespace-nowrap !rounded-button"
@click="exportImage"
>
<el-icon class="mr-2"><Download /></el-icon>
</el-button>
</div>
</div>
<div class="flex-1 bg-white rounded-lg border border-gray-200 p-6">
<div
class="flex bg-white items-center justify-center h-full text-gray-400"
ref="previewRef"
>
<div
class="text-center"
v-if="!previewContent"
>
<el-icon class="text-6xl mb-4"><Picture /></el-icon>
<p>预览区域</p>
<p class="text-sm mt-2">点击"预览效果"按钮查看渲染结果</p>
</div>
<div
v-else
v-html="previewContent"
class="w-full h-full"
ref="htmlContentRef"
></div>
</div>
</div>
<div class="mt-6">
<el-alert
type="info"
show-icon
:closable="false"
>
<template #title>
<span class="font-medium">使用提示</span>
</template>
<ul class="list-disc pl-5 space-y-1 mt-2">
<li>支持完整的 HTML 代码转换</li>
<li>可自定义输出图片尺寸</li>
<li>支持导出 PNGJPG 格式</li>
<li>建议代码格式化后再进行转换</li>
</ul>
</el-alert>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { Upload, Delete, View, FullScreen, Download, Picture } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import html2canvas from 'html2canvas';
const htmlContent = ref('');
const htmlContentRef = ref(null);
const width = ref(1920);
const height = ref(1080);
const previewContent = ref('');
const previewRef = ref<HTMLElement | null>(null);
const importFile = () => {
ElMessage.info('导入文件功能开发中...');
};
const clearContent = () => {
htmlContent.value = '';
previewContent.value = '';
};
const preview = () => {
if (!htmlContent.value) {
ElMessage.warning('请先输入 HTML 代码');
return;
}
previewContent.value = htmlContent.value;
};
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
previewRef.value?.requestFullscreen();
} else {
document.exitFullscreen();
}
};
const exportImage = () => {
if (!previewContent.value) {
ElMessage.warning('请先预览效果');
return;
}
html2canvas(previewRef.value).then((canvas) => {
const imgData = canvas.toDataURL('image/png');
const img = new Image();
img.src = imgData;
const link = document.createElement('a');
link.download = 'preview.png';
link.href = imgData;
link.click();
});
};
</script>
<style scoped>
:deep(.el-textarea__inner) {
height: 100%;
}
:deep(.el-input-number .el-input__inner) {
text-align: left;
}
.container {
max-width: 1440px;
}
</style>
Loading…
Cancel
Save