Compare commits

...

4 Commits

Author SHA1 Message Date
绝弹 f23f8f53e6 feat: 优化路由逻辑
自动部署 / build (push) Successful in 1m27s Details
2023-11-07 21:12:26 +08:00
luoer b11d43a0a6 feat: 优化路由逻辑 2023-11-07 17:49:42 +08:00
luoer c648519d42 feat: 重构编辑器目录 2023-11-07 11:48:51 +08:00
luoer 497b1a3dd4 feat: 优化编辑器数据传递方式 2023-11-06 11:54:50 +08:00
72 changed files with 5926 additions and 1028 deletions

10
.env
View File

@ -5,8 +5,12 @@
VITE_TITLE = 绝弹项目管理 VITE_TITLE = 绝弹项目管理
# 网站副标题 # 网站副标题
VITE_SUBTITLE = 快速开发web应用的模板工具 VITE_SUBTITLE = 快速开发web应用的模板工具
# 接口前缀 说明:参见 axios 的 baseURL # 接口前缀:参见 axios 的 baseURL
VITE_API = / VITE_API = /
# 首页路径
VITE_HOME_PATH = /home/home
# 路由模式web(路径) hash(锚点)
VITE_HISTORY = web
# ===================================================================================== # =====================================================================================
# 开发设置 # 开发设置
@ -19,9 +23,5 @@ VITE_PORT = 3020
VITE_PROXY = http://127.0.0.1:3030/ VITE_PROXY = http://127.0.0.1:3030/
# API文档 说明:需返回符合 OPENAPI 规范的json内容 # API文档 说明:需返回符合 OPENAPI 规范的json内容
VITE_OPENAPI = http://127.0.0.1:3030/openapi.json VITE_OPENAPI = http://127.0.0.1:3030/openapi.json
# =====================================================================================
# 构建设置
# =====================================================================================
# 文件后缀 说明设为dev时会优先加载index.dev.vue文件否则回退至index.vue文件 # 文件后缀 说明设为dev时会优先加载index.dev.vue文件否则回退至index.vue文件
VITE_EXTENSION = dev VITE_EXTENSION = dev

4838
.gitea/stat.html Normal file

File diff suppressed because one or more lines are too long

View File

