feat: 优化编辑器逻辑

master
luoer 2024-02-23 16:14:31 +08:00
parent 7f9cbe8466
commit 4aef16583d
35 changed files with 738 additions and 436 deletions

4
.env
View File

@ -2,9 +2,9 @@
# 应用配置
# =====================================================================================
# 网站标题
VITE_TITLE = 绝弹管理中心
VITE_TITLE = 管理中心
# 网站副标题
VITE_SUBTITLE = 绝弹管理中心1
VITE_SUBTITLE = 绝弹管理中心
# 部署路径: 当为 ./ 时路由模式需为 hash
VITE_BASE = /
# 接口前缀:参见 axios 的 baseURL

View File

@ -15,6 +15,7 @@
- 遵循 Conventional Changelog 规范, 自动生成版本记录文档
- 内置常用 VsCode 代码片段和推荐扩展,提升开发效率
- 支持路由动态打包、路由权限、路由缓存和动态首页
- 支持 Docker 部署,包含优化过的 Dockerfile 配置
## 快速开始

View File

@ -9,16 +9,13 @@
</head>
<body>
<div id="app" class="dark:bg-slate-900 dark:text-slate-200">
<div class="loading">
<img
src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHN0eWxlPSJtYXJnaW46IGF1dG87IGJhY2tncm91bmQ6IHJnYigyNTUsIDI1NSwgMjU1KTsgZGlzcGxheTogYmxvY2s7IHNoYXBlLXJlbmRlcmluZzogYXV0bzsiIHdpZHRoPSIyMDBweCIgaGVpZ2h0PSIyMDBweCIgdmlld0JveD0iMCAwIDEwMCAxMDAiIHByZXNlcnZlQXNwZWN0UmF0aW89InhNaWRZTWlkIj48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSg1MCA1MCkiPjxnPjxhbmltYXRlVHJhbnNmb3JtIGF0dHJpYnV0ZU5hbWU9InRyYW5zZm9ybSIgdHlwZT0icm90YXRlIiB2YWx1ZXM9IjA7NDUiIGtleVRpbWVzPSIwOzEiIGR1cj0iMC4ycyIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiPjwvYW5pbWF0ZVRyYW5zZm9ybT48cGF0aCBkPSJNMjkuNDkxNTI0MjA2MTE3MjU1IC01LjUgTDM3LjQ5MTUyNDIwNjExNzI1NSAtNS41IEwzNy40OTE1MjQyMDYxMTcyNTUgNS41IEwyOS40OTE1MjQyMDYxMTcyNTUgNS41IEEzMCAzMCAwIDAgMSAyNC43NDI3NDQwNTAxOTg3MzggMTYuOTY0NTY5NDU3MTQ2NzEyIEwyNC43NDI3NDQwNTAxOTg3MzggMTYuOTY0NTY5NDU3MTQ2NzEyIEwzMC4zOTk1OTgyOTk2OTExMTcgMjIuNjIxNDIzNzA2NjM5MDkyIEwyMi42MjE0MjM3MDY2MzkwOTYgMzAuMzk5NTk4Mjk5NjkxMTE0IEwxNi45NjQ1Njk0NTcxNDY3MTYgMjQuNzQyNzQ0MDUwMTk4NzM0IEEzMCAzMCAwIDAgMSA1LjUgMjkuNDkxNTI0MjA2MTE3MjU1IEw1LjUgMjkuNDkxNTI0MjA2MTE3MjU1IEw1LjUgMzcuNDkxNTI0MjA2MTE3MjU1IEwtNS40OTk5OTk5OTk5OTk5OTcgMzcuNDkxNTI0MjA2MTE3MjU1IEwtNS40OTk5OTk5OTk5OTk5OTcgMjkuNDkxNTI0MjA2MTE3MjU1IEEzMCAzMCAwIDAgMSAtMTYuOTY0NTY5NDU3MTQ2NzA1IDI0Ljc0Mjc0NDA1MDE5ODczOCBMLTE2Ljk2NDU2OTQ1NzE0NjcwNSAyNC43NDI3NDQwNTAxOTg3MzggTC0yMi42MjE0MjM3MDY2MzkwODUgMzAuMzk5NTk4Mjk5NjkxMTE3IEwtMzAuMzk5NTk4Mjk5NjkxMTE3IDIyLjYyMTQyMzcwNjYzOTA5MiBMLTI0Ljc0Mjc0NDA1MDE5ODczOCAxNi45NjQ1Njk0NTcxNDY3MTIgQTMwIDMwIDAgMCAxIC0yOS40OTE1MjQyMDYxMTcyNTUgNS41MDAwMDAwMDAwMDAwMDkgTC0yOS40OTE1MjQyMDYxMTcyNTUgNS41MDAwMDAwMDAwMDAwMDkgTC0zNy40OTE1MjQyMDYxMTcyNTUgNS41MDAwMDAwMDAwMDAwMSBMLTM3LjQ5MTUyNDIwNjExNzI1NSAtNS41MDAwMDAwMDAwMDAwMDEgTC0yOS40OTE1MjQyMDYxMTcyNTUgLTUuNTAwMDAwMDAwMDAwMDAyIEEzMCAzMCAwIDAgMSAtMjQuNzQyNzQ0MDUwMTk4NzM4IC0xNi45NjQ1Njk0NTcxNDY3MDUgTC0yNC43NDI3NDQwNTAxOTg3MzggLTE2Ljk2NDU2OTQ1NzE0NjcwNSBMLTMwLjM5OTU5ODI5OTY5MTExNyAtMjIuNjIxNDIzNzA2NjM5MDg1IEwtMjIuNjIxNDIzNzA2NjM5MDkyIC0zMC4zOTk1OTgyOTk2OTExMTcgTC0xNi45NjQ1Njk0NTcxNDY3MTIgLTI0Ljc0Mjc0NDA1MDE5ODczOCBBMzAgMzAgMCAwIDEgLTUuNTAwMDAwMDAwMDAwMDExIC0yOS40OTE1MjQyMDYxMTcyNTUgTC01LjUwMDAwMDAwMDAwMDAxMSAtMjkuNDkxNTI0MjA2MTE3MjU1IEwtNS41MDAwMDAwMDAwMDAwMTIgLTM3LjQ5MTUyNDIwNjExNzI1NSBMNS40OTk5OTk5OTk5OTk5OTggLTM3LjQ5MTUyNDIwNjExNzI1NSBMNS41IC0yOS40OTE1MjQyMDYxMTcyNTUgQTMwIDMwIDAgMCAxIDE2Ljk2NDU2OTQ1NzE0NjcwMiAtMjQuNzQyNzQ0MDUwMTk4NzQgTDE2Ljk2NDU2OTQ1NzE0NjcwMiAtMjQuNzQyNzQ0MDUwMTk4NzQgTDIyLjYyMTQyMzcwNjYzOTA4IC0zMC4zOTk1OTgyOTk2OTExMiBMMzAuMzk5NTk4Mjk5NjkxMTE3IC0yMi42MjE0MjM3MDY2MzkxIEwyNC43NDI3NDQwNTAxOTg3MzggLTE2Ljk2NDU2OTQ1NzE0NjcxNiBBMzAgMzAgMCAwIDEgMjkuNDkxNTI0MjA2MTE3MjU1IC01LjUwMDAwMDAwMDAwMDAxMyBNMCAtMjBBMjAgMjAgMCAxIDAgMCAyMCBBMjAgMjAgMCAxIDAgMCAtMjAiIGZpbGw9IiMwOWYiPjwvcGF0aD48L2c+PC9nPjwvc3ZnPgo="
alt="loading"
class="loading-image"
width="64"
height="64"
/>
<h1 class="loading-title">欢迎访问%VITE_TITLE%</h1>
<div class="loading-tip">资源加载中, 请稍等...</div>
<div class="cube">
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<style>
html,
@ -29,8 +26,7 @@
height: 100%;
overflow: hidden;
font-size: 14px;
font-family: Inter, '-apple-system', BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', 'noto sans',
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-family: Inter, '-apple-system', BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', 'noto sans', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
#app {
width: 100%;
@ -40,27 +36,51 @@
justify-content: center;
user-select: none;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
@keyframes cube {
0% {
transform: rotate(45deg) rotateX(-25deg) rotateY(25deg);
}
.loading-image {
width: 64px;
height: 64px;
50% {
transform: rotate(45deg) rotateX(-385deg) rotateY(25deg);
}
.loading-title {
margin: 0;
margin-top: 20px;
font-size: 22px;
font-weight: 400;
line-height: 1;
100% {
transform: rotate(45deg) rotateX(-385deg) rotateY(385deg);
}
.loading-tip {
margin-top: 12px;
line-height: 1;
color: #889;
}
.cube {
animation: cube 2s infinite ease;
height: 40px;
transform-style: preserve-3d;
width: 40px;
}
.cube div {
background-color: rgba(255, 255, 255, 0.25);
height: 100%;
position: absolute;
width: 100%;
border: 2px solid #000;
}
.cube div:nth-of-type(1) {
transform: translateZ(-20px) rotateY(180deg);
}
.cube div:nth-of-type(2) {
transform: rotateY(-270deg) translateX(50%);
transform-origin: top right;
}
.cube div:nth-of-type(3) {
transform: rotateY(270deg) translateX(-50%);
transform-origin: center left;
}
.cube div:nth-of-type(4) {
transform: rotateX(90deg) translateY(-50%);
transform-origin: top center;
}
.cube div:nth-of-type(5) {
transform: rotateX(-90deg) translateY(50%);
transform-origin: bottom center;
}
.cube div:nth-of-type(6) {
transform: translateZ(20px);
}
</style>
</div>

View File

@ -1 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="margin: auto; background: rgb(255, 255, 255); display: block; shape-rendering: auto;" width="200px" height="200px" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid"><g transform="translate(50 50)"><g><animateTransform attributeName="transform" type="rotate" values="0;45" keyTimes="0;1" dur="0.2s" repeatCount="indefinite"></animateTransform><path d="M29.491524206117255 -5.5 L37.491524206117255 -5.5 L37.491524206117255 5.5 L29.491524206117255 5.5 A30 30 0 0 1 24.742744050198738 16.964569457146712 L24.742744050198738 16.964569457146712 L30.399598299691117 22.621423706639092 L22.621423706639096 30.399598299691114 L16.964569457146716 24.742744050198734 A30 30 0 0 1 5.5 29.491524206117255 L5.5 29.491524206117255 L5.5 37.491524206117255 L-5.499999999999997 37.491524206117255 L-5.499999999999997 29.491524206117255 A30 30 0 0 1 -16.964569457146705 24.742744050198738 L-16.964569457146705 24.742744050198738 L-22.621423706639085 30.399598299691117 L-30.399598299691117 22.621423706639092 L-24.742744050198738 16.964569457146712 A30 30 0 0 1 -29.491524206117255 5.500000000000009 L-29.491524206117255 5.500000000000009 L-37.491524206117255 5.50000000000001 L-37.491524206117255 -5.500000000000001 L-29.491524206117255 -5.500000000000002 A30 30 0 0 1 -24.742744050198738 -16.964569457146705 L-24.742744050198738 -16.964569457146705 L-30.399598299691117 -22.621423706639085 L-22.621423706639092 -30.399598299691117 L-16.964569457146712 -24.742744050198738 A30 30 0 0 1 -5.500000000000011 -29.491524206117255 L-5.500000000000011 -29.491524206117255 L-5.500000000000012 -37.491524206117255 L5.499999999999998 -37.491524206117255 L5.5 -29.491524206117255 A30 30 0 0 1 16.964569457146702 -24.74274405019874 L16.964569457146702 -24.74274405019874 L22.62142370663908 -30.39959829969112 L30.399598299691117 -22.6214237066391 L24.742744050198738 -16.964569457146716 A30 30 0 0 1 29.491524206117255 -5.500000000000013 M0 -20A20 20 0 1 0 0 20 A20 20 0 1 0 0 -20" fill="#09f"></path></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -1,5 +1,5 @@
import { spawn } from 'child_process';
import { Plugin, ResolvedConfig } from 'vite';
import { Plugin } from 'vite';
import pkg from '../../package.json';
/**
@ -46,8 +46,11 @@ const getBuildInfo = async () => {
const version = commits ? `${latestTag}.${commits}` : `v${pkg.version}`;
const content = `欢迎访问!版本: ${version} 标识: ${hash} 构建: ${time}`;
const style = `"color: #09f; font-weight: 900;", "font-size: 12px; color: #09f; font-family: ''"`;
const script = `console.log(\`%c${LOGO} \n%c${content}\n\`, ${style});\n`;
return script;
const vString = `var __APP_VERSION__ = '${version}';\n`;
const hString = `var __APP_HASH__ = '${hash}';\n`;
const dString = `var __APP_DATE__ = '${time}';\n`;
const lString = `console.log(\`%c${LOGO} \n%c${content}\n\`, ${style});\n`;
return vString + hString + dString + lString;
};
/**
@ -55,16 +58,9 @@ const getBuildInfo = async () => {
* @returns Plugin
*/
export default function plugin(): Plugin {
let config: ResolvedConfig;
return {
name: 'vite:info',
enforce: 'pre',
configResolved(resolvedConfig) {
config = resolvedConfig;
},
async transformIndexHtml() {
const script = await getBuildInfo();
return [

View File

@ -18,6 +18,9 @@ const userStore = useUserStore();
const menuStore = useMenuStore();
const hasAuth = computed(() => {
if (!route.name.startsWith('_')) {
return true;
}
return route.matched.every(item => {
const needAuth = item.meta.auth;
const userAuth = userStore.auth;

View File

@ -1,8 +1,28 @@
import { Service } from './service';
import { addToastInterceptor } from '../interceptors/toast';
import { addAuthInterceptor } from '../interceptors/auth';
import { addExceptionInterceptor } from '../interceptors/exception';
import { env } from '@/config/env';
import { App } from 'vue';
import { Api } from '../generated/Api';
/**
* API
*/
export class Service extends Api<unknown> {
/**
* VUE
* @param app
*/
install(app: App) {
app.config.globalProperties.$api = this;
}
/**
*
* @description
*/
expireHandler: () => void = () => {};
}
/**
* API

View File

@ -1,21 +0,0 @@
import { App } from 'vue';
import { Api } from '../generated/Api';
/**
* API
*/
export class Service extends Api<unknown> {
/**
*
* @description
*/
expireHandler: () => void = () => {};
/**
* VUE
* @param app
*/
install(app: App) {
app.config.globalProperties.$api = this;
}
}

View File

@ -1,6 +1,5 @@
import { InjectionKey } from 'vue';
import { Block, Blocker, Container } from '../core';
import { useTextBlock } from './text';
const blockers: Record<string, Blocker> = import.meta.glob(['./*/index.ts', '!./font/*'], {
eager: true,
@ -25,47 +24,3 @@ const getIcon = (type: string) => {
};
export { BlockerMap, getBlockerRender, getIcon, getTypeName };
export const BlockerManagerKey = Symbol('k') as InjectionKey<ReturnType<typeof useBlockerManage>>
export function useBlockerManage() {
const blockers: Blocker[] = [useTextBlock()];
const leftPanels: any[] = [];
for (const blocker of blockers) {
const panel = blocker.addLeftTab?.();
if (panel) {
leftPanels.push(leftPanels);
}
}
const callInitHook = (container: Container) => {
for (const blocker of blockers) {
container = blocker.onLoadContainer?.(container) || container;
}
return container;
};
const callLoadHook = (data: any): Blocker => {
for (const blocker of blockers) {
data = blocker.onLoadBlock?.(data) || data;
}
return data;
};
const callSaveHook = (block: Block) => {
let data = block;
for (const blocker of blockers) {
data = blocker.onSaveBlock?.(data) || data;
}
return data;
};
return {
blockers,
leftPanels,
callInitHook,
callLoadHook,
callSaveHook,
};
}

View File

@ -1,87 +0,0 @@
import { Block, Blocker, defineBlocker } from '../../core';
import { font } from '../font';
import { Text } from './interface';
import Option from './option.vue';
import Render from './render.vue';
export default defineBlocker<Text>({
type: 'text',
icon: 'icon-park-outline-text',
title: '文本组件',
description: '文字',
render: Render,
option: Option,
initial: {
id: '',
type: 'text',
title: '',
x: 0,
y: 0,
w: 300,
h: 100,
xFixed: false,
yFixed: false,
bgImage: '',
bgColor: '',
meta: {},
actived: false,
resizable: true,
draggable: true,
params: {
marquee: false,
speed: 100,
direction: 'left',
fontCh: {
...font,
content:
'温馨提示:乘客您好,进站检票时,持票卡的乘客请在右侧闸机上方感应区内验票,扫码过闸的乘客请将乘车码对准闸机扫码口,扇门打开后依次进闸。乘车过程中请妥善保管好车票,以免丢失。',
},
},
},
});
export function useTextBlock(): Blocker<Text> {
const initialData: Text = {
id: '',
type: 'text',
title: '',
x: 0,
y: 0,
w: 300,
h: 100,
xFixed: false,
yFixed: false,
bgImage: '',
bgColor: '',
meta: {},
actived: false,
resizable: true,
draggable: true,
params: {
marquee: false,
speed: 100,
direction: 'left',
fontCh: {
...font,
content:
'温馨提示:乘客您好,进站检票时,持票卡的乘客请在右侧闸机上方感应区内验票,扫码过闸的乘客请将乘车码对准闸机扫码口,扇门打开后依次进闸。乘车过程中请妥善保管好车票,以免丢失。',
},
},
};
return {
type: 'text',
icon: 'icon-park-outline-text',
title: '文本组件',
description: '文字',
render: Render,
option: Option,
initial: initialData,
addLeftTab() {
return {
title: '文本测试',
icon: 'icon-park-outline-user',
component: () => h('div', null, 'TODO')
}
},
};
}

View File

@ -0,0 +1,145 @@
import { merge } from 'lodash-es';
import { Block, Blocker, defineBlocker } from '../../core';
import { BlockItem, Plugin } from '../../core/plugin';
import { font } from '../font';
import { Text } from './interface';
import Option from './option.vue';
import Render from './render.vue';
import { Button } from '@arco-design/web-vue';
export default defineBlocker<Text>({
type: 'text',
icon: 'icon-park-outline-text',
title: '文本组件',
description: '文字',
render: Render,
option: Option,
initial: {
id: '',
type: 'text',
title: '',
x: 0,
y: 0,
w: 300,
h: 100,
xFixed: false,
yFixed: false,
bgImage: '',
bgColor: '',
meta: {},
actived: false,
resizable: true,
draggable: true,
params: {
marquee: false,
speed: 100,
direction: 'left',
fontCh: {
...font,
content:
'温馨提示:乘客您好,进站检票时,持票卡的乘客请在右侧闸机上方感应区内验票,扫码过闸的乘客请将乘车码对准闸机扫码口,扇门打开后依次进闸。乘车过程中请妥善保管好车票,以免丢失。',
},
},
},
});
const defaults: Text = {
id: '',
type: 'text',
title: '',
x: 0,
y: 0,
w: 300,
h: 100,
xFixed: false,
yFixed: false,
bgImage: '',
bgColor: '',
meta: {},
actived: false,
resizable: true,
draggable: true,
params: {
marquee: false,
speed: 100,
direction: 'left',
fontCh: {
...font,
content: '温馨提示:乘客您好',
},
},
};
export const item: BlockItem = {
type: 'text',
icon: 'icon-park-outline-text',
title: '文本组件',
description: '文字',
editRender: Option,
viewRender: Render,
onInit: () => {
return merge({}, defaults);
},
};
export function TextBlock(): Plugin {
const defaults = {
id: '',
type: 'text',
title: '',
x: 0,
y: 0,
w: 300,
h: 100,
xFixed: false,
yFixed: false,
bgImage: '',
bgColor: '',
meta: {},
actived: false,
resizable: true,
draggable: true,
params: {
marquee: false,
speed: 100,
direction: 'left',
fontCh: {
...font,
content: '温馨提示:乘客您好',
},
},
};
return {
name: 'TextBlockPlugin',
hrRender: {
name: 'TextDelete',
render() {
return (
<Button>
{{
icon: <i class="icon-park-outline-delete"></i>,
default: '测试',
}}
</Button>
);
},
},
hlRender: {
name: 'tip',
render() {
return <span class="text-gray-400 text-xs ml-2"></span>;
},
},
addBlockItem() {
return {
type: 'text',
icon: 'icon-park-outline-text',
title: '文本组件',
description: '文字',
onInit: () => merge({}, defaults),
editRender: Option,
viewRender: Render,
};
},
};
}

View File

@ -2,41 +2,23 @@
<a-modal v-model:visible="show" :fullscreen="true" :footer="false" class="an-editor">
<div class="w-full h-full bg-slate-100 grid grid-rows-[auto_1fr] select-none">
<div class="h-13 bg-white border-b border-slate-200 z-10">
<EditorHeader
v-model:container="container"
:saving="saving"
@preview="showPreview = true"
@config="showConfig = true"
@exit="onExit()"
@save="saveData()"
></EditorHeader>
<EditorHeader v-model:container="container" :saving="saving" @preview="showPreview = true" @config="showConfig = true" @exit="onExit()" @save="saveData()"></EditorHeader>
</div>
<div class="grid grid-cols-[auto_1fr_auto] overflow-hidden">
<div class="h-full overflow-hidden bg-white shadow-[2px_0_6px_rgba(0,0,0,.05)] z-10">
<EditorLeft @rm-block="rmBlock" @current-block="setCurrentBlock"></EditorLeft>
</div>
<div class="w-full h-full">
<EditorMain
v-model:rightPanelCollapsed="rightPanelCollapsed"
@add-block="addBlock"
@current-block="setCurrentBlock"
@block-menu="onBlockContextMenu"
></EditorMain>
<EditorMain v-model:rightPanelCollapsed="rightPanelCollapsed" @add-block="addBlock" @current-block="setCurrentBlock" @block-menu="onBlockContextMenu"></EditorMain>
</div>
<div class="h-full overflow-hidden bg-white shadow-[-2px_0_6px_rgba(0,0,0,.05)]">
<EditorRight v-model:collapsed="rightPanelCollapsed" v-model:block="currentBlock"></EditorRight>
<EditorRight v-model:collapsed="rightPanelCollapsed" v-model:block="container.current"></EditorRight>
</div>
</div>
</div>
<EditorPreview v-model:visible="showPreview" :container="container" :blocks="blocks"></EditorPreview>
<EditorPreview v-model:visible="showPreview" :container="container" :blocks="container.children"></EditorPreview>
<EditorSetting v-model:visible="showConfig" v-model="container"></EditorSetting>
<ContextMenu
v-model:visible="blockMenu.show"
:x="blockMenu.x"
:y="blockMenu.y"
:items="blockMenuItems"
@done="blockMenu.show = false"
></ContextMenu>
<ContextMenu v-model:visible="blockMenu.show" :x="blockMenu.x" :y="blockMenu.y" :items="blockMenuItems" @done="blockMenu.show = false"></ContextMenu>
</a-modal>
</template>
@ -44,7 +26,7 @@
import { delConfirm, sleep } from '@/utils';
import { Message } from '@arco-design/web-vue';
import { useVModel } from '@vueuse/core';
import { Block, ContextMenuItem, EditorKey, useEditor } from '../core';
import { Block, EditorKey, useEditor } from '../core';
import ContextMenu from './ContextMenu.vue';
import EditorSetting from './EditorConfig.vue';
import EditorHeader from './EditorHeader.vue';
@ -52,6 +34,8 @@ import EditorLeft from './EditorLeft.vue';
import EditorMain from './EditorMain.vue';
import EditorPreview from './EditorPreview.vue';
import EditorRight from './EditorRight.vue';
import { ContextKey, usePluginContext } from '../core/plugin';
import { TextBlock } from '../blocks/text';
const props = defineProps({
visible: {
@ -67,7 +51,10 @@ const showPreview = ref(false);
const showConfig = ref(false);
const saving = ref(false);
const editor = useEditor();
const { container, blocks, currentBlock, addBlock, rmBlock, setCurrentBlock } = editor;
const context = usePluginContext([TextBlock()]);
const { container, addBlock, rmBlock, setCurrentBlock } = context;
console.log({context});
const blockMenu = reactive<{ show: boolean; x: number; y: number; block: Block | null }>({
show: false,
@ -76,7 +63,7 @@ const blockMenu = reactive<{ show: boolean; x: number; y: number; block: Block |
block: null,
});
const blockMenuItems: ContextMenuItem[] = [
const blockMenuItems: any[] = [
{
name: '删除',
icon: 'icon-park-outline-delete',
@ -103,7 +90,7 @@ const onBlockContextMenu = (block: Block, e: MouseEvent) => {
const saveData = async () => {
const data = {
container: container.value,
children: blocks.value,
children: container.value.children,
};
saving.value = true;
await sleep(3000);
@ -120,7 +107,7 @@ const loadData = async () => {
}
const data = JSON.parse(str);
container.value = data.container;
blocks.value = data.children;
container.value.children = data.children;
};
const onExit = async () => {
@ -133,6 +120,7 @@ const onExit = async () => {
};
provide(EditorKey, editor);
provide(ContextKey, context);
onMounted(loadData);
</script>

View File

@ -46,9 +46,7 @@ const props = defineProps({
required: true,
},
});
const emit = defineEmits(['update:visible', 'update:modelValue']);
const show = useVModel(props, 'visible', emit);
const model = useVModel(props, 'modelValue', emit);
</script>

View File

@ -9,11 +9,13 @@
</a-link>
<a-divider :direction="'vertical'" :margin="8"></a-divider>
<ani-texter v-model="container.title"></ani-texter>
<component v-for="item in HL" :is="item" />
<!-- <a-tag :color="container.id ? 'blue' : 'green'" class="mr-2 ml-1">
{{ container.id ? '修改' : '新增' }}
</a-tag> -->
</div>
<div class="flex gap-2">
<component v-for="item in HR" :key="item.name" :is="item" />
<a-button @click="emit('preview')">
<template #icon>
<i class="icon-park-outline-play"></i>
@ -38,6 +40,7 @@
<script setup lang="ts">
import { Container } from '../core';
import { ContextKey } from '../core/plugin';
import AniTexter from './InputTexter.vue';
defineProps({
@ -48,8 +51,9 @@ defineProps({
})
const emit = defineEmits(['preview', 'config', 'exit', 'save']);
const container = defineModel<Container>('container', { required: true });
const { HR, HL } = inject(ContextKey)!
</script>
<style scoped></style>

View File

@ -1,12 +1,7 @@
<template>
<div class="h-full grid grid-cols-[auto_1fr]" :style="{ width: !collapsed ? '248px' : undefined }">
<div class="h-full grid grid-rows-[1fr_auto] border-r border-slate-200">
<a-menu
:collapsed="true"
:default-selected-keys="['0_0']"
:selected-keys="[key]"
@menu-item-click="(k) => (key = k)"
>
<a-menu :collapsed="true" :default-selected-keys="['0_0']" :selected-keys="[key]" @menu-item-click="k => (key = k)">
<a-menu-item key="list">
<template #icon>
<i class="icon-park-outline-all-application"></i>
@ -31,10 +26,7 @@
<a-tooltip :content="collapsed ? '展开' : '折叠'" position="right">
<a-button type="text" @click="collapsed = !collapsed">
<template #icon>
<i
class="text-lg text-gray-400 hover:text-gray-700"
:class="collapsed ? 'icon-park-outline-expand-left' : 'icon-park-outline-expand-right'"
></i>
<i class="text-lg text-gray-400 hover:text-gray-700" :class="collapsed ? 'icon-park-outline-expand-left' : 'icon-park-outline-expand-right'"></i>
</template>
</a-button>
</a-tooltip>
@ -61,13 +53,13 @@
</ul>
<ul v-show="key === 'data'" class="list-none px-2 grid gap-2">
<li
v-for="item in blocks"
v-for="item in container.children"
:key="item.id"
class="group h-8 w-full overflow-hidden grid grid-cols-[auto_1fr_auto] items-center gap-2 bg-gray-100 text-gray-500 px-2 py-1 rounded border border-transparent"
:class="{
'!bg-brand-50': currentBlock === item,
'!text-brand-500': currentBlock === item,
'!border-brand-300': currentBlock === item,
'!bg-brand-50': container.current === item,
'!text-brand-500': container.current === item,
'!border-brand-300': container.current === item,
}"
@click="emit('current-block', item)"
>
@ -78,10 +70,7 @@
{{ item.title }}
</div>
<div class="w-4">
<i
class="!hidden !group-hover:inline-block text-gray-400 hover:text-gray-700 icon-park-outline-delete !text-xs"
@click.prevent="emit('rm-block', item)"
></i>
<i class="!hidden !group-hover:inline-block text-gray-400 hover:text-gray-700 icon-park-outline-delete !text-xs" @click.prevent="emit('rm-block', item)"></i>
</div>
</li>
</ul>
@ -90,24 +79,24 @@
</template>
<script setup lang="ts">
import { getIcon } from "../blocks";
import { Block, EditorKey } from "../core";
import { getIcon } from '../blocks';
import { Block, EditorKey } from '../core';
const { blocks, currentBlock, BlockerMap } = inject(EditorKey)!;
const { container, BlockerMap } = inject(EditorKey)!;
const blockList = Object.values(BlockerMap);
const collapsed = ref(false);
const key = ref<"list" | "data">("list");
const key = ref<'list' | 'data'>('list');
const emit = defineEmits<{
(event: "rm-block", block: Block): void;
(event: "current-block", block: Block | null): void;
(event: 'rm-block', block: Block): void;
(event: 'current-block', block: Block | null): void;
}>();
/**
* 拖拽开始时设置数据
*/
const onDragStart = (e: DragEvent) => {
e.dataTransfer?.setData("type", (e.target as HTMLElement).dataset.type!);
e.dataTransfer?.setData('type', (e.target as HTMLElement).dataset.type!);
};
</script>

View File

@ -1,27 +1,13 @@
<template>
<div class="h-full grid grid-rows-[auto_1fr]">
<div class="h-10">
<EditorMainHeader
:container="container"
v-model:rightPanelCollapsed="rightPanelCollapsed"
@preview="emit('preview')"
></EditorMainHeader>
<EditorMainHeader :container="container" v-model:rightPanelCollapsed="rightPanelCollapsed" @preview="emit('preview')"></EditorMainHeader>
</div>
<div class="h-full w-full overflow-hidden p-4">
<div
class="juetan-editor-container w-full h-full flex items-center justify-center overflow-hidden relative bg-slate-50"
>
<div
class="relative"
:style="containerStyle"
@dragover.prevent
@click="onClick"
@drop="onDragDrop"
@wheel="onMouseWheel"
@mousedown="onMouseDown"
>
<div class="juetan-editor-container w-full h-full flex items-center justify-center overflow-hidden relative bg-slate-50">
<div class="relative" :style="containerStyle" @dragover.prevent @click="onClick" @drop="onDragDrop" @wheel="onMouseWheel" @mousedown="onMouseDown">
<EditorMainBlock
v-for="block in blocks"
v-for="block in container.children"
:key="block.id"
:data="block"
:container="container"
@ -60,12 +46,13 @@
</template>
<script setup lang="ts">
import { Block, EditorKey } from '../core';
import { Block, EditorKey, formatContainerStyle } from '../core';
import { ContextKey } from '../core/plugin';
import EditorMainBlock from './EditorMainBlock.vue';
import EditorMainHeader from './EditorMainHeader.vue';
const rightPanelCollapsed = defineModel<boolean>('rightPanelCollapsed');
const { blocks, container, refLine, formatContainerStyle, scene } = inject(EditorKey)!;
const { container, refLine, scene } = inject(ContextKey)!;
const { onMouseDown, onMouseWheel } = scene;
const { active, xLines, yLines } = refLine;
@ -107,14 +94,7 @@ const onDragDrop = (e: DragEvent) => {
<style scoped>
.juetan-editor-container {
--color: rgba(0, 0, 0, 0.2);
background: linear-gradient(
45deg,
var(--color) 25%,
transparent 25%,
transparent 75%,
var(--color) 75%,
var(--color) 100%
),
background: linear-gradient(45deg, var(--color) 25%, transparent 25%, transparent 75%, var(--color) 75%, var(--color) 100%),
linear-gradient(45deg, var(--color) 25%, transparent 25%, transparent 75%, var(--color) 75%, var(--color) 100%);
background-size: 20px 20px;
background-position: 0 0, 10px 10px;

View File

@ -28,7 +28,8 @@
import { PropType } from "vue";
import { BlockerMap } from "../blocks";
import DragResizer from "./DragResizer.vue";
import { Block, Container, EditorKey } from "../core";
import { Block, Container } from "../core";
import { ContextKey } from "../core/plugin";
const props = defineProps({
data: {
@ -41,7 +42,7 @@ const props = defineProps({
},
});
const { setCurrentBlock, refLine } = inject(EditorKey)!;
const { setCurrentBlock, refLine } = inject(ContextKey)!;
const { active, recordBlocksXY, updateRefLine } = refLine;
/**

View File

@ -21,7 +21,7 @@
</span>
<span class="text-gray-400 text-xs mr-2">
组件
<span class="inline-block w-8 text-gray-700">{{ blocks.length }} </span>
<span class="inline-block w-8 text-gray-700">{{ container.children.length }} </span>
</span>
<a-tooltip content="自适应比例" position="bottom">
<a-button type="text" @click="setContainerOrigin">
@ -62,8 +62,8 @@
<script setup lang="ts">
import InputTexter from './InputTexter.vue';
// import EditorMainConfig from './EditorMainConfig.vue';
import { EditorKey } from '../core';
import { useVModel } from '@vueuse/core';
import { ContextKey } from '../core/plugin';
const props = defineProps({
rightPanelCollapsed: {
@ -74,7 +74,7 @@ const props = defineProps({
const emit = defineEmits(['preview', 'update:rightPanelCollapsed']);
const collapsed = useVModel(props, 'rightPanelCollapsed', emit);
const { container, blocks, setContainerOrigin } = inject(EditorKey)!;
const { container, setContainerOrigin } = inject(ContextKey)!;
const visible = ref(false);
</script>

View File

@ -5,7 +5,7 @@
<template #icon>
<i :class="BlockerMap[model.type].icon"></i>
</template>
{{ BlockerMap[model.type].title }}属性
{{ BlockerMap[model.type].title }}
</a-tag>
<a-scrollbar outer-class="h-full overflow-hidden" class="h-full overflow-auto">
<a-form :model="{}" layout="vertical" class="pr-3">
@ -23,12 +23,13 @@
<script setup lang="ts">
import { BlockerMap } from '../blocks';
import { Block, EditorKey } from '../core';
import { Block } from '../core';
import { ContextKey } from '../core/plugin';
import EditorSetting from './EditorSetting.vue';
const collapsed = defineModel<boolean>('collapsed');
const model = defineModel<Block | null>('block');
const { container } = inject(EditorKey)!;
const { container } = inject(ContextKey)!;
</script>
<style scoped></style>

View File

@ -1,6 +1,6 @@
<template>
<div class="p-3">
<a-tag class="text-sm! mb-2 w-full" size="large" color="blue" :bordered="false">
<a-tag class="text-sm! mb-2 w-full" size="large" color="red" :bordered="false">
<template #icon>
<i class="icon-park-outline-config" ></i>
</template>

View File

@ -1,4 +1,4 @@
import { Component } from "vue";
import { Component } from 'vue';
/**
*
@ -70,25 +70,11 @@ export interface Block<T = any> {
params: T;
}
export interface ContextMenuItem {
type?: 'divider' | 'menu'
showChildren?: boolean
onClick?: (item: ContextMenuItem) => void;
icon?: Component | string
name: string
tip?: string
class?: string;
children?: ContextMenuItem[]
}
export const useBlockContextMenu = (blocks: Block[]) => {
const items: ContextMenuItem[] = [
{
name: '删除',
icon: () => h('i', { class: 'icon-park-outline-delete' }),
onClick(item) {
},
}
]
export function formatBlockStyle(block: Block) {
const { bgColor, bgImage } = block;
return {
backgroundColor: bgColor,
backgroundImage: bgImage ? `url(${bgImage})` : null,
backgroundSize: '100% 100%',
};
}

View File

@ -1,3 +1,6 @@
import { CSSProperties } from 'vue';
import { Block } from './block';
/**
*
*/
@ -42,8 +45,22 @@ export interface Container {
*
*/
bgColor: string;
/**
* 使
*/
langList: string[];
/**
*
*/
langSwitch: number;
/**
*
*/
children: Block[];
/**
*
*/
current: Block | null;
}
/**
@ -51,15 +68,30 @@ export interface Container {
*/
export const defaultContainer: Container = {
id: 11,
title: "国庆节喜庆版式设计",
description: "适用于国庆节1日-7日间上午9:00-10:00播出的版式设计",
title: '国庆节喜庆版式设计',
description: '适用于国庆节1日-7日间上午9:00-10:00播出的版式设计',
x: 0,
y: 0,
zoom: 0.7,
width: 1920,
height: 1080,
bgImage: "",
bgColor: "#ffffff",
bgImage: '',
bgColor: '#ffffff',
langList: ['ch', 'en'],
langSwitch: 0
langSwitch: 0,
children: [],
current: null,
};
export function formatContainerStyle(container: Container) {
const { width, height, bgColor, bgImage, zoom, x, y } = container;
return {
position: 'absolute',
width: `${width}px`,
height: `${height}px`,
backgroundColor: bgColor,
backgroundImage: bgImage ? `url(${bgImage})` : null,
backgroundSize: '100% 100%',
transform: `translate3d(${x}px, ${y}px, 0) scale(${zoom})`,
} as CSSProperties;
}

View File

@ -1,20 +1,16 @@
import { Container, defaultContainer } from "./container";
import { Block } from "./block";
import { useReferenceLine } from "./ref-line";
import { BlockerMap } from "../blocks";
import { cloneDeep } from "lodash-es";
import { CSSProperties, InjectionKey } from "vue";
import { useScene } from "./scene";
import { Container, defaultContainer } from './container';
import { Block } from './block';
import { useReferenceLine } from './ref-line';
import { BlockerMap } from '../blocks';
import { cloneDeep } from 'lodash-es';
import { CSSProperties, InjectionKey } from 'vue';
import { useScene } from './scene';
export const useEditor = () => {
/**
*
*/
const container = ref<Container>({ ...defaultContainer });
/**
*
*/
const blocks = ref<Block[]>([]);
/**
*
*/
@ -22,7 +18,7 @@ export const useEditor = () => {
/**
* 线
*/
const refLine = useReferenceLine(blocks, currentBlock);
const refLine = useReferenceLine(container);
/**
*
*/
@ -43,17 +39,11 @@ export const useEditor = () => {
if (!blocker) {
return;
}
const ids = blocks.value.map((i) => Number(i.id));
const ids = container.value.children.map(i => Number(i.id));
const maxId = ids.length ? Math.max.apply(null, ids) : 0;
const id = (maxId + 1).toString();
const title = `${blocker.title}${id}`;
blocks.value.push({
...cloneDeep(blocker.initial),
id,
x,
y,
title,
});
container.value.children.push({ ...cloneDeep(blocker.initial), id, x, y, title });
};
/**
@ -61,9 +51,9 @@ export const useEditor = () => {
* @param block
*/
const rmBlock = (block: Block) => {
const index = blocks.value.indexOf(block);
const index = container.value.children.indexOf(block);
if (index > -1) {
blocks.value.splice(index, 1);
container.value.children.splice(index, 1);
}
};
@ -77,7 +67,7 @@ export const useEditor = () => {
return {
backgroundColor: bgColor,
backgroundImage: bgImage ? `url(${bgImage})` : undefined,
backgroundSize: "100% 100%",
backgroundSize: '100% 100%',
};
};
@ -89,12 +79,12 @@ export const useEditor = () => {
const formatContainerStyle = (container: Container) => {
const { width, height, bgColor, bgImage, zoom, x, y } = container;
return {
position: "absolute",
position: 'absolute',
width: `${width}px`,
height: `${height}px`,
backgroundColor: bgColor,
backgroundImage: bgImage ? `url(${bgImage})` : undefined,
backgroundSize: "100% 100%",
backgroundSize: '100% 100%',
transform: `translate3d(${x}px, ${y}px, 0) scale(${zoom})`,
} as CSSProperties;
};
@ -104,7 +94,7 @@ export const useEditor = () => {
* @param block
*/
const setCurrentBlock = (block: Block | null) => {
for (const item of blocks.value) {
for (const item of container.value.children) {
item.actived = false;
}
if (!block) {
@ -121,7 +111,7 @@ export const useEditor = () => {
const setContainerOrigin = () => {
container.value.x = 0;
container.value.y = 0;
const el = document.querySelector(".juetan-editor-container");
const el = document.querySelector('.juetan-editor-container');
if (el) {
const { width, height } = el.getBoundingClientRect();
const wZoom = width / container.value.width;
@ -133,7 +123,6 @@ export const useEditor = () => {
return {
container,
blocks,
currentBlock,
refLine,
scene,
@ -147,4 +136,4 @@ export const useEditor = () => {
};
};
export const EditorKey = Symbol("EditorKey") as InjectionKey<ReturnType<typeof useEditor>>;
export const EditorKey = Symbol('EditorKey') as InjectionKey<ReturnType<typeof useEditor>>;

View File

@ -0,0 +1,255 @@
import { CSSProperties, Component } from 'vue';
import { Block } from './block';
import { Container, defaultContainer } from './container';
import { cloneDeep } from 'lodash-es';
import { useReferenceLine } from './ref-line';
import { useScene } from './scene';
export interface BlockItem {
/**
*
*/
type: string;
/**
*
*/
icon: string;
/**
*
*/
title: string;
/**
*
*/
description: string;
/**
*
*/
pickRender?: any;
/**
*
*/
listRender?: any;
/**
*
*/
showRender?: any;
/**
*
*/
viewRender?: any;
/**
*
*/
editRender?: any;
/**
*
*/
onInit?: any;
/**
*
*/
onLoad?: any;
/**
*
*/
onSave?: any;
}
interface SortableRender {
name: string;
sort?: number;
render: Component;
}
export interface Plugin {
/**
*
*/
name: string;
/**
* name
*/
hlRender?: SortableRender;
/**
* name
*/
hrRender?: SortableRender;
/**
* name
*/
ltRender?: SortableRender;
/**
* name
*/
lbRender?: SortableRender;
/**
* name
*/
mlRender?: SortableRender;
/**
* name
*/
mrRender?: SortableRender;
/**
* name
*/
rtRender?: SortableRender;
rbRender?: () => BlockItem;
addBlockItem?: () => BlockItem;
blockItems?: BlockItem | BlockItem[];
onInit?: (context: any) => void;
onSave?: () => void;
onLoad?: (data: any) => void;
}
export const usePluginContext = (pluginlist: Plugin[]) => {
const container: Ref<Container> = ref(cloneDeep(defaultContainer)) as any;
const blocks = computed(() => container.value.children);
const blockerMap: Record<string, BlockItem> = {};
const refLine = useReferenceLine(container);
const scene = useScene(container);
/** 顶部栏左侧 */
const HL: Ref<SortableRender[]> = ref([]);
/** 顶部栏右侧 */
const HR: Ref<SortableRender[]> = ref([]);
/** 左侧栏顶部 */
const LC: Ref<SortableRender[]> = ref([]);
/** 左侧栏底部 */
const LB: Ref<SortableRender[]> = ref([]);
/** 中间栏左侧 */
const ML: Ref<SortableRender[]> = ref([]);
/** 中间栏右侧 */
const MR: Ref<SortableRender[]> = ref([]);
/** 右侧栏顶部 */
const RC: Ref<SortableRender[]> = ref([]);
function load(data: any) {
data.children = data.children.map(item => {
return blockerMap[item.type]?.onLoad?.(item) ?? item;
});
for (const plugin of pluginlist) {
data = plugin.onLoad?.(data) ?? data;
}
return data;
}
function save(container: Container) {}
function addBlock(type: string, x = 0, y = 0) {
const blocker = blockerMap[type];
if (!type) {
return;
}
if (!blocker) {
return;
}
const ids = blocks.value.map(i => Number(i.id));
const maxId = ids.length ? Math.max.apply(null, ids) : 0;
const id = (maxId + 1).toString();
const title = `${blocker.title}${id}`;
const block = { ...cloneDeep(blocker.onInit?.()), id, x, y, title };
blocks.value.push(block);
}
function rmBlock(block: Block) {
const index = blocks.value.indexOf(block);
if (index > -1) {
blocks.value.splice(index, 1);
}
}
function setCurrentBlock(block: Block | null) {
for (const item of container.value.children) {
item.actived = false;
}
if (!block) {
container.value.current = null;
} else {
block.actived = true;
container.value.current = block;
}
}
function setContainerOrigin() {
container.value.x = 0;
container.value.y = 0;
const el = document.querySelector('.juetan-editor-container');
if (el) {
const { width, height } = el.getBoundingClientRect();
const wZoom = width / container.value.width;
const hZoom = height / container.value.width;
const zoom = Math.floor((wZoom > hZoom ? wZoom : hZoom) * 10000) / 10000;
container.value.zoom = zoom;
}
}
const context = {
container,
blockerMap,
refLine,
scene,
HL,
HR,
LB,
LC,
ML,
MR,
RC,
setCurrentBlock,
setContainerOrigin,
addBlock,
rmBlock,
};
function addRender(list: any[], render?: SortableRender) {
if (!render) {
return;
}
if (list.some(i => i.name === render.name)) {
console.log('name has existed');
return;
}
list.push(render);
}
for (const plugin of pluginlist) {
plugin.onInit?.(context);
addRender(HL.value, plugin.hlRender);
addRender(HR.value, plugin.hrRender);
addRender(LC.value, plugin.ltRender);
addRender(LB.value, plugin.lbRender);
addRender(ML.value, plugin.mlRender);
addRender(MR.value, plugin.mrRender);
addRender(RC.value, plugin.rtRender);
const bi = plugin.addBlockItem?.();
if (bi) {
blockerMap[bi.type] = bi;
}
}
HL.value.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
HR.value.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
LC.value.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
LB.value.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
ML.value.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
MR.value.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
RC.value.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
return context;
};
export const ContextKey = Symbol('ContextKey') as InjectionKey<ReturnType<typeof usePluginContext>>;
function corePlugin(): Plugin {
return {
name: 'core',
rtRender: {
name: 'ss',
render() {
return () => 123;
},
},
};
}

View File

@ -1,6 +1,7 @@
import { Ref } from "vue";
import { getClosestValInSortedArr } from "../utils/closest";
import { Block } from "./block";
import { Container } from "./container";
/**
* 线
@ -8,7 +9,7 @@ import { Block } from "./block";
* @param current
* @returns
*/
export const useReferenceLine = (blocks: Ref<Block[]>, current: Ref<Block | null>) => {
export const useReferenceLine = (container: Ref<Container>) => {
let xYsMap = new Map<number, number[]>();
let yXsMap = new Map<number, number[]>();
let sortedXs: number[] = [];
@ -22,8 +23,8 @@ export const useReferenceLine = (blocks: Ref<Block[]>, current: Ref<Block | null
*/
const recordBlocksXY = () => {
clear();
for (const block of blocks.value) {
if (block === current.value) {
for (const block of container.value.children) {
if (block === container.value.current) {
continue;
}
const { minX, minY, midX, midY, maxX, maxY } = getBlockBox(block);

View File

@ -0,0 +1,6 @@
export function arraify<T>(data: T | T[]): T[] {
if (!data) {
return [];
}
return Array.isArray(data) ? data : [data];
}

View File

@ -4,6 +4,16 @@ import localData from 'dayjs/plugin/localeData';
import relativeTime from 'dayjs/plugin/relativeTime';
import { App } from 'vue';
declare module 'dayjs' {
export var DATETIME: 'YYYY-MM-DD HH:mm';
export var DATE: 'YYYY-MM-DD';
export var TIME: 'HH:mm:ss';
export var install: (app: App) => void;
interface Dayjs {
_format: Dayjs['format'];
}
}
/**
*
*/

View File

@ -1,19 +0,0 @@
import 'dayjs';
import { App } from 'vue';
declare module 'dayjs' {
/**
*
*/
export var DATETIME: 'YYYY-MM-DD HH:mm';
export var DATE: 'YYYY-MM-DD';
export var TIME: 'HH:mm:ss';
interface Dayjs {
_format: Dayjs['format'];
}
export var install: (app: App) => void;
}

View File

@ -9,7 +9,7 @@
</a-button> -->
<router-link to="/" class="px-2 flex items-center gap-2 text-slate-700">
<img src="/favicon.ico" alt="" width="24" height="24" class="" />
<h1 class="relative text-[18px] leading-[22px] dark:text-white m-0 p-0 font-semibold">
<h1 class="relative text-[18px] leading-[22px] dark:text-white m-0 p-0 font-normal">
{{ appStore.title }}
<span class="absolute -right-10 -top-1 font-normal text-xs text-gray-400"> v0.0.1 </span>
</h1>
@ -55,13 +55,14 @@
</a-layout-sider>
<a-layout class="layout-content flex-1">
<a-layout-content class="overflow-x-auto">
<a-spin :loading="appStore.pageLoding" tip="页面加载中,请稍等..." class="block h-full w-full">
<a-spin :loading="appStore.pageLoding" class="block h-full w-full">
<template #icon>
<IconSync></IconSync>
<div class="loader"></div>
</template>
<router-view v-slot="{ Component }">
<keep-alive :include="menuStore.caches">
<component :is="Component"></component>
<component v-if="hasAuth" :is="Component"></component>
<AnForbidden v-else></AnForbidden>
</keep-alive>
</router-view>
</a-spin>
@ -75,20 +76,44 @@
import { useAppStore } from '@/store/app';
import { useMenuStore } from '@/store/menu';
import { Message } from '@arco-design/web-vue';
import { IconSync } from '@arco-design/web-vue/es/icon';
import { useFullscreen } from '@vueuse/core';
import Menu from './Menu.vue';
import userDropdown from './UserDropdown.vue';
import { useUserStore } from '@/store/user';
defineOptions({ name: 'LayoutPage' });
const route = useRoute();
const appStore = useAppStore();
const menuStore = useMenuStore();
const userStore = useUserStore();
const isCollapsed = ref(false);
const themeConfig = ref({ visible: false });
const { toggle, isSupported } = useFullscreen();
const hasAuth = computed(() => {
return route.matched.every(item => {
const needAuth = item.meta.auth;
const userAuth = userStore.auth;
if (needAuth?.includes('*')) {
return true;
}
if (!userStore.accessToken && needAuth?.includes('unlogin')) {
return true;
}
if (!userStore.accessToken) {
return false;
}
if (!needAuth) {
return true;
}
if (userAuth.some(i => needAuth.some(j => j === i))) {
return true;
}
return false;
});
});
const buttons = [
{
icon: 'icon-park-outline-remind',
@ -187,6 +212,30 @@ const buttons = [
background-color: #e4ebf1;
transition: padding 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
}
/* HTML: <div class="loader"></div> */
.loader {
width: 120px;
height: 16px;
border-radius: 20px;
color: #222;
border: 2px solid;
position: relative;
}
.loader::before {
content: '';
position: absolute;
margin: 2px;
inset: 0 100% 0 0;
border-radius: inherit;
background: currentColor;
animation: l6 2s infinite;
}
@keyframes l6 {
100% {
inset: 0;
}
}
</style>
<route lang="json">

View File

@ -18,7 +18,7 @@ const { component: CategoryTable } = useTable({
<div class="flex flex-col overflow-hidden">
<span>
{record.name}
<span class="text-gray-400 text-xs truncate ml-2">@{record.code}</span>
<span class="text-orange-500 truncate ml-2">@{record.code}</span>
</span>
<div class="text-gray-400 text-xs truncate mt-0.5">{record.description}</div>
</div>

View File

@ -1,6 +1,10 @@
<template>
<bread-page>
<a-form :model="{}" :label-col-props="{ span: 3 }" label-align="left" layout="vertical" class="space-y-6">
<div>
<h2 class="m-0 text-base">常规设置</h2>
<p class="text-gray-500 mt-2">首次为你的帐户添加密码时你需要前往密码重置页面以便我们验证你的身份</p>
</div>
<a-form :model="{}" :label-col-props="{ span: 3 }" label-align="left" layout="vertical" class="space-y-6 mt-4 col-form">
<a-form-item label="站点LOGO">
<a-avatar :size="64">
<img :src="appStore.logo" alt="" />

View File

@ -1,21 +1,17 @@
<template>
<bread-page>
<div class="flex">
<a-form
:model="{}"
:label-col-props="{ span: 3 }"
:disabled="!mail.enable"
layout="vertical"
label-align="left"
class="w-[580px]! space-y-6"
>
<div>
<div>
<h2 class="m-0 text-base">邮件设置</h2>
<p class="text-gray-500 mt-1.5">首次为你的帐户添加密码时你需要前往密码重置页面以便我们验证你的身份</p>
</div>
<a-form :model="{}" :label-col-props="{ span: 3 }" :disabled="!mail.enable" layout="vertical" label-align="left" class="col-form divide-y space-y-6">
<a-form-item label="是否启用" :disabled="false">
<a-radio-group v-model="mail.enable">
<a-radio :value="true">启用</a-radio>
<a-radio :value="false">禁用</a-radio>
</a-radio-group>
<a-switch v-model="mail.enable"> </a-switch>
<template #help> 启用后其他服务可发送邮件通知 </template>
</a-form-item>
<a-form-item label="服务器和端口">
<a-form-item label="服务器地址和端口" class="pt-6">
<a-input v-model="mail.smtpHost" allow-clear placeholder="请输入" class="!w-[314px]"></a-input>
<span class="inline-block px-2">:</span>
<a-input-number v-model="mail.smtpPort" :min="0" :max="65535" class="w-24!"></a-input-number>
@ -26,42 +22,31 @@
<a target="_blank" class="mr-2" href="https://mail.qq.com">QQ邮箱</a>
</template>
</a-form-item>
<a-form-item label="发信人地址">
<a-form-item label="发信人地址" class="pt-6">
<a-input v-model="mail.smtpFrom" placeholder="请输入" class="!w-[432px]"></a-input>
<template #help> 示例: example@mail.com仅作为发送邮件时的发送人标识与登陆无关</template>
</a-form-item>
<a-form-item label="是否需要验证">
<a-radio-group v-model="mail.smtpAuth">
<a-radio :value="true"></a-radio>
<a-radio :value="false"></a-radio>
</a-radio-group>
<a-form-item label="是否需要验证" class="pt-6">
<a-switch v-model="mail.smtpAuth"> </a-switch>
<template #help> 可选 </template>
</a-form-item>
<a-form-item label="验证账号">
<a-input
:disabled="!mail.enable || !mail.smtpAuth"
v-model="mail.smtpUser"
placeholder="请输入"
class="!w-[432px]"
></a-input>
<a-form-item label="验证账号" class="pt-6">
<a-input :disabled="!mail.enable || !mail.smtpAuth" v-model="mail.smtpUser" placeholder="请输入" class="!w-[432px]"></a-input>
<template #help> 示例: example@mail.com企业邮箱请使用企业域名后缀</template>
</a-form-item>
<a-form-item label="验证密码">
<a-input
:disabled="!mail.enable || !mail.smtpAuth"
v-model="mail.smtpPass"
placeholder="请输入"
class="!w-[432px]"
></a-input>
<a-form-item label="验证密码" class="pt-6">
<a-input :disabled="!mail.enable || !mail.smtpAuth" v-model="mail.smtpPass" placeholder="请输入" class="!w-[432px]"></a-input>
<template #help> 示例AATOLARFABJKYWUY具体请在对应邮箱设置面板进行生成 </template>
</a-form-item>
<a-form-item :disabled="false">
<a-form-item :disabled="false" class="pt-2">
<a-button type="primary"> 保存修改 </a-button>
</a-form-item>
</a-form>
</div>
<a-divider direction="vertical" :margin="32"></a-divider>
<div class="flex-1">
<div>
<div class="text-base font-semibold">配置测试</div>
<div class="text-base font-semibold">邮件测试</div>
<div class="text-gray-400 mt-1">发送一封测试邮件检测邮件设置是否能正常工作</div>
<div class="mt-6">
<a-input placeholder="接收人邮箱" class="w-[432px]!"></a-input>

View File

@ -38,7 +38,7 @@ const usernameRender: TableColumnRender = ({ record }) => (
<div class="w-full flex-1 overflow-hidden">
<div>
<span class="cursor-pointer hover:text-brand-500">{record.nickname}</span>
<span class="text-gray-400 text-xs truncate ml-2">@{record.username}</span>
<span class="text-orange-500 truncate ml-2">@{record.username}</span>
</div>
<div class="w-full text-gray-400 space-x-4 text-xs">
<span>

View File

@ -69,7 +69,7 @@ body {
margin-top: 8px;
}
[class^="icon-"] {
font-size: 16px;
font-size: 18px;
vertical-align: -2px;
}
.arco-menu-item {
@ -159,3 +159,16 @@ body {
border-color: var(--color-neutral-2);
}
}
.col-form {
.arco-form-item-wrapper-col {
flex-direction: row;
}
.arco-form-item-content-wrapper {
width: 450px;
}
.arco-form-item-message {
font-size: 14px;
}
}

View File

@ -9,7 +9,6 @@ declare module 'vue' {
export interface GlobalComponents {
AAutoComplete: typeof import('@arco-design/web-vue')['AutoComplete']
AAvatar: typeof import('@arco-design/web-vue')['Avatar']
ABadge: typeof import('@arco-design/web-vue')['Badge']
ABreadcrumb: typeof import('@arco-design/web-vue')['Breadcrumb']
ABreadcrumbItem: typeof import('@arco-design/web-vue')['BreadcrumbItem']
AButton: typeof import('@arco-design/web-vue')['Button']