@ -41,7 +41,7 @@
margin: 0; margin: 0;
margin-top: 20px; margin-top: 20px;
font-size: 22px; font-size: 22px;
font-weight: 300; font-weight: 400;
line-height: 1; line-height: 1;
} }
.loading-tip { .loading-tip {

View File

@ -1,32 +1,139 @@
/**
*
* @see "src/api/instance/useRequest.ts"
*/
export function useRequest<T extends PromiseFn, E = unknown>(fn: T, options: Options<T> = {}) {
type Data = Awaited<ReturnType<T>>;
type Args = Parameters<T>;
const { initialParams, initialData, retry = 0, retryDelay = 0, interval = 0 } = options;
const { onBefore, onSuccess, onError, onFinally } = options;
/**
*
*/
const data = ref<Data | null>(initialData ?? null);
/**
*
*/
const error = ref<E | null>(null);
/**
*
*/
const loading = ref(false);
let isCanceled = false;
let retryCount = 0;
let retryTimer = 0;
let interTimer = 0;
let latestArgs = initialParams ?? [];
const _send = async (...args: Args) => {
try {
onBefore?.(args);
loading.value = true;
const res = await fn(...args);
retryCount = 0;
if (isCanceled) {
return [];
}
onSuccess?.(res);
data.value = res;
error.value = null;
} catch (err: any) {
if (isCanceled) {
return [];
}
onError?.(err);
data.value = null;
error.value = err;
if (retry > 0 && retryCount < retry) {
retryCount++;
retryTimer = setTimeout(() => _send(...args), retryDelay) as any;
}
} finally {
loading.value = false;
if (isCanceled) {
return [];
}
onFinally?.();
if (!retryCount && interval > 0) {
interTimer = setTimeout(() => _send(...args), interval) as any;
}
}
return [error.value, data.value];
};
const clearAllTimer = () => {
clearTimeout(retryTimer);
clearTimeout(interTimer);
};
/**
*
*/
const cancel = () => {
isCanceled = true;
clearAllTimer();
};
/**
*
*/
const send = (...args: Args) => {
isCanceled = false;
retryCount = 0;
latestArgs = args;
clearAllTimer();
return _send(...args);
};
onMounted(() => initialParams && send(...initialParams));
onUnmounted(cancel);
return {
data,
error,
loading,
send,
cancel,
};
}
type PromiseFn = (...args: any[]) => Promise<any>; type PromiseFn = (...args: any[]) => Promise<any>;
type Options<T extends PromiseFn = PromiseFn> = { interface Options<T extends PromiseFn = PromiseFn> {
/**
*
* @description
*/
initialParams?: Parameters<T>;
/**
*
* @description
*/
initialData?: Awaited<ReturnType<T>>;
/** /**
* loading * loading
* @default false
*/ */
toast?: boolean | string; toast?: boolean | string;
/**
*
*/
initialParams?: boolean | Parameters<T>;
/**
*
*/
initialData?: Partial<Awaited<ReturnType<T>>>;
/** /**
* *
* @default 0
*/ */
retry?: number; retry?: number;
/** /**
* (ms) * (ms)
* @default 0
*/ */
retryDelay?: number; retryDelay?: number;
/** /**
* (ms) * (ms)
* @default 0
*/ */
interval?: number; interval?: number;
/** /**
* *
*/ */
onBefore?: (args: Parameters<T>) => void; onBefore?: (args: Parameters<T>) => void;
/** /**
@ -41,139 +148,4 @@ type Options<T extends PromiseFn = PromiseFn> = {
* *
*/ */
onFinally?: () => void; onFinally?: () => void;
};
type State<T extends PromiseFn = PromiseFn, D = Awaited<ReturnType<T>>> = {
/**
*
*/
data: D | undefined;
/**
*
*/
error: unknown;
/**
*
*/
loading: boolean;
/**
*
*/
send: (...args: Parameters<T>) => Promise<[unknown, undefined] | [undefined, D]>;
/**
*
*/
cancel: () => void;
};
const log = (...args: any[]) => {
if (process.env.NODE_ENV === "development") {
console.log(...args);
}
};
/**
*
* @see src/api/instance/useRequest.ts
*/
export function useRequest<T extends PromiseFn>(fn: T, options: Options<T> = {}) {
const {
initialParams,
retry,
retryDelay = 0,
interval,
initialData,
onBefore,
onSuccess,
onError,
onFinally,
} = options;
const state = reactive<State<T>>({
data: initialData,
error: null,
loading: false,
send: null,
cancel: null,
} as any);
const inner = {
canceled: false,
retryCount: 0,
retryTimer: 0 as any,
intervalTimer: 0 as any,
latestParams: (initialParams || []) as any,
clearAllTimer: () => {
inner.retryTimer && clearTimeout(inner.retryTimer);
inner.intervalTimer && clearTimeout(inner.intervalTimer);
},
};
const _send: any = async (...args: Parameters<T>) => {
let data;
let error;
inner.retryCount && log(`retry: ${inner.retryCount}`);
try {
state.loading = true;
onBefore?.(args);
const res = await fn(...args);
inner.retryCount = 0;
if (!inner.canceled) {
onSuccess?.(res.data);
data = res.data;
}
} catch (err) {
if (!inner.canceled) {
error = err;
onError?.(err);
if (retry && retry > 0 && inner.retryCount < retry) {
inner.retryCount++;
inner.retryTimer = setTimeout(() => {
_send(...args);
}, retryDelay);
}
}
} finally {
log("finally");
state.loading = false;
state.error = error;
if (!error) {
state.data = data;
}
if (!inner.canceled) {
onFinally?.();
if (!inner.retryCount && interval && interval > 0) {
inner.intervalTimer = setTimeout(() => {
_send(...args);
}, interval);
}
}
}
return [error, data];
};
state.cancel = () => {
inner.canceled = true;
inner.clearAllTimer();
};
state.send = (...args: Parameters<T>) => {
inner.canceled = false;
inner.retryCount = 0;
inner.latestParams = args;
inner.clearAllTimer();
return _send(...args);
};
onMounted(() => {
if (initialParams) {
state.send(...(Array.isArray(initialParams) ? initialParams : ([] as any)));
}
});
onUnmounted(() => {
state.cancel();
});
return state;
} }

View File

@ -1,5 +1,5 @@
import { defineBlocker } from "../../config"; import { defineBlocker } from "../../core";
import { font } from "../components/font"; import { font } from "../font";
import { Date } from "./interface"; import { Date } from "./interface";
import Option from "./option.vue"; import Option from "./option.vue";
import Render from "./render.vue"; import Render from "./render.vue";

View File

@ -1,5 +1,5 @@
import { Block } from "../../config"; import { Block } from "../../core";
import { Font } from "../components/font"; import { Font } from "../font";
export interface DatePrams { export interface DatePrams {
/** /**

View File

@ -1,10 +1,10 @@
<template> <template>
<base-option :data="data"></base-option> <base-option v-model="model"></base-option>
<a-divider></a-divider> <a-divider></a-divider>
<font-option :data="data.params.fontCh"> <font-option v-model="model.params.fontCh">
<a-form-item label="日期格式"> <a-form-item label="日期格式">
<a-auto-complete <a-auto-complete
v-model="data.params.fontCh.content" v-model="model.params.fontCh.content"
:data="FomatSuguestions" :data="FomatSuguestions"
:allow-clear="true" :allow-clear="true"
placeholder="例如 HH:mm:ss" placeholder="例如 HH:mm:ss"
@ -25,17 +25,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { PropType } from "vue";
import BaseOption from "../../components/BaseOption.vue"; import BaseOption from "../../components/BaseOption.vue";
import { FontOption } from "../components/font"; import { FontOption } from "../font";
import { Date, FomatSuguestions } from "./interface"; import { Date, FomatSuguestions } from "./interface";
defineProps({ const model = defineModel<Date>({ required: true });
data: {
type: Object as PropType<Date>,
required: true,
},
});
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
@ -45,4 +39,4 @@ defineProps({
} }
} }
</style> </style>
../components/font ../components/font ../font

View File

@ -7,7 +7,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { dayjs } from "@/libs/dayjs"; import { dayjs } from "@/libs/dayjs";
import { PropType } from "vue"; import { PropType } from "vue";
import { FontRender } from "../components/font"; import { FontRender } from "../font";
import { Date } from "./interface"; import { Date } from "./interface";
defineProps({ defineProps({
@ -19,4 +19,4 @@ defineProps({
</script> </script>
<style scoped></style> <style scoped></style>
../components/font ../font

View File

@ -2,20 +2,20 @@
<div> <div>
<slot> <slot>
<a-form-item label="内容"> <a-form-item label="内容">
<a-textarea v-model="data.content" placeholder="输入内容..."></a-textarea> <a-textarea v-model="model.content" placeholder="输入内容..."></a-textarea>
</a-form-item> </a-form-item>
</slot> </slot>
<a-form-item label="颜色"> <a-form-item label="颜色">
<input-color v-model="data.color"></input-color> <input-color v-model="model.color"></input-color>
</a-form-item> </a-form-item>
<div class="flex gap-4"> <div class="flex gap-4">
<a-form-item label="字体"> <a-form-item label="字体">
<a-select v-model="data.family" :options="FontFamilyOptions" class="w-full overflow-hidden"> </a-select> <a-select v-model="model.family" :options="FontFamilyOptions" class="w-full overflow-hidden"> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="大小"> <a-form-item label="大小">
<a-input-number v-model="data.size" :min="12" :step="2"> </a-input-number> <a-input-number v-model="model.size" :min="12" :step="2"> </a-input-number>
</a-form-item> </a-form-item>
</div> </div>
@ -24,48 +24,42 @@
<div class="h-8 flex items-center justify-between"> <div class="h-8 flex items-center justify-between">
<a-tag <a-tag
class="cursor-pointer !h-7" class="cursor-pointer !h-7"
:color="data.bold ? 'blue' : ''" :color="model.bold ? 'blue' : ''"
:bordered="data.bold ? true : false" :bordered="model.bold ? true : false"
@click="data.bold = !data.bold" @click="model.bold = !model.bold"
> >
<i class="icon-park-outline-text-bold"></i> <i class="icon-park-outline-text-bold"></i>
</a-tag> </a-tag>
<a-tag <a-tag
class="!h-7 cursor-pointer" class="!h-7 cursor-pointer"
:color="data.italic ? 'blue' : ''" :color="model.italic ? 'blue' : ''"
:bordered="data.italic ? true : false" :bordered="model.italic ? true : false"
@click="data.italic = !data.italic" @click="model.italic = !model.italic"
> >
<i class="icon-park-outline-text-italic"></i> <i class="icon-park-outline-text-italic"></i>
</a-tag> </a-tag>
<a-tag <a-tag
class="!h-7 cursor-pointer" class="!h-7 cursor-pointer"
:color="data.underline ? 'blue' : ''" :color="model.underline ? 'blue' : ''"
:bordered="data.underline ? true : false" :bordered="model.underline ? true : false"
@click="data.underline = !data.underline" @click="model.underline = !model.underline"
> >
<i class="icon-park-outline-text-underline"></i> <i class="icon-park-outline-text-underline"></i>
</a-tag> </a-tag>
</div> </div>
</a-form-item> </a-form-item>
<a-form-item label="方向"> <a-form-item label="方向">
<a-select v-model="data.align" :options="AlignOptions"></a-select> <a-select v-model="model.align" :options="AlignOptions"></a-select>
</a-form-item> </a-form-item>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { PropType } from "vue"; import InputColor from "../../components/InputColor.vue";
import InputColor from "../../../components/InputColor.vue";
import { AlignOptions, Font, FontFamilyOptions } from "./interface"; import { AlignOptions, Font, FontFamilyOptions } from "./interface";
defineProps({ const model = defineModel<Font>({ required: true });
data: {
type: Object as PropType<Font>,
required: true,
},
});
</script> </script>
<style scoped></style> <style scoped></style>

View File

@ -1,6 +1,9 @@
import { Blocker } from "../config"; import { Blocker } from "../core";
const blockers: Record<string, Blocker> = import.meta.glob("./*/index.ts", { eager: true, import: "default" }); const blockers: Record<string, Blocker> = import.meta.glob(["./*/index.ts", "!./font/*"], {
eager: true,
import: "default",
});
const BlockerMap: Record<string, Blocker> = {}; const BlockerMap: Record<string, Blocker> = {};
for (const blocker of Object.values(blockers)) { for (const blocker of Object.values(blockers)) {
@ -20,4 +23,3 @@ const getIcon = (type: string) => {
}; };
export { BlockerMap, getBlockerRender, getIcon, getTypeName }; export { BlockerMap, getBlockerRender, getIcon, getTypeName };

View File

@ -1,5 +1,5 @@
import { defineBlocker } from "../../config"; import { defineBlocker } from "../../core";
import { font } from "../components/font"; import { font } from "../font";
import { Text } from "./interface"; import { Text } from "./interface";
import Option from "./option.vue"; import Option from "./option.vue";
import Render from "./render.vue"; import Render from "./render.vue";

View File

@ -1,5 +1,5 @@
import { Block } from "../../config"; import { Block } from "../../core";
import { Font } from "../components/font"; import { Font } from "../font";
export interface TextPrams { export interface TextPrams {
/** /**

View File

@ -1,22 +1,22 @@
<template> <template>
<div> <div>
<base-option :data="data"></base-option> <base-option v-model="model"></base-option>
</div> </div>
<a-divider></a-divider> <a-divider></a-divider>
<div> <div>
<div class="muti-form-item grid grid-cols-2 gap-x-4"> <div class="muti-form-item grid grid-cols-2 gap-x-4">
<a-form-item label="是否滚动"> <a-form-item label="是否滚动">
<a-radio-group type="button" v-model="data.params.marquee" class="!w-full"> <a-radio-group type="button" v-model="model.params.marquee" class="!w-full">
<a-radio :value="false"></a-radio> <a-radio :value="false"></a-radio>
<a-radio :value="true"></a-radio> <a-radio :value="true"></a-radio>
</a-radio-group> </a-radio-group>
</a-form-item> </a-form-item>
<a-form-item :disabled="!data.params.marquee" label="滚动速度"> <a-form-item :disabled="!model.params.marquee" label="滚动速度">
<a-input-number v-model="data.params.speed" :min="10" :step="10"></a-input-number> <a-input-number v-model="model.params.speed" :min="10" :step="10"></a-input-number>
</a-form-item> </a-form-item>
</div> </div>
<a-form-item :disabled="!data.params.marquee" label="滚动方向"> <a-form-item :disabled="!model.params.marquee" label="滚动方向">
<a-radio-group type="button" v-model="data.params.direction" class="!w-full"> <a-radio-group type="button" v-model="model.params.direction" class="!w-full">
<a-radio v-for="item in DirectionOptions" :key="item.value" :value="item.value" class="dir-radio"> <a-radio v-for="item in DirectionOptions" :key="item.value" :value="item.value" class="dir-radio">
<i :class="item.icon"></i> <i :class="item.icon"></i>
</a-radio> </a-radio>
@ -24,21 +24,15 @@
</a-form-item> </a-form-item>
</div> </div>
<a-divider></a-divider> <a-divider></a-divider>
<font-option :data="data.params.fontCh"></font-option> <font-option v-model="model.params.fontCh"></font-option>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { PropType } from "vue";
import BaseOption from "../../components/BaseOption.vue"; import BaseOption from "../../components/BaseOption.vue";
import { FontOption } from "../components/font"; import { FontOption } from "../font";
import { DirectionOptions, Text } from "./interface"; import { DirectionOptions, Text } from "./interface";
defineProps({ const model = defineModel<Text>({ required: true });
data: {
type: Object as PropType<Text>,
required: true,
},
});
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
@ -48,4 +42,5 @@ defineProps({
} }
} }
</style> </style>
../components/font ../components/font
../font

View File

@ -7,7 +7,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { PropType } from "vue"; import { PropType } from "vue";
import { FontRender, getFontStyle } from "../components/font"; import { FontRender, getFontStyle } from "../font";
import { Text } from "./interface"; import { Text } from "./interface";
import AniMarquee from "./marquee.vue"; import AniMarquee from "./marquee.vue";
@ -24,4 +24,4 @@ const style = computed(() => {
</script> </script>
<style scoped></style> <style scoped></style>
../components/font ../components/font../font

View File

@ -1,5 +1,5 @@
import { defineBlocker } from "../../config"; import { defineBlocker } from "../../core";
import { font } from "../components/font"; import { font } from "../font";
import { Time } from "./interface"; import { Time } from "./interface";
import Option from "./option.vue"; import Option from "./option.vue";
import Render from "./render.vue"; import Render from "./render.vue";

View File

@ -1,5 +1,5 @@
import { Block } from "../../config"; import { Block } from "../../core";
import { Font } from "../components/font"; import { Font } from "../font";
export interface TimeParams { export interface TimeParams {
/** /**

View File

@ -1,10 +1,10 @@
<template> <template>
<base-option :data="data"></base-option> <base-option v-model="model"></base-option>
<a-divider></a-divider> <a-divider></a-divider>
<font-option :data="data.params.fontCh"> <font-option v-model="model.params.fontCh">
<a-form-item label="时间格式"> <a-form-item label="时间格式">
<a-auto-complete <a-auto-complete
v-model="data.params.fontCh.content" v-model="model.params.fontCh.content"
:data="FomatSuguestions" :data="FomatSuguestions"
:allow-clear="true" :allow-clear="true"
placeholder="例如 HH:mm:ss" placeholder="例如 HH:mm:ss"
@ -25,17 +25,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { PropType } from "vue";
import BaseOption from "../../components/BaseOption.vue"; import BaseOption from "../../components/BaseOption.vue";
import { FontOption } from "../components/font"; import { FontOption } from "../font";
import { Time, FomatSuguestions } from "./interface"; import { Time, FomatSuguestions } from "./interface";
defineProps({ const model = defineModel<Time>({ required: true });
data: {
type: Object as PropType<Time>,
required: true,
},
});
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
@ -45,4 +39,4 @@ defineProps({
} }
} }
</style> </style>
../components/font ../font

View File

@ -1,14 +1,15 @@
<template> <template>
<font-render :data="data.params.fontCh"> <font-render :data="props.data.params.fontCh">
{{ time }} {{ time }}
</font-render> </font-render>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { dayjs } from "@/libs/dayjs"; import { dayjs } from "@/libs/dayjs";
import { PropType, onMounted, onUnmounted, ref } from "vue"; import { onMounted, onUnmounted, ref } from "vue";
import { FontRender } from "../components/font"; import { FontRender } from "../font";
import { Time } from "./interface"; import { Time } from "./interface";
import { PropType } from "vue";
const props = defineProps({ const props = defineProps({
data: { data: {
@ -34,3 +35,4 @@ onUnmounted(() => {
<style scoped></style> <style scoped></style>
../components/font ../components/font
../font

View File

@ -1,31 +1,35 @@
<template> <template>
<div> <div>
<a-form-item label="组件名称"> <a-form-item label="组件名称">
<a-input v-model="data.title"></a-input> <a-input v-model="model.title"></a-input>
</a-form-item> </a-form-item>
<div class="flex gap-4"> <div class="flex gap-4">
<a-form-item label="左侧"> <a-form-item label="左侧">
<a-input-number v-model="data.x" :min="0" :max="container.width"> <a-input-number v-model="model.x" :min="0" :max="container.width">
<template #prefix> <template #prefix>
<a-tooltip content="固定水平方向"> <a-tooltip content="固定水平方向">
<i <i
class="cursor-pointer text-gray-400 hover:text-gray-700" class="cursor-pointer text-gray-400 hover:text-gray-700"
:class="data.xFixed ? 'icon-park-outline-lock text-gray-900' : 'icon-park-outline-unlock text-gray-400'" :class="
@click="data.xFixed = !data.xFixed" model.xFixed ? 'icon-park-outline-lock text-gray-900' : 'icon-park-outline-unlock text-gray-400'
"
@click="model.xFixed = !model.xFixed"
></i> ></i>
</a-tooltip> </a-tooltip>
</template> </template>
</a-input-number> </a-input-number>
</a-form-item> </a-form-item>
<a-form-item label="顶部"> <a-form-item label="顶部">
<a-input-number v-model="data.y" :min="0" :max="container.height"> <a-input-number v-model="model.y" :min="0" :max="container.height">
<template #prefix> <template #prefix>
<a-tooltip content="固定垂直方向"> <a-tooltip content="固定垂直方向">
<i <i
class="cursor-pointer text-gray-400 hover:text-gray-700" class="cursor-pointer text-gray-400 hover:text-gray-700"
:class="data.yFixed ? 'icon-park-outline-lock text-gray-900' : 'icon-park-outline-unlock text-gray-400'" :class="
@click="data.yFixed = !data.yFixed" model.yFixed ? 'icon-park-outline-lock text-gray-900' : 'icon-park-outline-unlock text-gray-400'
"
@click="model.yFixed = !model.yFixed"
></i> ></i>
</a-tooltip> </a-tooltip>
</template> </template>
@ -35,37 +39,31 @@
<div class="flex gap-4"> <div class="flex gap-4">
<a-form-item label="宽度"> <a-form-item label="宽度">
<a-input-number v-model="data.w" :min="0" :max="container.width"> </a-input-number> <a-input-number v-model="model.w" :min="0" :max="container.width"> </a-input-number>
</a-form-item> </a-form-item>
<a-form-item label="高度"> <a-form-item label="高度">
<a-input-number v-model="data.h" :min="0" :max="container.height"> </a-input-number> <a-input-number v-model="model.h" :min="0" :max="container.height"> </a-input-number>
</a-form-item> </a-form-item>
</div> </div>
<a-form-item label="背景图片"> <a-form-item label="背景图片">
<input-image v-model="data.bgImage"></input-image> <input-image v-model="model.bgImage"></input-image>
</a-form-item> </a-form-item>
<a-form-item label="背景颜色"> <a-form-item label="背景颜色">
<input-color v-model="data.bgColor"></input-color> <input-color v-model="model.bgColor"></input-color>
</a-form-item> </a-form-item>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { PropType } from "vue"; import { Block, EditorKey } from "../core";
import { Block, ContextKey } from "../config";
import InputColor from "./InputColor.vue"; import InputColor from "./InputColor.vue";
import InputImage from "./InputImage.vue"; import InputImage from "./InputImage.vue";
defineProps({ const model = defineModel<Block>({ required: true });
data: { const { container } = inject(EditorKey)!;
type: Object as PropType<Block>,
required: true,
},
});
const { container } = inject(ContextKey)!
</script> </script>
<style scoped></style> <style scoped></style>
../core../core/editor

View File

@ -0,0 +1,98 @@
<template>
<a-modal :visible="visible" :fullscreen="true" :footer="false" class="ani-modal">
<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">
<panel-header v-model:container="container"></panel-header>
</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">
<panel-left @rm-block="rmBlock" @current-block="setCurrentBlock"></panel-left>
</div>
<div class="w-full h-full">
<panel-main
v-model:rightPanelCollapsed="rightPanelCollapsed"
@add-block="addBlock"
@current-block="setCurrentBlock"
></panel-main>
</div>
<div class="h-full overflow-hidden bg-white shadow-[-2px_0_6px_rgba(0,0,0,.05)]">
<panel-right v-model:collapsed="rightPanelCollapsed" v-model:block="currentBlock"></panel-right>
</div>
</div>
</div>
<appnify-preview v-model:visible="preview"></appnify-preview>
</a-modal>
</template>
<script setup lang="ts">
import { EditorKey, useEditor } from "../core";
import PanelHeader from "./PanelHeader.vue";
import PanelLeft from "./PanelLeft.vue";
import PanelMain from "./PanelMain.vue";
import PanelRight from "./PanelRight.vue";
import AppnifyPreview from "./EditorPreview.vue";
const visible = defineModel("visible", { default: false });
const rightPanelCollapsed = ref(false);
const leftPanelCollapsed = ref(false);
const preview = ref(false);
const editor = useEditor();
const { container, blocks, currentBlock, addBlock, rmBlock, setCurrentBlock } = editor;
const saveData = () => {
const data = {
container: container.value,
children: blocks.value,
};
const str = JSON.stringify(data);
localStorage.setItem("ANI_EDITOR_DATA", str);
};
const loadData = async () => {
const str = localStorage.getItem("ANI_EDITOR_DATA");
if (!str) {
return;
}
const data = JSON.parse(str);
container.value = data.container;
blocks.value = data.children;
};
provide(EditorKey, editor);
onMounted(loadData);
</script>
<style lang="less">
.ani-modal {
.muti-form-item .arco-form-item .arco-form-item-label {
line-height: 1;
}
.arco-modal-fullscreen {
display: block;
height: 100%;
}
.arco-modal-header {
display: none;
}
.arco-modal-body {
padding: 0;
height: 100%;
width: 100%;
overflow: hidden;
}
.arco-tabs-nav-vertical .arco-tabs-nav-ink {
display: none;
}
.arco-tabs-nav-vertical.arco-tabs-nav-type-line .arco-tabs-tab {
padding: 4px;
}
.arco-form-item-content-flex {
display: block;
}
.arco-divider-text-left {
left: 0;
padding-left: 0;
}
}
</style>
../core/editor

View File

@ -39,18 +39,10 @@
import { Message } from "@arco-design/web-vue"; import { Message } from "@arco-design/web-vue";
import { useFullscreen } from "@vueuse/core"; import { useFullscreen } from "@vueuse/core";
import { BlockerMap } from "../blocks"; import { BlockerMap } from "../blocks";
import { ContextKey } from "../config"; import { EditorKey } from "../core";
const { container, blocks } = inject(ContextKey)!; const { container, blocks } = inject(EditorKey)!;
const visible = defineModel<boolean>("visible");
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:visible"]);
const el = ref<HTMLElement | null>(null); const el = ref<HTMLElement | null>(null);
const { enter, isFullscreen, isSupported } = useFullscreen(el); const { enter, isFullscreen, isSupported } = useFullscreen(el);
@ -58,13 +50,13 @@ watch(
() => isFullscreen.value, () => isFullscreen.value,
() => { () => {
if (!isFullscreen.value) { if (!isFullscreen.value) {
emit("update:visible", false); visible.value = false;
} }
} }
); );
watch( watch(
() => props.visible, () => visible.value,
(value) => { (value) => {
if (!value) { if (!value) {
return; return;
@ -79,3 +71,4 @@ watch(
</script> </script>
<style scoped></style> <style scoped></style>
../core/editor

View File

@ -43,7 +43,6 @@
<div class="flex-1 truncate text-gray-600" :class="{ 'text-brand-500': selectedKeys.includes(item.id) }"> <div class="flex-1 truncate text-gray-600" :class="{ 'text-brand-500': selectedKeys.includes(item.id) }">
{{ item.title }}(<span class="text-xs text-gray-400">1280 * 800</span>) {{ item.title }}(<span class="text-xs text-gray-400">1280 * 800</span>)
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@ -71,7 +70,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { mockLoad } from "./mock"; import { mockLoad } from "../utils/mock";
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@ -186,3 +185,4 @@ watch(
z-index: 2; z-index: 2;
} }
</style> </style>
../utils/mock

View File

@ -7,20 +7,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const model = defineModel<string>();
const props = defineProps({
modelValue: {
type: String,
default: "",
},
});
const emit = defineEmits(["update:modelValue"]);
const model = computed({
get: () => props.modelValue,
set: (value) => emit("update:modelValue", value),
});
</script> </script>
<style scoped></style> <style scoped></style>

View File

@ -8,22 +8,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ImagePicker from './ImagePicker.vue'; import ImagePicker from "./ImagePicker.vue";
const props = defineProps({ const model = defineModel<string>();
modelValue: {
type: String,
default: "",
},
});
const emit = defineEmits(["update:modelValue"]);
const visible = ref(false); const visible = ref(false);
const model = computed({
get: () => props.modelValue,
set: (value) => emit("update:modelValue", value),
});
</script> </script>
<style scoped></style> <style scoped></style>

View File

@ -23,15 +23,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { Message } from "@arco-design/web-vue"; import { Message } from "@arco-design/web-vue";
import { ContextKey } from "../config"; import { Container } from "../core";
import AniTexter from "../panel-main/components/texter.vue"; import AniTexter from "./InputTexter.vue";
const { saveData, container } = inject(ContextKey)!;
const onSaveData = () => { const onSaveData = () => {
saveData();
Message.success("保存成功"); Message.success("保存成功");
}; };
const container = defineModel<Container>("container", { required: true });
</script> </script>
<style scoped></style> <style scoped></style>
../core

View File

@ -41,7 +41,7 @@
</div> </div>
</div> </div>
<div v-show="!collapsed"> <div v-show="!collapsed">
<ul v-show="key === 'list'" class="list-none px-2 grid gap-2" @dragstart="onDragStart" @dragover="onDragOver"> <ul v-show="key === 'list'" class="list-none px-2 grid gap-2" @dragstart="onDragStart" @dragover.prevent>
<li <li
v-for="item in blockList" v-for="item in blockList"
:key="item.type" :key="item.type"
@ -65,11 +65,11 @@
:key="item.id" :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="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="{ :class="{
'!bg-brand-50': current.block === item, '!bg-brand-50': currentBlock === item,
'!text-brand-500': current.block === item, '!text-brand-500': currentBlock === item,
'!border-brand-300': current.block === item, '!border-brand-300': currentBlock === item,
}" }"
@click="setCurrentBlock(item)" @click="emit('current-block', item)"
> >
<div class=""> <div class="">
<i class="text-base" :class="getIcon(item.type)"></i> <i class="text-base" :class="getIcon(item.type)"></i>
@ -80,7 +80,7 @@
<div class="w-4"> <div class="w-4">
<i <i
class="!hidden !group-hover:inline-block text-gray-400 hover:text-gray-700 icon-park-outline-delete !text-xs" class="!hidden !group-hover:inline-block text-gray-400 hover:text-gray-700 icon-park-outline-delete !text-xs"
@click="onDeleteBlock($event, item)" @click.prevent="emit('rm-block', item)"
></i> ></i>
</div> </div>
</li> </li>
@ -90,13 +90,18 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { BlockerMap, getIcon } from "../blocks"; import { getIcon } from "../blocks";
import { Block, ContextKey } from "../config"; import { Block, EditorKey } from "../core";
const { blocks, current, setCurrentBlock } = inject(ContextKey)!; const { blocks, currentBlock, BlockerMap } = inject(EditorKey)!;
const blockList = Object.values(BlockerMap); const blockList = Object.values(BlockerMap);
const collapsed = ref(false); const collapsed = ref(false);
const key = ref("list"); const key = ref<"list" | "data">("list");
const emit = defineEmits<{
(event: "rm-block", block: Block): void;
(event: "current-block", block: Block | null): void;
}>();
/** /**
* 拖拽开始时设置数据 * 拖拽开始时设置数据
@ -104,25 +109,7 @@ const key = ref("list");
const onDragStart = (e: DragEvent) => { const onDragStart = (e: DragEvent) => {
e.dataTransfer?.setData("type", (e.target as HTMLElement).dataset.type!); e.dataTransfer?.setData("type", (e.target as HTMLElement).dataset.type!);
}; };
/**
* 拖拽时阻止默认行为
*/
const onDragOver = (e: Event) => {
console.log("over");
e.preventDefault();
};
/**
* 删除组件
*/
const onDeleteBlock = async (e: Event, block: Block) => {
e.preventDefault();
const index = blocks.value.indexOf(block);
if (index > -1) {
blocks.value.splice(index, 1);
}
};
</script> </script>
<style scoped></style> <style scoped></style>
../core../core/editor

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="h-full grid grid-rows-[auto_1fr]"> <div class="h-full grid grid-rows-[auto_1fr]">
<div class="h-10"> <div class="h-10">
<ani-header :container="container"></ani-header> <ani-header :container="container" v-model:rightPanelCollapsed="rightPanelCollapsed"></ani-header>
</div> </div>
<div class="h-full w-full overflow-hidden p-4"> <div class="h-full w-full overflow-hidden p-4">
<div <div
@ -10,16 +10,16 @@
<div <div
class="relative" class="relative"
:style="containerStyle" :style="containerStyle"
@dragover.prevent
@click="onClick" @click="onClick"
@drop="onDragDrop" @drop="onDragDrop"
@dragover.prevent @wheel="onMouseWheel"
@wheel="scene.onMouseWheel" @mousedown="onMouseDown"
@mousedown="scene.onMouseDown"
> >
<ani-block v-for="block in blocks" :key="block.id" :data="block" :container="container"></ani-block> <ani-block v-for="block in blocks" :key="block.id" :data="block" :container="container"></ani-block>
<template v-if="refLine.active.value"> <template v-if="active">
<div <div
v-for="line in refLine.xl.value" v-for="line in xLines"
:key="line.y" :key="line.y"
:style="{ :style="{
position: 'absolute', position: 'absolute',
@ -31,7 +31,7 @@
}" }"
></div> ></div>
<div <div
v-for="line in refLine.yl.value" v-for="line in yLines"
:key="line.x" :key="line.x"
:style="{ :style="{
position: 'absolute', position: 'absolute',
@ -50,40 +50,33 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { cloneDeep } from "lodash-es"; import { Block, EditorKey } from "../core";
import { CSSProperties } from "vue"; import AniBlock from "./PanelMainBlock.vue";
import { BlockerMap } from "../blocks"; import AniHeader from "./PanelMainHeader.vue";
import { ContextKey, Scene } from "../config";
import AniBlock from "./components/block.vue";
import AniHeader from "./components/header.vue";
const { blocks, container, refLine, setCurrentBlock } = inject(ContextKey)!; const rightPanelCollapsed = defineModel<boolean>("rightPanelCollapsed");
const scene = new Scene(container); const { blocks, container, refLine, formatContainerStyle, scene } = inject(EditorKey)!;
const { onMouseDown, onMouseWheel } = scene;
const { active, xLines, yLines } = refLine;
const emit = defineEmits<{
(event: "add-block", type: string, x?: number, y?: number): void;
(event: "current-block", block: Block | null): void;
}>();
/** /**
* 清空当前组件 * 清空当前组件
*/ */
const onClick = (e: Event) => { const onClick = (e: Event) => {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
setCurrentBlock(null); emit("current-block", null);
} }
}; };
/** /**
* 容器样式 * 容器样式
*/ */
const containerStyle = computed(() => { const containerStyle = computed(() => formatContainerStyle(container.value));
const { width, height, bgColor, bgImage, zoom, x, y } = container.value;
return {
position: "absolute",
width: `${width}px`,
height: `${height}px`,
backgroundColor: bgColor,
backgroundImage: bgImage ? `url(${bgImage})` : undefined,
backgroundSize: "100% 100%",
transform: `translate3d(${x}px, ${y}px, 0) scale(${zoom})`,
} as CSSProperties;
});
/** /**
* 接收拖拽并新增组件 * 接收拖拽并新增组件
@ -91,23 +84,11 @@ const containerStyle = computed(() => {
const onDragDrop = (e: DragEvent) => { const onDragDrop = (e: DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const type = e.dataTransfer?.getData("type"); const type = e.dataTransfer?.getData("type");
if (!type) { if (!type) {
return; return;
} }
const blocker = BlockerMap[type]; emit("add-block", type, e.offsetX, e.offsetY);
const currentIds = blocks.value.map((item) => Number(item.id));
const maxId = currentIds.length ? Math.max.apply(null, currentIds) : 0;
const id = (maxId + 1).toString();
const title = `${blocker.title}${id}`;
blocks.value.push({
...cloneDeep(blocker.initial),
id,
title,
x: e.offsetX,
y: e.offsetY,
});
}; };
</script> </script>
@ -127,3 +108,4 @@ const onDragDrop = (e: DragEvent) => {
background-position: 0 0, 10px 10px; background-position: 0 0, 10px 10px;
} }
</style> </style>
../core../core/editor

View File

@ -26,9 +26,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { PropType } from "vue"; import { PropType } from "vue";
import { BlockerMap } from "../../blocks"; import { BlockerMap } from "../blocks";
import DragResizer from "../../components/DragResizer.vue"; import DragResizer from "./DragResizer.vue";
import { Block, Container, ContextKey } from "../../config"; import { Block, Container, EditorKey } from "../core";
const props = defineProps({ const props = defineProps({
data: { data: {
@ -41,7 +41,8 @@ const props = defineProps({
}, },
}); });
const { setCurrentBlock, refLine } = inject(ContextKey)!; const { setCurrentBlock, refLine } = inject(EditorKey)!;
const { active, recordBlocksXY, updateRefLine } = refLine;
/** /**
* 组件样式 * 组件样式
@ -59,15 +60,12 @@ const blockStyle = computed(() => {
* 拖拽组件 * 拖拽组件
*/ */
const onItemDragging = (rect: any) => { const onItemDragging = (rect: any) => {
if (refLine.active.value) { if (active.value) {
const { x = 0, y = 0 } = refLine.updateRefLine(rect); const { offsetX = 0, offsetY = 0 } = updateRefLine(rect);
rect.left += x; rect.left += offsetX;
rect.top += y; rect.top += offsetY;
} }
props.data.x = rect.left; onItemResizing(rect);
props.data.y = rect.top;
props.data.w = rect.width;
props.data.h = rect.height;
}; };
/** /**
@ -84,7 +82,7 @@ const onItemResizing = (rect: any) => {
* 按下鼠标 * 按下鼠标
*/ */
const onItemMouseDown = () => { const onItemMouseDown = () => {
refLine.active.value = true; active.value = true;
}; };
/** /**
@ -92,14 +90,14 @@ const onItemMouseDown = () => {
*/ */
const onItemActivated = (block: Block) => { const onItemActivated = (block: Block) => {
setCurrentBlock(block); setCurrentBlock(block);
refLine.recordBlocksXY(); recordBlocksXY();
}; };
/** /**
* 松开鼠标 * 松开鼠标
*/ */
const onItemMouseup = () => { const onItemMouseup = () => {
refLine.active.value = false; active.value = false;
}; };
</script> </script>
@ -129,3 +127,4 @@ const onItemMouseup = () => {
} }
} }
</style> </style>
../core../core/editor

View File

@ -31,7 +31,7 @@
</a-button> </a-button>
</a-tooltip> </a-tooltip>
<a-tooltip content="预览" position="bottom"> <a-tooltip content="预览" position="bottom">
<a-button type="text" @click="preview"> <a-button type="text">
<template #icon> <template #icon>
<i class="icon-park-outline-play text-base !text-gray-600"></i> <i class="icon-park-outline-play text-base !text-gray-600"></i>
</template> </template>
@ -58,12 +58,12 @@
</a-form> </a-form>
</template> </template>
</a-popover> </a-popover>
<a-tooltip :content="current.rightPanelCollapsed ? '展开' : '折叠'" position="bottom"> <a-tooltip :content="rightPanelCollapsed ? '展开' : '折叠'" position="bottom">
<a-button type="text" @click="current.rightPanelCollapsed = !current.rightPanelCollapsed"> <a-button type="text" @click="rightPanelCollapsed = !rightPanelCollapsed">
<template #icon> <template #icon>
<i <i
class="text-base !text-gray-600" class="text-base !text-gray-600"
:class="current.rightPanelCollapsed ? 'icon-park-outline-expand-right' : 'icon-park-outline-expand-left'" :class="rightPanelCollapsed ? 'icon-park-outline-expand-right' : 'icon-park-outline-expand-left'"
></i> ></i>
</template> </template>
</a-button> </a-button>
@ -73,12 +73,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import InputColor from "../../components/InputColor.vue"; import InputColor from "./InputColor.vue";
import InputImage from "../../components/InputImage.vue"; import InputImage from "./InputImage.vue";
import { ContextKey } from "../../config"; import AniTexter from "./InputTexter.vue";
import AniTexter from "./texter.vue"; import { EditorKey } from "../core";
const { container, blocks, current, preview, setContainerOrigin } = inject(ContextKey)!; const { container, blocks, setContainerOrigin } = inject(EditorKey)!;
const rightPanelCollapsed = defineModel<boolean>("rightPanelCollapsed");
</script> </script>
<style scoped></style> <style scoped></style>
../core/editor

View File

@ -1,13 +1,9 @@
<template> <template>
<div class="h-full w-[248px] overflow-hidden" :style="`display: ${current.rightPanelCollapsed ? 'none' : 'block'}`"> <div class="h-full w-[248px] overflow-hidden" :style="`display: ${collapsed ? 'none' : 'block'}`">
<div v-if="current.block" class="p-3"> <div v-if="model" class="p-3">
<a-radio-group type="button" default-value="1" class="w-full mb-2">
<a-radio value="1">属性</a-radio>
<a-radio value="2">文本</a-radio>
</a-radio-group>
<a-form :model="{}" layout="vertical"> <a-form :model="{}" layout="vertical">
<div class="muti-form-item mt-2"> <div class="muti-form-item mt-2">
<component :is="BlockerMap[current.block.type].option" :data="current.block" /> <component :is="BlockerMap[model.type].option" v-model="model" />
</div> </div>
</a-form> </a-form>
</div> </div>
@ -19,9 +15,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { BlockerMap } from "../blocks"; import { BlockerMap } from "../blocks";
import { ContextKey } from "../config"; import { Block } from "../core";
const { current } = inject(ContextKey)!; const collapsed = defineModel<boolean>("collapsed");
const model = defineModel<Block | null>("block");
</script> </script>
<style scoped></style> <style scoped></style>
../core

View File

@ -1,52 +0,0 @@
import { InjectionKey, Ref } from "vue";
import { Block } from "./block";
import { Container } from "./container";
import { ReferenceLine } from "./ref-line";
export interface Current {
block: Block | null;
rightPanelCollapsed: boolean;
}
export interface Context {
/**
*
*/
current: Ref<Current>;
/**
*
*/
blocks: Ref<Block[]>;
/**
*
*/
container: Ref<Container>;
/**
* 线
*/
refLine: ReferenceLine;
/**
*
* @param block
* @returns
*/
setCurrentBlock: (block: Block | null) => void;
/**
*
*/
setContainerOrigin: () => void;
/**
*
*/
saveData: () => void;
/**
*
*/
loadData: () => void;
/**
*
*/
preview: () => void;
}
export const ContextKey = Symbol("ContextKey") as InjectionKey<Context>;

View File

@ -1,15 +0,0 @@
import { Ref } from "vue";
import { Container } from "./container";
import { Block } from "./block";
/**
* TODO
*/
export class Editor {
public container: Ref<Container> = {} as Ref<Container>;
public content: Ref<Block> = {} as Ref<Block>;
constructor() {
// TODO
}
}

View File

@ -1,65 +0,0 @@
import { Ref } from "vue";
import { Container } from ".";
/**
*
* @description
*/
export class Scene {
private startX = 0;
private startY = 0;
private cacheX = 0;
private cacheY = 0;
public minZoom = 0.5;
public maxZoom = 10;
public zoomStep = 0.1;
constructor(private container: Ref<Container>) {
this.onMouseDown = this.onMouseDown.bind(this);
this.onMouseMove = this.onMouseMove.bind(this);
this.onMouseUp = this.onMouseUp.bind(this);
this.onMouseWheel = this.onMouseWheel.bind(this);
}
onMouseDown(e: MouseEvent) {
this.startX = e.x;
this.startY = e.y;
this.cacheX = this.container.value.x;
this.cacheY = this.container.value.y;
window.addEventListener("mousemove", this.onMouseMove);
window.addEventListener("mouseup", this.onMouseUp);
}
onMouseMove(e: MouseEvent) {
this.container.value.x = this.cacheX + (e.x - this.startX);
this.container.value.y = this.cacheY + (e.y - this.startY);
}
onMouseUp() {
window.removeEventListener("mousemove", this.onMouseMove);
window.removeEventListener("mouseup", this.onMouseUp);
}
onMouseWheel(e: WheelEvent) {
e.preventDefault();
const el = e.currentTarget as HTMLElement;
const rect = el.getBoundingClientRect();
const x = (e.clientX - rect.x) / this.container.value.zoom;
const y = (e.clientY - rect.y) / this.container.value.zoom;
const delta = -e.deltaY > 0 ? this.zoomStep : -this.zoomStep;
this.container.value.zoom += delta;
if (this.container.value.zoom < this.minZoom) {
this.container.value.zoom = this.minZoom;
return;
}
if (this.container.value.zoom > this.maxZoom) {
this.container.value.zoom = this.maxZoom;
return;
}
this.container.value.x += -x * delta + el.offsetWidth * (delta / 2);
this.container.value.y += -y * delta + el.offsetHeight * (delta / 2);
}
}

View File

@ -1,3 +1,6 @@
/**
*
*/
export interface Block<T = any> { export interface Block<T = any> {
/** /**
* ID * ID

View File

@ -1,6 +1,9 @@
import { Component } from "vue"; import { Component } from "vue";
import { Block } from "./block"; import { Block } from "./block";
/**
*
*/
export interface Blocker<T = any> { export interface Blocker<T = any> {
/** /**
* *

View File

@ -1,3 +1,6 @@
/**
*
*/
export interface Container { export interface Container {
/** /**
* id * id
@ -40,3 +43,19 @@ export interface Container {
*/ */
bgColor: string; bgColor: string;
} }
/**
*
*/
export const defaultContainer: Container = {
id: 11,
title: "国庆节喜庆版式设计",
description: "适用于国庆节1日-7日间上午9:00-10:00播出的版式设计",
x: 0,
y: 0,
zoom: 0.7,
width: 1920,
height: 1080,
bgImage: "",
bgColor: "#ffffff",
};

View File

@ -0,0 +1,150 @@
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[]>([]);
/**
*
*/
const currentBlock = ref<Block | null>(null);
/**
* 线
*/
const refLine = useReferenceLine(blocks, currentBlock);
/**
*
*/
const scene = useScene(container);
/**
*
* @param type
* @param x
* @param y
* @returns
*/
const addBlock = (type: string, x = 0, y = 0) => {
if (!type) {
return;
}
const blocker = BlockerMap[type];
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}`;
blocks.value.push({
...cloneDeep(blocker.initial),
id,
x,
y,
title,
});
};
/**
*
* @param block
*/
const rmBlock = (block: Block) => {
const index = blocks.value.indexOf(block);
if (index > -1) {
blocks.value.splice(index, 1);
}
};
/**
*
* @param block
* @returns
*/
const formatBlockStyle = (block: Block) => {
const { bgColor, bgImage } = block;
return {
backgroundColor: bgColor,
backgroundImage: bgImage ? `url(${bgImage})` : undefined,
backgroundSize: "100% 100%",
};
};
/**
*
* @param container
* @returns
*/
const 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})` : undefined,
backgroundSize: "100% 100%",
transform: `translate3d(${x}px, ${y}px, 0) scale(${zoom})`,
} as CSSProperties;
};
/**
*
* @param block
*/
const setCurrentBlock = (block: Block | null) => {
for (const item of blocks.value) {
item.actived = false;
}
if (!block) {
currentBlock.value = null;
} else {
block.actived = true;
currentBlock.value = block;
}
};
/**
*
*/
const 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;
}
};
return {
container,
blocks,
currentBlock,
refLine,
scene,
BlockerMap,
setCurrentBlock,
setContainerOrigin,
addBlock,
rmBlock,
formatBlockStyle,
formatContainerStyle,
};
};
export const EditorKey = Symbol("EditorKey") as InjectionKey<ReturnType<typeof useEditor>>;

View File

@ -1,6 +1,6 @@
export * from "./block"; export * from "./block";
export * from "./blocker"; export * from "./blocker";
export * from "./container"; export * from "./container";
export * from "./context";
export * from "./ref-line"; export * from "./ref-line";
export * from "./scene"; export * from "./scene";
export * from "./editor";

View File

@ -1,71 +1,58 @@
import { Ref } from "vue"; import { Ref } from "vue";
import { Block } from "./block"; import { Block } from "./block";
import { Current } from "./context"; import { getClosestValInSortedArr } from "../utils/closest";
import { getClosestValInSortedArr } from "./util";
export interface DragRect {
left: number;
top: number;
width: number;
height: number;
}
/** /**
* 线 * 线
* @param blocks
* @param current
* @returns
*/ */
export class ReferenceLine { export const useReferenceLine = (blocks: Ref<Block[]>, current: Ref<Block | null>) => {
private xYsMap = new Map<number, number[]>(); let xYsMap = new Map<number, number[]>();
private yXsMap = new Map<number, number[]>(); let yXsMap = new Map<number, number[]>();
private sortedXs: number[] = []; let sortedXs: number[] = [];
private sortedYs: number[] = []; let sortedYs: number[] = [];
private xLines: { y: number; xs: number[] }[] = []; const active = ref(false);
private yLines: { x: number; ys: number[] }[] = []; const xLines = ref<{ x: number; y: number; w: number }[]>([]);
public active = ref(false); const yLines = ref<{ x: number; y: number; h: number }[]>([]);
public xl = ref<{ x: number; y: number; w: number }[]>([]);
public yl = ref<{ x: number; y: number; h: number }[]>([]);
constructor(private blocks: Ref<Block[]>, private current: Ref<Current>) {
this.updateRefLine = this.updateRefLine.bind(this);
}
/** /**
* *
*/ */
recordBlocksXY() { const recordBlocksXY = () => {
this.clear(); clear();
const { xYsMap, yXsMap, blocks, current } = this;
for (const block of blocks.value) { for (const block of blocks.value) {
if (block === current.value.block) { if (block === current.value) {
continue; continue;
} }
const { minX, minY, midX, midY, maxX, maxY } = this.getBlockBox(block); const { minX, minY, midX, midY, maxX, maxY } = getBlockBox(block);
addBoxToMap(xYsMap, minX, [minY, maxY]);
addBoxToMap(xYsMap, midX, [minY, maxY]);
addBoxToMap(xYsMap, maxX, [minY, maxY]);
this.addBoxToMap(xYsMap, minX, [minY, maxY]); addBoxToMap(yXsMap, minY, [minX, maxX]);
this.addBoxToMap(xYsMap, midX, [minY, maxY]); addBoxToMap(yXsMap, midY, [minX, maxX]);
this.addBoxToMap(xYsMap, maxX, [minY, maxY]); addBoxToMap(yXsMap, maxY, [minX, maxX]);
this.addBoxToMap(yXsMap, minY, [minX, maxX]);
this.addBoxToMap(yXsMap, midY, [minX, maxX]);
this.addBoxToMap(yXsMap, maxY, [minX, maxX]);
} }
this.sortedXs = Array.from(xYsMap.keys()).sort((a, b) => a - b); sortedXs = Array.from(xYsMap.keys()).sort((a, b) => a - b);
this.sortedYs = Array.from(yXsMap.keys()).sort((a, b) => a - b); sortedYs = Array.from(yXsMap.keys()).sort((a, b) => a - b);
} };
/** /**
* *
*/ */
addBoxToMap(map: Map<number, number[]>, xOrY: number, xsOrYs: number[]) { const addBoxToMap = (map: Map<number, number[]>, xOrY: number, xsOrYs: number[]) => {
if (!map.get(xOrY)) { if (!map.get(xOrY)) {
map.set(xOrY, []); map.set(xOrY, []);
} }
map.get(xOrY)?.push(...xsOrYs); map.get(xOrY)?.push(...xsOrYs);
} };
/** /**
* *
*/ */
getBlockBox(block: Block) { const getBlockBox = (block: Block) => {
const { x, y, w, h } = block ?? {}; const { x, y, w, h } = block ?? {};
return { return {
minX: x, minX: x,
@ -75,12 +62,12 @@ export class ReferenceLine {
maxX: x + w, maxX: x + w,
maxY: y + h, maxY: y + h,
}; };
} };
/** /**
* *
*/ */
getRectBox(rect: DragRect) { const getRectBox = (rect: DragRect) => {
const { left: x, top: y, width: w, height: h } = rect; const { left: x, top: y, width: w, height: h } = rect;
return { return {
minX: x, minX: x,
@ -90,18 +77,18 @@ export class ReferenceLine {
maxX: x + w, maxX: x + w,
maxY: y + h, maxY: y + h,
}; };
} };
/** /**
* *
*/ */
clear() { function clear() {
this.xYsMap.clear(); xYsMap.clear();
this.yXsMap.clear(); yXsMap.clear();
this.sortedXs = []; sortedXs = [];
this.sortedYs = []; sortedYs = [];
this.xl.value = []; xLines.value = [];
this.yl.value = []; yLines.value = [];
} }
/** /**
@ -112,18 +99,16 @@ export class ReferenceLine {
* 5. * 5.
* 6. 线 * 6. 线
*/ */
updateRefLine(rect: DragRect) { function updateRefLine(rect: DragRect) {
this.xLines = []; const allXLines = [];
this.yLines = []; const allYLines = [];
const box = this.getRectBox(rect); const box = getRectBox(rect);
const { xYsMap, yXsMap, sortedXs, sortedYs } = this; let offsetX: number | undefined;
let offsetY: number | undefined;
if (!sortedXs.length && !sortedYs.length) { if (!sortedXs.length && !sortedYs.length) {
return { x: 0, y: 0 }; return { x: 0, y: 0 };
} }
let offsetX: number | undefined = undefined;
let offsetY: number | undefined = undefined;
// 离最近X的距离 // 离最近X的距离
const closetMinX = getClosestValInSortedArr(sortedXs, box.minX); const closetMinX = getClosestValInSortedArr(sortedXs, box.minX);
const closetMidX = getClosestValInSortedArr(sortedXs, box.midX); const closetMidX = getClosestValInSortedArr(sortedXs, box.midX);
@ -185,19 +170,19 @@ export class ReferenceLine {
if (offsetX !== undefined) { if (offsetX !== undefined) {
if (isEqualNum(0, closetMinX - targetBox.minX)) { if (isEqualNum(0, closetMinX - targetBox.minX)) {
this.yLines.push({ allYLines.push({
x: closetMinX, x: closetMinX,
ys: [targetBox.minY, targetBox.maxY, ...(xYsMap.get(closetMinX) ?? [])], ys: [targetBox.minY, targetBox.maxY, ...(xYsMap.get(closetMinX) ?? [])],
}); });
} }
if (isEqualNum(0, closetMidX - targetBox.midX)) { if (isEqualNum(0, closetMidX - targetBox.midX)) {
this.yLines.push({ allYLines.push({
x: closetMidX, x: closetMidX,
ys: [targetBox.midX, ...(xYsMap.get(closetMidX) ?? [])], ys: [targetBox.midX, ...(xYsMap.get(closetMidX) ?? [])],
}); });
} }
if (isEqualNum(0, closetMaxX - targetBox.maxX)) { if (isEqualNum(0, closetMaxX - targetBox.maxX)) {
this.yLines.push({ allYLines.push({
x: closetMaxX, x: closetMaxX,
ys: [targetBox.minY, targetBox.maxY, ...(xYsMap.get(closetMaxX) ?? [])], ys: [targetBox.minY, targetBox.maxY, ...(xYsMap.get(closetMaxX) ?? [])],
}); });
@ -206,45 +191,62 @@ export class ReferenceLine {
if (offsetY !== undefined) { if (offsetY !== undefined) {
if (isEqualNum(0, closetMinY - targetBox.minY)) { if (isEqualNum(0, closetMinY - targetBox.minY)) {
this.xLines.push({ allXLines.push({
y: closetMinY, y: closetMinY,
xs: [targetBox.minX, targetBox.maxX, ...(yXsMap.get(closetMinY) ?? [])], xs: [targetBox.minX, targetBox.maxX, ...(yXsMap.get(closetMinY) ?? [])],
}); });
} }
if (isEqualNum(0, closetMidY - targetBox.midY)) { if (isEqualNum(0, closetMidY - targetBox.midY)) {
this.xLines.push({ allXLines.push({
y: closetMidY, y: closetMidY,
xs: [targetBox.midX, ...(yXsMap.get(closetMidY) ?? [])], xs: [targetBox.midX, ...(yXsMap.get(closetMidY) ?? [])],
}); });
} }
if (isEqualNum(0, closetMaxY - targetBox.maxY)) { if (isEqualNum(0, closetMaxY - targetBox.maxY)) {
this.xLines.push({ allXLines.push({
y: closetMaxY, y: closetMaxY,
xs: [targetBox.minX, targetBox.maxX, ...(yXsMap.get(closetMaxY) ?? [])], xs: [targetBox.minX, targetBox.maxX, ...(yXsMap.get(closetMaxY) ?? [])],
}); });
} }
} }
const yl: any[] = []; const yls: any[] = [];
for (const line of this.yLines) { for (const line of allYLines) {
const y = Math.min(...line.ys); const y = Math.min(...line.ys);
const h = Math.max(...line.ys) - y; const h = Math.max(...line.ys) - y;
yl.push({ x: line.x, y, h }); yls.push({ x: line.x, y, h });
} }
const xl: any[] = []; const xls: any[] = [];
for (const line of this.xLines) { for (const line of allXLines) {
const x = Math.min(...line.xs); const x = Math.min(...line.xs);
const w = Math.max(...line.xs) - x; const w = Math.max(...line.xs) - x;
xl.push({ y: line.y, x, w }); xls.push({ y: line.y, x, w });
} }
this.yl.value = yl; yLines.value = yls;
this.xl.value = xl; xLines.value = xls;
return { return {
x: offsetX, offsetX,
y: offsetY, offsetY,
}; };
} }
return {
active,
xLines,
yLines,
recordBlocksXY,
updateRefLine,
};
};
export interface DragRect {
left: number;
top: number;
width: number;
height: number;
} }
export type ReferenceLine = ReturnType<typeof useReferenceLine>;

View File

@ -0,0 +1,63 @@
import { Ref } from "vue";
import { Container } from ".";
/**
*
* @description
*/
export const useScene = (container: Ref<Container>) => {
const minZoom = 0.5;
const maxZoom = 10;
const zoomStep = 0.1;
let startX = 0;
let startY = 0;
let cacheX = 0;
let cacheY = 0;
const onMouseDown = (e: MouseEvent) => {
startX = e.x;
startY = e.y;
cacheX = container.value.x;
cacheY = container.value.y;
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
};
const onMouseMove = (e: MouseEvent) => {
container.value.x = cacheX + (e.x - startX);
container.value.y = cacheY + (e.y - startY);
};
const onMouseUp = () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
const onMouseWheel = (e: WheelEvent) => {
e.preventDefault();
const el = e.currentTarget as HTMLElement;
const rect = el.getBoundingClientRect();
const x = (e.clientX - rect.x) / container.value.zoom;
const y = (e.clientY - rect.y) / container.value.zoom;
const delta = -e.deltaY > 0 ? zoomStep : -zoomStep;
container.value.zoom += delta;
if (container.value.zoom < minZoom) {
container.value.zoom = minZoom;
return;
}
if (container.value.zoom > maxZoom) {
container.value.zoom = maxZoom;
return;
}
container.value.x += -x * delta + el.offsetWidth * (delta / 2);
container.value.y += -y * delta + el.offsetHeight * (delta / 2);
};
return {
onMouseDown,
onMouseWheel,
};
};

View File

@ -0,0 +1,3 @@
import Editor from "./components/Editor.vue";
export { Editor }

View File

@ -1,180 +0,0 @@
<template>
<a-modal :visible="visible" :fullscreen="true" :footer="false" class="ani-modal">
<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">
<panel-header></panel-header>
</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">
<panel-left></panel-left>
</div>
<div class="w-full h-full">
<panel-main></panel-main>
</div>
<div class="h-full overflow-hidden bg-white shadow-[-2px_0_6px_rgba(0,0,0,.05)]">
<panel-right></panel-right>
</div>
</div>
</div>
<appnify-preview v-model:visible="preview"></appnify-preview>
</a-modal>
</template>
<script setup lang="ts">
import { Block, Container, ContextKey, ReferenceLine } from "./config";
import PanelHeader from "./panel-header/index.vue";
import PanelLeft from "./panel-left/index.vue";
import PanelMain from "./panel-main/index.vue";
import PanelRight from "./panel-right/index.vue";
import AppnifyPreview from "./preview/index.vue";
const visible = defineModel("visible", { default: false });
const preview = ref(false);
/**
* 运行时上下文
*/
const current = ref({
block: null as Block | null,
rightPanelCollapsed: false,
});
/**
* 组件列表
*/
const blocks = ref<Block[]>([]);
/**
* 参考线
*/
const refLine = new ReferenceLine(blocks, current);
/**
* 画布容器
*/
const container = ref<Container>({
id: 11,
title: "国庆节喜庆版式设计",
description: "适用于国庆节1日-7日间上午9:00-10:00播出的版式设计",
x: 0,
y: 0,
zoom: 0.7,
width: 1920,
height: 1080,
bgImage: "",
bgColor: "#ffffff",
});
onMounted(() => {
loadData();
});
/**
* 保存数据
*/
const saveData = () => {
const data = {
container: container.value,
children: blocks.value,
};
const str = JSON.stringify(data);
localStorage.setItem("ANI_EDITOR_DATA", str);
};
/**
* 加载数据
*/
const loadData = async () => {
const str = localStorage.getItem("ANI_EDITOR_DATA");
if (!str) {
return;
}
const data = JSON.parse(str);
container.value = data.container;
blocks.value = data.children;
};
/**
* 设置当前选中的组件
*/
const setCurrentBlock = (block: Block | null) => {
for (const block of blocks.value) {
block.actived = false;
}
if (!block) {
current.value.block = null;
return;
}
block.actived = true;
current.value.block = block;
};
/**
* 恢复画布到原始比例和远点
*/
const 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;
}
};
/**
* 提供上下文注入
*/
provide(ContextKey, {
current,
container,
blocks,
refLine,
setCurrentBlock,
setContainerOrigin,
loadData,
saveData,
preview() {
preview.value = true;
},
});
</script>
<style lang="less">
.ani-modal {
.muti-form-item .arco-form-item .arco-form-item-label {
// color: #899;
// font-size: 12px;
line-height: 1;
}
.arco-modal-fullscreen {
display: block;
height: 100%;
}
.arco-modal-header {
display: none;
}
.arco-modal-body {
padding: 0;
height: 100%;
width: 100%;
overflow: hidden;
}
.arco-tabs-nav-vertical .arco-tabs-nav-ink {
display: none;
}
.arco-tabs-nav-vertical.arco-tabs-nav-type-line .arco-tabs-tab {
padding: 4px;
}
.arco-form-item-content-flex {
display: block;
}
.arco-divider-text-left {
left: 0;
padding-left: 0;
}
}
</style>

View File

@ -1,4 +1,4 @@
const sleep = (time: number) => { export const sleep = (time: number) => {
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
setTimeout(() => { setTimeout(() => {
resolve(); resolve();

View File

@ -10,7 +10,7 @@ export default defineComponent({
watch( watch(
() => route.path, () => route.path,
() => { () => {
selectedKeys.value = route.matched.map((i) => i.path); selectedKeys.value = route.matched.map((i) => i.aliasOf?.path ?? i.path);
}, },
{ immediate: true } { immediate: true }
); );
@ -31,7 +31,9 @@ export default defineComponent({
const icon = route.icon ? () => <i class={route.icon} /> : null; const icon = route.icon ? () => <i class={route.icon} /> : null;
const node: any = route.children?.length ? ( const node: any = route.children?.length ? (
<> <>
<div class="px-2"><a-divider margin={6} class="!border-slate-100"></a-divider></div> <div class="px-2">
<a-divider margin={6} class="!border-slate-100"></a-divider>
</div>
{this.renderItem(route?.children)} {this.renderItem(route?.children)}
</> </>
) : ( ) : (

View File

@ -101,7 +101,7 @@ const onSubmitForm = async () => {
try { try {
loading.value = true; loading.value = true;
const res = await api.auth.login(model); const res = await api.auth.login(model);
userStore.setAccessToken(res.data.data as unknown as string); userStore.setAccessToken(res.data.data);
Notification.success({ Notification.success({
title: "登陆提示", title: "登陆提示",
content: `欢迎,您已成功登陆系统!`, content: `欢迎,您已成功登陆系统!`,
@ -124,7 +124,7 @@ const onSubmitForm = async () => {
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1); box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
} }
.login-left { .login-left {
background: rgb(var(--primary-6)) url(/src/pages/_login/image-br.svg) no-repeat center center/90% auto; background: rgb(var(--primary-6)) url(./image-br.svg) no-repeat center center/90% auto;
} }
</style> </style>

View File

@ -12,7 +12,7 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { api } from "@/api"; import { api } from "@/api";
import { Table, useTable } from "@/components"; import { Table, useTable } from "@/components";
import aniEditor from "@/components/editor/index.vue"; import { Editor as aniEditor } from "@/components/editor";
import dayjs from "dayjs"; import dayjs from "dayjs";
const visible = ref(false); const visible = ref(false);

View File

@ -7,11 +7,11 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { api } from "@/api"; import { api } from "@/api";
import { createColumn, updateColumn, useAniTable } from "@/components"; import { createColumn, updateColumn, useAniTable } from "@/components";
import { MenuTypes, MenuType } from "@/constants/menu"; import { MenuType, MenuTypes } from "@/constants/menu";
import { flatedMenus } from "@/router"; import { flatMenus } from "@/router";
import { listToTree } from "@/utils/listToTree"; import { listToTree } from "@/utils/listToTree";
const menuArr = flatedMenus.map((i) => ({ label: i.title, value: i.id })); const menuArr = flatMenus.map((i) => ({ label: i.title, value: i.id }));
const expanded = ref(false); const expanded = ref(false);
const toggleExpand = () => { const toggleExpand = () => {

68
src/router/guards/auth.ts Normal file
View File

@ -0,0 +1,68 @@
import { api } from "@/api";
import { store, useUserStore } from "@/store";
import { useMenuStore } from "@/store/menu";
import { treeFind } from "@/utils/listToTree";
import { Notification } from "@arco-design/web-vue";
import { Router } from "vue-router";
import { menus } from "../menus";
import { APP_HOME_NAME } from "../routes/base";
import { APP_ROUTE_NAME, routes } from "../routes/page";
const WHITE_LIST = ["/:all(.*)*"];
const UNSIGNIN_LIST = ["/login"];
/**
*
* @param to
* @description store pinia-plugin-peristedstate
* @returns
*/
export function useAuthGuard(router: Router) {
api.expireHandler = () => {
const userStore = useUserStore(store);
const redirect = router.currentRoute.value.path;
userStore.clearUser();
router.push({ path: "/login", query: { redirect } });
};
router.beforeEach(async function (to) {
const userStore = useUserStore(store);
const menuStore = useMenuStore(store);
if (to.meta.auth?.some((i) => i === "*")) {
return true;
}
if (WHITE_LIST.includes(to.path)) {
return true;
}
if (UNSIGNIN_LIST.includes(to.path)) {
if (!userStore.accessToken) {
return true;
}
Notification.warning({
title: "跳转提示",
content: `您已登陆,如需重新登陆请退出后再操作!`,
});
return false;
}
if (!userStore.accessToken) {
return { path: "/login", query: { redirect: to.path } };
}
if (!menuStore.menus.length) {
menuStore.setMenus(menus);
menuStore.setHome(import.meta.env.VITE_HOME_PATH);
for (const route of routes) {
router.addRoute(route);
}
const home = treeFind(routes, (i) => i.path === menuStore.home);
if (home) {
const route = { ...home, name: APP_HOME_NAME, alias: "/" };
router.removeRoute(home.name!);
router.addRoute(APP_ROUTE_NAME, route);
return router.replace(to.path);
}
}
return true;
});
return router;
}

View File

@ -1,33 +0,0 @@
import { store, useUserStore } from "@/store";
import { Message } from "@arco-design/web-vue";
import { NavigationGuardWithThis } from "vue-router";
const whitelist = ["/:all(.*)*"];
const signoutlist = ["/login"];
export const authGuard: NavigationGuardWithThis<undefined> = async function (to) {
// 放在外面pinia-plugin-peristedstate 插件会失效
const userStore = useUserStore(store);
if (to.meta?.auth === false) {
return true;
}
if (whitelist.includes(to.path) || to.name === "_all") {
return true;
}
if (signoutlist.includes(to.path)) {
if (userStore.accessToken) {
Message.warning(`提示:您已登陆,如需重新请退出后再操作!`);
return false;
}
return true;
}
if (!userStore.accessToken) {
return {
path: "/login",
query: {
redirect: to.path,
},
};
}
return true;
};

View File

@ -1,31 +0,0 @@
import { NProgress } from "@/libs/nprogress";
import { useAppStore } from "@/store";
import { NavigationGuardWithThis, NavigationHookAfter } from "vue-router";
const routeMap = new Map<string, boolean>();
const before: NavigationGuardWithThis<undefined> = function (to) {
NProgress.start();
if (routeMap.get(to.fullPath)) {
return true;
}
const appStore = useAppStore();
appStore.setPageLoading(true);
};
const after: NavigationHookAfter = function (to) {
NProgress.done();
if (routeMap.get(to.fullPath)) {
return;
}
const appStore = useAppStore();
setTimeout(() => {
appStore.setPageLoading(false);
routeMap.set(to.fullPath, true);
}, 500);
};
export const progressGuard = {
before,
after,
};

View File

@ -1,9 +0,0 @@
import { store, useAppStore } from "@/store";
import { NavigationHookAfter } from "vue-router";
export const titleGuard: NavigationHookAfter = function (to) {
const appStore = useAppStore(store);
const title = to.meta.title || appStore.title;
const subtitle = appStore.subtitle;
document.title = `${title} | ${subtitle}`;
};

View File

@ -0,0 +1,30 @@
import { NProgress } from "@/libs/nprogress";
import { useAppStore } from "@/store";
import { Router } from "vue-router";
const routeMap = new Map<string, boolean>();
export function useProgressGard(router: Router) {
router.beforeEach(function (to) {
NProgress.start();
if (routeMap.get(to.fullPath)) {
return true;
}
const appStore = useAppStore();
appStore.setPageLoading(true);
});
router.afterEach(function (to) {
NProgress.done();
if (routeMap.get(to.fullPath)) {
return;
}
const appStore = useAppStore();
setTimeout(() => {
appStore.setPageLoading(false);
routeMap.set(to.fullPath, true);
}, 500);
});
return router;
}

View File

@ -0,0 +1,12 @@
import { store, useAppStore } from "@/store";
import { Router } from "vue-router";
export function useTitleGuard(router: Router) {
router.beforeEach(function (to) {
const appStore = useAppStore(store);
const title = to.meta.title || appStore.title;
const subtitle = appStore.subtitle;
document.title = `${title} | ${subtitle}`;
});
return router;
}

View File

@ -1,3 +1,4 @@
export * from "./menus"; export * from "./menus";
export * from "./router"; export * from "./router";
export * from "./routes"; export * from "./routes/page";

View File

@ -1,10 +1,10 @@
import { RouteRecordRaw } from "vue-router"; import { RouteRecordRaw } from "vue-router";
import { appRoutes } from "../routes"; import { appRoutes } from "../routes/page";
/** /**
* *
*/ */
interface MenuItem { export interface MenuItem {
id: string; id: string;
parentId?: string; parentId?: string;
path: string; path: string;
@ -23,38 +23,32 @@ interface MenuItem {
function routesToItems(routes: RouteRecordRaw[]): MenuItem[] { function routesToItems(routes: RouteRecordRaw[]): MenuItem[] {
const items: MenuItem[] = []; const items: MenuItem[] = [];
routes.forEach((route) => { for (const route of routes) {
const { meta = {}, parentMeta, path } = route as any;
const { title, sort, icon } = meta;
let id = path;
let paths = route.path.split("/"); let paths = route.path.split("/");
let id = route.path;
let parentId = paths.slice(0, -1).join("/"); let parentId = paths.slice(0, -1).join("/");
if (parentMeta) {
if ((route as any).parentMeta) { const { title, icon, sort } = parentMeta;
id = `${route.path}/index`; id = `${path}/index`;
parentId = route.path; parentId = path;
items.push({ items.push({
id: route.path, title,
icon,
sort,
path,
id: path,
parentId: paths.slice(0, -1).join("/"), parentId: paths.slice(0, -1).join("/"),
path: `${route.path}`,
title: (route as any).parentMeta.title,
icon: (route as any).parentMeta.icon,
sort: (route as any).parentMeta.sort,
}); });
} else { } else {
const p = paths.slice(0, -1).join("/"); const p = paths.slice(0, -1).join("/");
if (routes.some((i) => i.path === p && (i as any).parentMeta)) { if (routes.some((i) => i.path === p) && parentMeta) {
parentId = p; parentId = p;
} }
} }
items.push({ id, title, parentId, path, icon, sort });
items.push({ }
id,
parentId,
path: route.path,
sort: route.meta?.sort,
title: route.meta?.title,
icon: route.meta?.icon,
});
});
return items; return items;
} }
@ -68,18 +62,18 @@ function listToTree(list: MenuItem[]) {
const map: Record<string, MenuItem> = {}; const map: Record<string, MenuItem> = {};
const tree: MenuItem[] = []; const tree: MenuItem[] = [];
list.forEach((item) => { for (const item of list) {
map[item.id] = item; map[item.id] = item;
}); }
list.forEach((item) => { for (const item of list) {
const parent = map[item.parentId as string]; const parent = map[item.parentId as string];
if (parent) { if (parent) {
(parent.children || (parent.children = [])).push(item); (parent.children || (parent.children = [])).push(item);
} else { } else {
tree.push(item); tree.push(item);
} }
}); }
return tree; return tree;
} }
@ -102,31 +96,17 @@ function sort<T extends { children?: T[]; [key: string]: any }>(routes: T[], key
}); });
} }
/**
*
* @param routes
* @returns
*/
function transformToMenuItems(routes: RouteRecordRaw[]) {
const menus = routesToItems(routes);
const tree = listToTree(menus);
return sort(tree);
}
/** /**
* *
*/ */
const flatedMenus = routesToItems(appRoutes); export const flatMenus = routesToItems(appRoutes);
/** /**
* *
*/ */
const treeMenus = listToTree(flatedMenus); export const treeMenus = listToTree(flatMenus);
/** /**
* *
*/ */
const menus = sort(treeMenus); export const menus = sort(treeMenus);
export { menus, treeMenus, flatedMenus };
export type { MenuItem };

View File

@ -1,30 +1,29 @@
import { createRouter, createWebHashHistory } from "vue-router"; import { createRouter } from "vue-router";
import { authGuard } from "../guards/guard-auth"; import { useAuthGuard } from "../guards/auth";
import { progressGuard } from "../guards/guard-progress"; import { useProgressGard } from "../guards/progress";
import { titleGuard } from "../guards/guard-title"; import { useTitleGuard } from "../guards/title";
import { routes } from "../routes"; import { baseRoutes } from "../routes/base";
import { api } from "@/api"; import { historyMode } from "./util";
import { store, useUserStore } from "@/store";
/**
*
*/
export const router = createRouter({ export const router = createRouter({
history: createWebHashHistory(), history: historyMode(),
routes: [ routes: [...baseRoutes],
{
path: "/",
redirect: "/home/home",
},
...routes,
],
}); });
router.beforeEach(progressGuard.before); /**
router.afterEach(progressGuard.after); *
router.beforeEach(authGuard); */
router.afterEach(titleGuard); useProgressGard(router);
api.expireHandler = () => { /**
const userStore = useUserStore(store); *
userStore.clearUser(); */
const redirect = router.currentRoute.value.path; useAuthGuard(router);
router.push({ path: "/login", query: { redirect } });
}; /**
*
*/
useTitleGuard(router);

17
src/router/router/util.ts Normal file
View File

@ -0,0 +1,17 @@
import { createWebHashHistory, createWebHistory } from "vue-router";
/**
*
*/
const HistoryMap = {
web: createWebHistory,
hash: createWebHashHistory,
};
/**
*
*/
export function historyMode() {
const mode = HistoryMap[import.meta.env.VITE_HISTORY];
return mode();
}

14
src/router/routes/base.ts Normal file
View File

@ -0,0 +1,14 @@
import { RouteRecordRaw } from "vue-router";
export const APP_HOME_NAME = "__APP_HOME__";
/**
*
*/
export const baseRoutes: RouteRecordRaw[] = [
{
path: "/",
name: APP_HOME_NAME,
component: () => "Home Page",
},
];

View File

@ -1,10 +1,11 @@
import generatedRoutes from "virtual:generated-pages"; import generatedRoutes from "virtual:generated-pages";
import { RouteRecordRaw } from "vue-router"; import { RouteRecordRaw } from "vue-router";
const APP_ROUTE_NAME = "_layout"; export const TOP_ROUTE_PREF = "_";
export const APP_ROUTE_NAME = "_layout";
/** /**
* *
* @description _ * @description _
*/ */
const transformRoutes = (routes: RouteRecordRaw[]) => { const transformRoutes = (routes: RouteRecordRaw[]) => {
@ -12,11 +13,11 @@ const transformRoutes = (routes: RouteRecordRaw[]) => {
const appRoutes: RouteRecordRaw[] = []; const appRoutes: RouteRecordRaw[] = [];
for (const route of routes) { for (const route of routes) {
if ((route.name as string)?.startsWith("_")) { if ((route.name as string)?.startsWith(TOP_ROUTE_PREF)) {
if (route.name === APP_ROUTE_NAME) { if (route.name === APP_ROUTE_NAME) {
route.children = appRoutes; route.children = appRoutes;
} }
route.path = route.path.replace("_", ""); route.path = route.path.replace(TOP_ROUTE_PREF, "");
topRoutes.push(route); topRoutes.push(route);
continue; continue;
} }

View File

@ -1,43 +1,13 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
interface PageTag {
id: string;
title: string;
path: string;
closable?: boolean;
closible?: boolean;
actived?: boolean;
}
export const useAppStore = defineStore({ export const useAppStore = defineStore({
id: "app", id: "app",
state: () => ({ state: (): AppStore => ({
/**
*
*/
isDarkMode: false, isDarkMode: false,
/**
*
*/
title: import.meta.env.VITE_TITLE, title: import.meta.env.VITE_TITLE,
/**
*
*/
subtitle: import.meta.env.VITE_SUBTITLE, subtitle: import.meta.env.VITE_SUBTITLE,
/**
*
*/
pageLoding: false, pageLoding: false,
pageTags: [ pageTags: [],
{
id: "/",
title: "首页",
path: "/",
closable: false,
closible: false,
actived: false,
},
] as PageTag[],
}), }),
actions: { actions: {
/** /**
@ -46,6 +16,7 @@ export const useAppStore = defineStore({
toggleDark() { toggleDark() {
this.isDarkMode ? this.setLight() : this.setDark(); this.isDarkMode ? this.setLight() : this.setDark();
}, },
/** /**
* *
*/ */
@ -54,6 +25,7 @@ export const useAppStore = defineStore({
document.body.classList.remove("dark"); document.body.classList.remove("dark");
this.isDarkMode = false; this.isDarkMode = false;
}, },
/** /**
* *
*/ */
@ -62,12 +34,14 @@ export const useAppStore = defineStore({
document.body.classList.add("dark"); document.body.classList.add("dark");
this.isDarkMode = true; this.isDarkMode = true;
}, },
/** /**
* loading * loading
*/ */
setPageLoading(loading: boolean) { setPageLoading(loading: boolean) {
this.pageLoding = loading; this.pageLoding = loading;
}, },
/** /**
* *
* @param tag * @param tag
@ -83,14 +57,13 @@ export const useAppStore = defineStore({
actived: false, actived: false,
...tag, ...tag,
}); });
console.log(this.pageTags);
}, },
/** /**
* *
* @param tag * @param tag
*/ */
delPageTag(tag: PageTag) { delPageTag(tag: PageTag) {
console.log("del page tag");
const index = this.pageTags.findIndex((i) => i.id === tag.id); const index = this.pageTags.findIndex((i) => i.id === tag.id);
if (index > -1) { if (index > -1) {
this.pageTags.splice(index, 1); this.pageTags.splice(index, 1);
@ -99,3 +72,35 @@ export const useAppStore = defineStore({
}, },
persist: !import.meta.env.DEV, persist: !import.meta.env.DEV,
}); });
interface AppStore {
/**
*
*/
isDarkMode: boolean;
/**
*
*/
title: string;
/**
*
*/
subtitle: string;
/**
*
*/
pageLoding: boolean;
/**
*
*/
pageTags: PageTag[];
}
interface PageTag {
id: string;
title: string;
path: string;
closable?: boolean;
closible?: boolean;
actived?: boolean;
}

39
src/store/menu/index.ts Normal file
View File

@ -0,0 +1,39 @@
import { MenuItem } from "@/router";
import { defineStore } from "pinia";
export const useMenuStore = defineStore({
id: "menu",
state: (): MenuStore => {
return {
menus: [],
home: "",
};
},
actions: {
/**
*
*/
setMenus(menus: MenuItem[]) {
this.menus = menus;
},
/**
*
* @param path
*/
setHome(path: string) {
this.home = path;
}
},
});
export interface MenuStore {
/**
*
*/
menus: MenuItem[];
/**
*
*/
home: string;
}

View File

@ -2,4 +2,5 @@ import { createPinia } from "pinia";
import persistedstatePlugin from "pinia-plugin-persistedstate"; import persistedstatePlugin from "pinia-plugin-persistedstate";
export const store = createPinia(); export const store = createPinia();
store.use(persistedstatePlugin);
store.use(persistedstatePlugin);

View File

@ -2,31 +2,13 @@ import { defineStore } from "pinia";
export const useUserStore = defineStore({ export const useUserStore = defineStore({
id: "user", id: "user",
state: () => { state: (): UserStore => {
return { return {
/**
* ID
*/
id: 0, id: 0,
/**
*
*/
username: "juetan", username: "juetan",
/**
*
*/
nickname: "绝弹", nickname: "绝弹",
/** `
*
*/
avatar: "https://github.com/juetan.png", avatar: "https://github.com/juetan.png",
/**
* JWT
*/
accessToken: "", accessToken: "",
/**
*
*/
refreshToken: undefined, refreshToken: undefined,
}; };
}, },
@ -38,7 +20,11 @@ export const useUserStore = defineStore({
this.accessToken = token; this.accessToken = token;
}, },
setAccessToken(token: string) { /**
* 访
* @param token
*/
setAccessToken(token?: string) {
this.accessToken = token; this.accessToken = token;
}, },
@ -52,13 +38,41 @@ export const useUserStore = defineStore({
/** /**
* *
*/ */
setUser(user: any) { setUser(user: Partial<UserStore>) {
this.id = user.id; const { id, username, nickname, avatar, accessToken } = user;
this.username = user.username; id && (this.id = id);
this.nickname = user.nickname; username && (this.username = username);
this.avatar = user.avatar; nickname && (this.nickname = nickname);
this.accessToken = user.token; avatar && (this.avatar = avatar);
accessToken && (this.accessToken = accessToken);
}, },
}, },
persist: true, persist: true,
}); });
export interface UserStore {
/**
* ID
*/
id: number;
/**
*
*/
username: string;
/**
*
*/
nickname: string;
/**
*
*/
avatar?: string;
/**
* JWT
*/
accessToken?: string;
/**
*
*/
refreshToken?: string;
}

View File

@ -61,30 +61,30 @@ declare module '@vue/runtime-core' {
ATree: typeof import('@arco-design/web-vue')['Tree'] ATree: typeof import('@arco-design/web-vue')['Tree']
AUpload: typeof import('@arco-design/web-vue')['Upload'] AUpload: typeof import('@arco-design/web-vue')['Upload']
BaseOption: typeof import('./../components/editor/components/BaseOption.vue')['default'] BaseOption: typeof import('./../components/editor/components/BaseOption.vue')['default']
Block: typeof import('./../components/editor/panel-main/components/block.vue')['default']
BreadCrumb: typeof import('./../components/breadcrumb/bread-crumb.vue')['default'] BreadCrumb: typeof import('./../components/breadcrumb/bread-crumb.vue')['default']
BreadPage: typeof import('./../components/breadcrumb/bread-page.vue')['default'] BreadPage: typeof import('./../components/breadcrumb/bread-page.vue')['default']
ColorPicker: typeof import('./../components/editor/components/ColorPicker.vue')['default'] ColorPicker: typeof import('./../components/editor/components/ColorPicker.vue')['default']
DragResizer: typeof import('./../components/editor/components/DragResizer.vue')['default'] DragResizer: typeof import('./../components/editor/components/DragResizer.vue')['default']
Editor: typeof import('./../components/editor/index.vue')['default'] Editor: typeof import('./../components/editor/components/Editor.vue')['default']
EditorPreview: typeof import('./../components/editor/components/EditorPreview.vue')['default']
Empty: typeof import('./../components/empty/index.vue')['default'] Empty: typeof import('./../components/empty/index.vue')['default']
Header: typeof import('./../components/editor/panel-main/components/header.vue')['default']
ImagePicker: typeof import('./../components/editor/components/ImagePicker.vue')['default'] ImagePicker: typeof import('./../components/editor/components/ImagePicker.vue')['default']
InputColor: typeof import('./../components/editor/components/InputColor.vue')['default'] InputColor: typeof import('./../components/editor/components/InputColor.vue')['default']
InputImage: typeof import('./../components/editor/components/InputImage.vue')['default'] InputImage: typeof import('./../components/editor/components/InputImage.vue')['default']
InputTexter: typeof import('./../components/editor/components/InputTexter.vue')['default']
Marquee: typeof import('./../components/editor/blocks/text/marquee.vue')['default'] Marquee: typeof import('./../components/editor/blocks/text/marquee.vue')['default']
Option: typeof import('./../components/editor/blocks/date/option.vue')['default'] Option: typeof import('./../components/editor/blocks/date/option.vue')['default']
Page403: typeof import('./../components/error/page-403.vue')['default'] Page403: typeof import('./../components/error/page-403.vue')['default']
PanelHeader: typeof import('./../components/editor/panel-header/index.vue')['default'] PanelHeader: typeof import('./../components/editor/components/PanelHeader.vue')['default']
PanelLeft: typeof import('./../components/editor/panel-left/index.vue')['default'] PanelLeft: typeof import('./../components/editor/components/PanelLeft.vue')['default']
PanelMain: typeof import('./../components/editor/panel-main/index.vue')['default'] PanelMain: typeof import('./../components/editor/components/PanelMain.vue')['default']
PanelRight: typeof import('./../components/editor/panel-right/index.vue')['default'] PanelMainBlock: typeof import('./../components/editor/components/PanelMainBlock.vue')['default']
Preview: typeof import('./../components/editor/preview/index.vue')['default'] PanelMainHeader: typeof import('./../components/editor/components/PanelMainHeader.vue')['default']
PanelRight: typeof import('./../components/editor/components/PanelRight.vue')['default']
Render: typeof import('./../components/editor/blocks/date/render.vue')['default'] Render: typeof import('./../components/editor/blocks/date/render.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
'Temp.dev1': typeof import('./../components/breadcrumb/temp.dev1.vue')['default'] 'Temp.dev1': typeof import('./../components/breadcrumb/temp.dev1.vue')['default']
Texter: typeof import('./../components/editor/panel-main/components/texter.vue')['default']
Toast: typeof import('./../components/toast/toast.vue')['default'] Toast: typeof import('./../components/toast/toast.vue')['default']
} }
} }

9
src/types/env.d.ts vendored
View File

@ -1,4 +1,3 @@
import 'vite';
interface ImportMetaEnv { interface ImportMetaEnv {
/** /**
@ -25,6 +24,14 @@ interface ImportMetaEnv {
* *
*/ */
VITE_PORT: number; VITE_PORT: number;
/**
*
*/
VITE_HOME_PATH: string;
/**
*
*/
VITE_HISTORY: "web" | "hash";
} }
interface ImportMeta { interface ImportMeta {

View File

@ -1,7 +1,7 @@
import 'vue-router'; import "vue-router";
declare module 'vue-router' { declare module "vue-router" {
interface RouteRecordRaw { interface RouteRecordSingleView {
parentMeta: { parentMeta: {
/** /**
* *
@ -34,7 +34,7 @@ declare module 'vue-router' {
/** /**
* *
*/ */
hidden?: boolean; hide?: boolean;
/** /**
* *
*/ */

View File

@ -33,3 +33,29 @@ export function treeEach(tree: any[], fn: (item: any) => void, before = true) {
!before && fn(item); !before && fn(item);
} }
} }
/**
*
* @param tree
* @param fn
* @returns
*/
export function treeFind<T extends { children?: T[]; [key: string]: any } = any>(
tree: T[],
fn: (item: T) => boolean
): T | null {
let data: T | null = null;
for (const item of tree) {
if (fn(item)) {
data = item;
break;
}
if (item.children) {
data = treeFind(item.children, fn);
if (data) {
break;
}
}
}
return data;
}