init project

This commit is contained in:
Timi
2025-11-05 15:21:23 +08:00
parent e52250367a
commit dc2e923e99
55 changed files with 6815 additions and 3 deletions

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
# http://editorconfig.org
root = true
[*]
charset = utf-8
indent_size = 4
indent_style = tab
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

15
.eslintignore Normal file
View File

@@ -0,0 +1,15 @@
*.sh
node_modules
*.md
*.woff
*.ttf
.vscode
.idea
dist
/public
/docs
.husky
.local
.eslintrc.js
dist
pnpm-lock.yaml

78
.eslintrc.js Normal file
View File

@@ -0,0 +1,78 @@
module.exports = {
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:vue/vue3-essential",
"./.eslintrc-auto-import.json"
],
"overrides": [
{
"env": {
"node": true
},
"files": [
".eslintrc.{js}"
],
"parserOptions": {
"sourceType": "script"
}
}
],
"parserOptions": {
"ecmaVersion": "latest",
"parser": "@typescript-eslint/parser",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint",
"vue"
],
"rules": { // 注释是解释使用该设置的效果,而不是设置属性本身
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
// 其他
"camelcase": 2, // 变量驼峰式命名
"quotes": ["error", "double"], // 强制双引字符串
"eqeqeq": ["error", "always"], // 强制全等比较
"semi": ["error", "always"], // 强制语句分号结束
"max-len": [
"error",
{
"code": 180
}
],
// 逗号
"comma-style": [2, "last"], // 逗号出现在行末 [first, last]
"comma-dangle": [2, "never"], // 数组或对象不可带最后一个逗号 [never, always, always-multiline]
// 空格
"no-trailing-spaces": "error", // 禁止行末存在空格
"comma-spacing": [2, { "before": false, "after": true }], // 逗号后需要空格
"semi-spacing": ["error", { "before": false, "after": true }], // 分号后需要空格
"computed-property-spacing": [2, "never"], // 以方括号取对象属性时,[ 后面和 ] 前面需要空格, [never, always]
"space-before-function-paren": ["error", { // 函数括号前空格
"anonymous": "always", // 针对匿名函数表达式,比如 function () {}
"named": "never", // 针对命名函数表达式,比如 function foo() {}
"asyncArrow": "always" // 针对异步的箭头函数表达式,比如 async () => {}
}],
// 缩进
"no-mixed-spaces-and-tabs": "off", // 允许混合缩进
"no-tabs": ["error", { allowIndentationTabs: true }], // 使用 Tab 缩进
"indent": ["error", "tab", { // Tab 缩进相关
SwitchCase: 1 // Switch Case 缩进一级
}],
// 框架
"@typescript-eslint/ban-types": "off", // TS 允许空对象
"@typescript-eslint/no-empty-function": "off", // TS 允许空函数
"@typescript-eslint/no-explicit-any": "off", // TS 允许 any 类型
"@typescript-eslint/explicit-module-boundary-types": "off", // TS 允许显式模块边界类型?
"vue/no-multiple-template-root": "off", // Vue3 支持多个根节点
"@typescript-eslint/no-this-alias": "off", // 允许 this 变量本地化
"vue/no-v-model-argument": "off", // 允许 v-model 支持参数
"vue/multi-word-component-names": "off" // 允许单个词语的组件名
}
};

4
.gitignore vendored
View File

@@ -1,3 +1,7 @@
/examples/auto-imports.d.ts
/components.d.ts
/.eslintrc-auto-import.json
# ---> Vue
# gitignore template for Vue.js projects
#

5
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

12
.idea/common4web.iml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

7
.idea/jsLinters/eslint.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EslintConfiguration">
<work-dir-patterns value="$PROJECT_DIR$" />
<custom-configuration-file used="true" path="$PROJECT_DIR$/.eslintrc.js" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/common4web.iml" filepath="$PROJECT_DIR$/.idea/common4web.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
shamefully-hoist=true

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 yeyu
Copyright (c) 2025 timi
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including

View File

@@ -1,3 +1,18 @@
# common4web
# Vue 3 + TypeScript + Vite
前端通用库
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support For `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.

20
examples/Root.vue Normal file
View File

@@ -0,0 +1,20 @@
<template>
<div class="root">
hello world
</div>
<popup />
</template>
<script lang="ts" setup>
import { Popup } from "common4web";
</script>
<style lang="less" scoped>
.root {
width: 80%;
.sp {
height: 520px;
}
}
</style>

10
examples/main.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from "vue";
import Root from "./Root.vue";
import common4Web, { axios, VPopup } from "common4web"; // 本地开发
axios.defaults.baseURL = "http://localhost:8091";
const app = createApp(Root);
app.use(common4Web);
app.directive("popup", VPopup);
app.mount("#root");

7
examples/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
declare module "prismjs";

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/examples/main.ts"></script>
</body>
</html>

66
package.json Normal file
View File

@@ -0,0 +1,66 @@
{
"name": "common4web",
"main": "./dist/common4web.umd.js",
"types": "./dist/src/index.d.ts",
"module": "./dist/common4web.mjs",
"style": "./dist/style.css",
"private": false,
"version": "1.0.0",
"license": "MIT",
"scripts": {
"dev": "vite",
"dev:doc": "pnpm run -C docs dev",
"build": "vue-tsc --noEmit && vite build",
"build:doc": "pnpm run -C docs build"
},
"files": [
"dist/**",
"src/**",
"examples/**",
"README.md",
"package.json"
],
"exports": {
".": {
"import": "./dist/common4web.mjs",
"require": "./dist/common4web.umd.js"
},
"./style.css": "./dist/common4web.css"
},
"engines": {
"node": ">=16.0.0"
},
"dependencies": {
"axios": "1.12.0",
"less": "4.4.2",
"marked": "^16.4.1",
"marked-gfm-heading-id": "^4.1.1",
"marked-highlight": "^2.2.1",
"marked-mangle": "^1.1.10",
"prismjs": "1.30.0",
"terser": "^5.39.0"
},
"devDependencies": {
"@types/marked": "^6.0.0",
"@types/node": "^22.15.2",
"@types/prismjs": "1.26.5",
"@typescript-eslint/eslint-plugin": "^8.31.0",
"@typescript-eslint/parser": "^8.31.0",
"@vitejs/plugin-vue": "^5.2.3",
"eslint": "^9.25.1",
"eslint-config-prettier": "^10.1.2",
"eslint-define-config": "^2.1.0",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-vue": "^10.0.0",
"prettier": "^3.5.3",
"typescript": "^5.8.3",
"unplugin-auto-import": "^20.2.0",
"unplugin-vue-components": "^30.0.0",
"vite": "7.1.11",
"vite-plugin-dts": "^4.5.3",
"vite-plugin-prismjs": "^0.0.11",
"vite-plugin-vue-setup-extend": "^0.4.0",
"vue": "^3.5.13",
"vue-tsc": "^3.1.2"
}
}

3464
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,201 @@
@import url(~/assets/style/variable);
*::-webkit-scrollbar {
width: 10px !important;
height: 10px !important;
cursor: var(--tui-cur-default);
background: #CFD2E0;
}
*::-webkit-scrollbar-corner {
background: #CFD2E0;
cursor: var(--tui-cur-default);
}
*::-webkit-scrollbar-thumb {
cursor: var(--tui-cur-default);
background: #525870;
}
*::selection {
color: #FFF;
background: #525870 !important;
}
html {
cursor: var(--tui-cur-default);
}
body {
width: 100% !important;
margin: 0;
padding: 0;
overflow-x: hidden !important;
overflow-y: scroll !important;
font-family: var(--tui-font);
-webkit-text-size-adjust: 100%;
&::-webkit-scrollbar {
background: transparent;
}
#root {
display: flex;
position: relative;
justify-content: center;
}
}
a {
color: @tuiColors[blue];
cursor: var(--tui-cur-pointer);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
a.underline {
text-decoration: underline;
}
img {
display: block;
}
input, select, textarea {
resize: none;
outline: none;
display: block;
background: transparent;
}
label,
select,
input[type="radio"],
input[type="file"],
input[type="checkbox"] {
cursor: var(--tui-cur-default);
}
textarea,
input[type="text"],
input[type="date"],
input[type="email"],
input[type="password"] {
cursor: var(--tui-cur-text);
-webkit-appearance: none;
&:-webkit-autofill,
&:-webkit-autofill:hover,
&:-webkit-autofill:focus,
&:-webkit-autofill:active {
-webkit-background-clip: text;
}
}
textarea {
tab-size: 4;
font-family: var(--tui-font);
}
.gray-filter {
filter: grayscale(1);
-webkit-filter: grayscale(1);
}
.bold {
font-weight: bold !important;
}
.italic {
font-style: italic !important;
}
.delete {
text-decoration: line-through !important;
}
.underline {
text-decoration: underline !important;
}
.not-underline {
text-decoration: none !important;
}
.not-underline:hover {
text-decoration: none !important;
}
/* 文本适当对齐 */
.justify-text {
text-align: justify;
}
/* 模糊玻璃效果:白色 */
.glass-white {
color: var(--eui-black, #000);
background: rgba(255, 255, 255, .8);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
/* 模糊玻璃效果:黑色 */
.glass-black {
color: var(--eui-white, #FFF);
background: rgba(0, 0, 0, .8);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.word-space {
display: inline-block;
margin: 0 .5rem;
}
/* 强制换行 */
.break-all,
.break-all textarea {
word-wrap: break-word;
word-break: break-all;
white-space: normal;
}
/* 文本溢出截断 */
.clip-text {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* 禁止选择 */
.diselect {
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.selectable {
-webkit-touch-callout: default;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
}
.ir-default,
.ir-pixelated {
image-rendering: pixelated;
}
.ir-auto {
image-rendering: auto;
}
.ir-smooth {
image-rendering: smooth;
}

View File

@@ -0,0 +1,44 @@
@tuiColors: {
red: #F33;
pink: #FF7A9B;
black: #111;
blue: #006EFF;
light-blue: #00A6FF;
green: GREEN;
orange: #E7913B;
gray: #666;
light-gray: #AAA;
white: #FFF;
dark-white: #E7EAEF;
yellow: #FF0;
purple: PURPLE;
transparent: transparent;
}
:root {
--tui-font: PingFang SC, Microsoft YaHei, Arial Regular;
--tui-shadow: 3px 3px 12px var(--tui-shadow-color);
--tui-bezier: cubic-bezier(.19, .1, .22, 1);
--tui-shadow-color: rgba(0, 0, 0, .2);
each(@tuiColors, {
--tui-@{key}: @value;
});
--tui-border: 1px solid var(--tui-light-gray);
--tui-page-padding: .5rem 1rem;
}
// 字体颜色
each(@tuiColors, {
.@{key} {
color: @value;
}
});
// 背景颜色
each(@tuiColors, {
.bg-@{key} {
background: @value;
}
});

View File

@@ -0,0 +1,5 @@
import view from "./index.vue";
import Toolkit from "~/utils/Toolkit";
export const Captcha = Toolkit.withInstall(view);
export default Captcha;

View File

@@ -0,0 +1,46 @@
<template>
<img
class="tui-captcha ir-pixelated"
v-if="src"
:width="width"
:height="height"
:src="src"
alt="验证码"
@click="update()"
/>
</template>
<script lang="ts" setup>
import Toolkit from "~/utils/Toolkit";
defineOptions({
name: "Captcha"
});
const props = withDefaults(defineProps<{
width: number,
height: number,
from: string,
api: string,
}>(), {});
const { width, height, from, api } = toRefs(props);
const src = ref("");
function update() {
src.value = `${api.value}?from=${from.value}&width=${width.value}&height=${height.value}&r=${Toolkit.random(0, 999999)}`;
}
onMounted(update);
defineExpose({
update
});
</script>
<style lang="less" scoped>
.tui-captcha {
cursor: var(--tui-cur-pointer);
border: 1px solid gray;
display: block;
pointer-events: all;
}
</style>

19
src/components/index.ts Normal file
View File

@@ -0,0 +1,19 @@
/** 导出所有组件 */
import Popup from "./popup";
import Captcha from "./captcha";
import MarkdownView from "./markdown-view";
import MarkdownEditor from "./markdown-editor";
export default [
Popup,
Captcha,
MarkdownView,
MarkdownEditor
];
export {
Popup,
Captcha,
MarkdownView,
MarkdownEditor
};

View File

@@ -0,0 +1,112 @@
// from element ui
// https://github.com/ElemeFE/element/blob/dev/packages/input/src/calcTextareaHeight.js
let tempTextArea: HTMLTextAreaElement | null;
const HIDDEN_STYLE = `
height:0 !important;
visibility:hidden !important;
overflow:hidden !important;
position:absolute !important;
z-index:-1000 !important;
top:0 !important;
right:0 !important
`;
const CONTEXT_STYLE = [
"letter-spacing",
"line-height",
"padding-top",
"padding-bottom",
"font-family",
"font-weight",
"font-size",
"text-rendering",
"text-transform",
"width",
"text-indent",
"padding-left",
"padding-right",
"border-width",
"box-sizing"
];
type NodeStyling = {
contextStyle: string;
paddingSize: number;
borderSize: number;
boxSizing: string;
}
export type Result = {
height: number;
minHeight: number;
}
function calculateNodeStyling(targetElement: HTMLTextAreaElement): NodeStyling {
const style = window.getComputedStyle(targetElement);
const boxSizing = style.getPropertyValue("box-sizing");
const paddingSize = (
parseFloat(style.getPropertyValue("padding-bottom")) +
parseFloat(style.getPropertyValue("padding-top"))
);
const borderSize = (
parseFloat(style.getPropertyValue("border-bottom-width")) +
parseFloat(style.getPropertyValue("border-top-width"))
);
const contextStyle = CONTEXT_STYLE
.map(name => `${name}:${style.getPropertyValue(name)}`)
.join(";");
return {contextStyle, paddingSize, borderSize, boxSizing};
}
export default function calc(el: HTMLTextAreaElement, minRows = 1, maxRows?: number) {
if (!tempTextArea) {
tempTextArea = document.createElement("textarea") as HTMLTextAreaElement;
document.body.appendChild(tempTextArea);
}
const {paddingSize, borderSize, boxSizing, contextStyle} = calculateNodeStyling(el);
tempTextArea.setAttribute("style", `${contextStyle};${HIDDEN_STYLE}`);
tempTextArea.value = el.value || el.placeholder || "";
let height = tempTextArea.scrollHeight;
const result: Result = {
height: 0,
minHeight: 0
};
if (boxSizing === "border-box") {
height = height + borderSize;
} else if (boxSizing === "content-box") {
height = height - paddingSize;
}
tempTextArea.value = "";
const singleRowHeight = tempTextArea.scrollHeight - paddingSize;
if (minRows) {
let minHeight = singleRowHeight * minRows;
if (boxSizing === "border-box") {
minHeight = minHeight + paddingSize + borderSize;
}
height = Math.max(minHeight, height);
result.minHeight = minHeight;
}
if (maxRows) {
let maxHeight = singleRowHeight * maxRows;
if (boxSizing === "border-box") {
maxHeight = maxHeight + paddingSize + borderSize;
}
height = Math.min(maxHeight, height);
}
result.height = height;
tempTextArea.parentNode && tempTextArea.parentNode.removeChild(tempTextArea);
tempTextArea = null;
return result;
}

View File

@@ -0,0 +1,5 @@
import view from "./index.vue";
import Toolkit from "~/utils/Toolkit";
export const MarkdownEditor = Toolkit.withInstall(view);
export default MarkdownEditor;

View File

@@ -0,0 +1,192 @@
<template>
<div
ref="root"
class="tui-markdown-editor diselect"
:class="{ 'fold': isFold }"
>
<div class="editor">
<div class="header">
<slot name="editorHeader">
<h4 class="title">
<span>源码</span>
<span class="light-gray word-space">Markdown</span>
</h4>
</slot>
</div>
<slot name="editor">
<textarea ref="textArea" class="text-area" v-model="_data"></textarea>
</slot>
</div>
<div class="preview" :class="{ 'showing': showingPreview }">
<div class="header">
<div
v-if="isFold"
class="icon cur-pointer"
v-text="showingPreview ? '>' : '<'"
@click="showingPreview = !showingPreview"
>
</div>
<slot name="previewHeader">
<h4 class="title">预览</h4>
</slot>
</div>
<div ref="previewContent" class="content">
<markdown-view :content="_data" />
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { MarkdownView } from "~/index";
import calcHeight from "./CalcTextareaHeight";
defineOptions({
name: "MarkdownEditor"
});
const props = withDefaults(defineProps<{
data?: string,
minRows?: number,
maxRows?: number
}>(), {
data: "",
minRows: 8,
maxRows: 32
});
const { data, minRows, maxRows } = toRefs(props);
const _data = ref(data.value);
const textArea = ref<HTMLTextAreaElement>();
const textAreaHeight = ref(30);
const previewContent = ref<HTMLDivElement>();
const emit = defineEmits(["update:data"]);
watch(_data, () => {
emit("update:data", _data.value);
calcTextAreaHeight();
});
watch(data, () => {
_data.value = data.value;
calcTextAreaHeight();
});
// 自适应折叠预览
const root = ref<HTMLDivElement>();
const isFold = ref(false);
const showingPreview = ref(false);
onMounted(() => {
if (root.value) {
const foldObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
if (entries && 0 < entries.length) {
isFold.value = entries[0].contentRect.width < 650;
}
});
foldObserver.observe(root.value);
}
});
// 自适应高度
const calcTextAreaHeight = () => {
if (textArea.value) {
textAreaHeight.value = calcHeight(textArea.value, minRows.value, maxRows.value).height;
}
};
onMounted(() => {
calcTextAreaHeight();
if (textArea.value) {
const textareaHeightObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
if (entries && 0 < entries.length) {
if (previewContent.value) {
previewContent.value.style.height = entries[0].contentRect.height + "px";
}
}
});
textareaHeightObserver.observe(textArea.value);
}
});
defineExpose({
textArea
});
</script>
<style lang="less" scoped>
.tui-markdown-editor {
width: 100%;
border: var(--tui-border);
display: flex;
overflow: hidden;
position: relative;
.header {
display: flex;
align-items: center;
border-bottom: 1px solid var(--tui-dark-white);
.icon {
margin-left: .5rem;
}
}
.title {
margin: 0;
height: 30px;
display: flex;
line-height: 30px;
padding-left: 10px;
}
.editor {
width: 50%;
display: flex;
border-right: var(--tui-border);
flex-direction: column;
.text-area {
width: calc(100% - .5rem * 2);
height: v-bind("textAreaHeight + 'px'");
resize: none;
border: none;
outline: none;
padding: .5rem;
font-size: 14px;
word-wrap: normal;
white-space: nowrap;
}
}
.preview {
width: 50%;
background: #FFF;
.content {
padding: .5rem 1rem;
overflow-y: auto;
}
}
&.fold {
.editor {
width: 100%;
}
.preview {
left: calc(100% - 5rem);
width: calc(100% - 2rem);
height: 100%;
z-index: 1;
position: absolute;
border-left: var(--tui-border);
transition: left .5s var(--tui-bezier);
box-shadow: -2px 0 0 var(--tui-shadow-color);
&.showing {
left: 2rem;
}
}
}
}
</style>

View File

@@ -0,0 +1,6 @@
import view from "./index.vue";
import Toolkit from "~/utils/Toolkit";
import "./style.less";
export const MarkdownView = Toolkit.withInstall(view);
export default MarkdownView;

View File

@@ -0,0 +1,42 @@
<template>
<div
class="tui-markdown-view selectable break-all line-numbers"
v-html="markdownHTML"
:data-max-height="maxHeight"
></div>
</template>
<script lang="ts" setup>
import Prism from "prismjs";
import Markdown from "~/utils/Markdown";
defineOptions({
name: "MarkdownView"
});
const props = withDefaults(defineProps<{
content?: string;
showCodeBorder?: boolean;
maxHeight?: string;
}>(), {
content: "",
showCodeBorder: true,
maxHeight: "400px"
});
const { content } = toRefs(props);
const markdownHTML = ref("");
const doRender = async () => {
markdownHTML.value = await Markdown.getInstance().toHTML(content.value);
await nextTick();
Prism.highlightAll();
};
watch(() => props.content, doRender);
onMounted(doRender);
</script>
<style lang="less" scoped>
.tui-markdown-view {
width: 100%;
overflow: auto;
}
</style>

View File

@@ -0,0 +1,341 @@
@import url(~/assets/style/variable);
.tui-markdown-view {
font-size: 14px;
line-height: 1.5;
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 2rem 0 .5rem 0;
padding: .25rem 1rem;
position: relative;
border-left: .4rem solid var(--tui-light-blue);
font-weight: bold;
}
p:first-child,
h1:first-child,
h2:first-child,
h3:first-child,
h4:first-child,
h5:first-child,
h6:first-child {
margin: 0;
}
h1:hover a.anchor,
h2:hover a.anchor,
h3:hover a.anchor,
h4:hover a.anchor,
h5:hover a.anchor,
h6:hover a.anchor {
text-decoration: none;
}
h2 a,
h3 a {
color: #34495E;
}
h1 {
font-size: 1rem;
line-height: 1.2;
}
h2 {
font-size: 1rem;
line-height: 1.2;
}
h3 {
font-size: 1rem;
line-height: 1.2;
}
h4 {
font-size: 1rem;
}
h5 {
font-size: .8rem;
}
h6 {
color: #777;
font-size: 1rem;
}
p,
ul,
ol,
dl,
table,
blockquote {
margin: .5rem 0;
min-height: 2em;
}
p {
text-indent: 2em;
}
blockquote p {
text-indent: 0;
}
code {
padding: .2em .5em;
background: #EEE;
box-sizing: border-box;
border-radius: 2px;
}
li > ol,
li > ul {
margin: 0 0;
}
li > p {
text-indent: 0;
}
hr {
height: 2px;
margin: 16px 0;
border: 0 none;
padding: 0;
overflow: hidden;
background: #E7E7E7;
box-sizing: content-box;
}
h1 p,
h2 p,
h3 p,
h4 p,
h5 p,
h6 p {
margin-top: 0;
}
li p.first {
display: inline-block;
}
ul,
ol {
padding-left: 30px;
}
ul:first-child,
ol:first-child {
margin-top: 0;
}
ul:last-child,
ol:last-child {
margin-bottom: 0;
}
ul {
list-style-type: disc;
}
video {
width: 100%;
background: #000;
}
iframe {
width: 100%;
height: 520px;
}
blockquote {
padding: 2px 10px;
background: rgba(153, 153, 153, .1);
border-left: 4px solid #EDEDED;
}
table {
padding: 0;
margin: 0 auto;
min-width: 240px;
max-width: 100%;
word-break: initial;
border-spacing: 0;
border-collapse: collapse;
}
table thead {
background: #F2F2F2;
}
table tr {
margin: 0;
padding: 0;
border-top: 1px solid #DFE2E5;
}
table tr:nth-child(2n) {
background-color: #FAFAFA;
}
table tr th {
border: 1px solid #DFE2E5;
margin: 0;
padding: 2px 13px;
text-align: left;
font-weight: bold;
border-bottom: 0;
}
table tr td {
border: 1px solid #DFE2E5;
margin: 0;
padding: 0 13px;
word-wrap: break-word;
word-break: break-all;
text-align: left;
white-space: normal;
}
table tr th:first-child,
table tr td:first-child {
margin-top: 0;
}
table tr th:last-child,
table tr td:last-child {
margin-bottom: 0;
}
tt {
color: #e96900;
padding: 2px 4px;
font-size: 0.92rem;
background: #F8F8F8;
border-radius: 2px;
}
tt {
margin: 0 2px;
}
.block {
display: block;
}
.center {
text-align: center;
justify-content: center;
}
.border {
border: 1px solid #525870;
}
.media {
margin: .5rem auto;
display: block;
max-width: 100%;
+ .media-tips {
color: #777;
display: block;
text-align: center;
margin-bottom: 2rem;
}
}
// 代码
pre[class*="language-"] {
border: 1px solid #B8BBC9;
padding: 0 !important;
position: relative;
overflow: auto;
font-size: 14px;
background: transparent;
max-height: var(data-max-height);
transition: max-height .5s var(--tui-bezier);
font-family: var(--td-font-family);
line-height: 1;
border-radius: 0;
code {
color: #333;
background: transparent;
text-shadow: none !important;
font-family: var(--td-font-family);
.line-numbers-rows {
left: 0;
float: left;
z-index: 1;
position: sticky;
background: rgba(242, 242, 242, .9);
letter-spacing: 1px;
>span::before {
padding-right: .2rem;
}
}
.codes {
position: absolute;
min-width: calc(100% - 4.6em);
padding-right: .5em;
.token.namespace {
opacity: 1;
}
.token.punctuation {
color: #333;
}
.token.constant {
color: #FF7A9B;
}
.token.annotation {
color: purple;
}
.token.function {
color: #777;
}
.token.class-name {
color: #FF461F;
}
.token.generics .class-name {
color: #895532;
font-weight: bold;
}
.token.comment {
color: #999;
}
.token.string {
color: #55AA55;
}
.token.number {
color: #EB9354;
}
.token.keyword,
.token.boolean {
color: #177CB0;
}
}
}
}
}

View File

@@ -0,0 +1,5 @@
import view from "./index.vue";
import Toolkit from "~/utils/Toolkit";
export const Popup = Toolkit.withInstall(view);
export default Popup;

View File

@@ -0,0 +1,26 @@
<template>
<div id="tui-popup"></div>
</template>
<script lang="ts" setup>
defineOptions({
name: "Popup"
});
</script>
<style lang="less" scoped>
#tui-popup {
border: var(--tui-border);
z-index: 20;
position: fixed;
font-size: 13px;
visibility: hidden;
box-shadow: 2px 2px 0 rgba(50, 50, 50, .2);
background: #F4F4F4;
:deep(.text) {
padding: 3px 6px 5px 6px;
max-width: 420px;
}
}
</style>

70
src/index.ts Normal file
View File

@@ -0,0 +1,70 @@
import type { App } from "vue";
import components from "./components";
import Network from "./utils/Network";
import Time from "./utils/Time";
import IOSize from "./utils/IOSize";
import Events from "./utils/Events";
import Cooker from "./utils/Cooker";
import Toolkit from "./utils/Toolkit";
import Resizer from "./utils/Resizer";
import Storage from "./utils/Storage";
import Prismjs from "./utils/Prismjs";
import Markdown from "./utils/Markdown";
import Scroller from "./utils/Scroller";
import VPopup from "./utils/directives/Popup";
import VDraggable from "./utils/directives/Draggable";
import { deviceStore } from "./store/device";
import { windowStore } from "./store/window";
import "./assets/style/variable.less";
import "./assets/style/common4web.less";
export * from "./components";
export * from "./types/Model";
export * from "./utils/Prismjs";
export * from "./utils/directives/Popup";
export type { ScrollListener } from "./utils/Scroller";
export type { DraggableConfig } from "./utils/directives/Draggable";
export type { PopupConfig } from "./utils/directives/Popup";
const install = function (app: App) {
components.forEach(component => {
app.use(component as unknown as { install: () => any });
});
};
const axios = Network.axios;
export default {
install
};
export {
axios,
Network,
deviceStore,
windowStore,
Time,
Events,
IOSize,
Cooker,
Toolkit,
Resizer,
Storage,
Prismjs,
Markdown,
Scroller,
VPopup,
VDraggable
};

110
src/store/device.ts Normal file
View File

@@ -0,0 +1,110 @@
import { Resizer } from "~/index";
// true 为手机
const isPhone = ref(false);
// true 为平板
const isTablet = ref(false);
// true 为桌面
const isDesktop = ref(false);
// true 为大屏幕
const isLargeScreen = ref(false);
// true 为竖屏
const isVertical = ref(false);
// true 为横屏
const isHorizontal = ref(false);
// 宽高比
const aspectRatio = ref(0);
// true 为超宽屏
const isUltrawide = ref(false);
// true 为近似方屏
const isSquare = ref(false);
// 当前断点
const currentBreakpoint = ref<"xs" | "sm" | "md" | "lg" | "xl">("lg");
// 当前屏幕宽度
const screenWidth = ref(0);
// 当前屏幕高度
const screenHeight = ref(0);
// 短屏幕(高度小于 500
const isShortScreen = ref(false);
// true 为启用移动端布局
const isMobileLayout = ref(false);
/** 断点配置单位px */
enum Breakpoints {
/** 超小设备 */
XS = 480,
/** 手机 */
SM = 650,
/** 平板 */
MD = 768,
/** 笔记本 */
LG = 1024,
/** 大屏幕 */
XL = 1440
}
Resizer.addListener("DEVICE_SIZE", (width, height) => {
screenWidth.value = width;
screenHeight.value = height;
// 设备类型
isPhone.value = width < Breakpoints.SM;
isTablet.value = width >= Breakpoints.SM && width < Breakpoints.LG;
isDesktop.value = width >= Breakpoints.LG;
isLargeScreen.value = width >= Breakpoints.XL;
// 屏幕方向
isVertical.value = width < height;
isHorizontal.value = !isVertical.value;
// 宽高比特征
aspectRatio.value = width / height;
isUltrawide.value = 2 <= aspectRatio.value; // 21:9 ≈ 2.33
isSquare.value = 0.9 < aspectRatio.value && aspectRatio.value < 1.1;
// 布局相关
isShortScreen.value = height < 500;
isMobileLayout.value = width < Breakpoints.MD;
if (Breakpoints.XL <= width) {
currentBreakpoint.value = "xl";
} else if (Breakpoints.LG <= width) {
currentBreakpoint.value = "lg";
} else if (Breakpoints.MD <= width) {
currentBreakpoint.value = "md";
} else if (Breakpoints.SM <= width) {
currentBreakpoint.value = "sm";
} else {
currentBreakpoint.value = "xs";
}
});
const deviceStore = {
isPhone,
isTablet,
isDesktop,
isLargeScreen,
isVertical,
isHorizontal,
aspectRatio,
isUltrawide,
isSquare,
currentBreakpoint,
screenWidth,
screenHeight,
isShortScreen,
isMobileLayout
};
export {
deviceStore
};

11
src/store/window.ts Normal file
View File

@@ -0,0 +1,11 @@
function setTitle(title?: string) {
window.document.title = title + "";
}
const windowStore = {
setTitle
};
export {
windowStore
};

43
src/types.ts Normal file
View File

@@ -0,0 +1,43 @@
import type { Plugin } from "vue";
/**
* 安装方法
*/
export type InstallRecord<T> = T & Plugin;
/**
* 默认插槽参数
*/
export type DefaultSlotProp = (props: {}) => unknown
/**
* 默认插槽类型
*/
export interface DefaultSlots {
default: DefaultSlotProp;
icon?: DefaultSlotProp;
}
/**
* 合并类型为交叉类型
*/
export type Merge<T, R> = {
[K in keyof T]: T[K]
} & {
[K in keyof R]: R[K]
}
/**
* 合并交叉类型
*/
type MergeIntersection<T> = Pick<T, keyof T>
/**
* 提取必传属性
*/
export type PickRequiredUnion<P, U extends keyof P> = MergeIntersection<Merge<Required<Pick<P, U>>, Omit<P, U>>>
/**
* 除了提取的属性,其他都是必传属性
*/
export type PickNotRequiredUnion<P, U extends keyof P> = MergeIntersection<Merge<Pick<P, U>, Required<Omit<P, U>>>>

60
src/types/Model.ts Normal file
View File

@@ -0,0 +1,60 @@
export enum RunEnv {
DEV = "DEV",
DEV_SSL = "DEV_SSL",
PROD = "PROD"
}
// 基本实体模型
export type Model = {
id?: string;
createdAt?: number;
updatedAt?: number;
deletedAt?: number;
}
export type Response = {
code: number;
msg?: string;
data: object;
}
export type Page = {
index: number;
size: number;
keyword?: string;
orderMap?: { [key: string]: OrderType };
}
export enum OrderType {
ASC = "ASC",
DESC = "DESC"
}
export type PageResult<T> = {
total: number;
list: T[];
}
// 携带验证码的请求体
export type CaptchaData<T> = {
captchaId?: string;
captcha?: string;
data?: T;
}
export enum ImageType {
AUTO = "ir-auto",
SMOOTH = "ir-smooth",
PIXELATED = "ir-pixelated"
}
export type KeyValue<T> = {
key: string;
value: T;
}
export type LabelValue<T> = {
label: string;
value: T;
}

31
src/utils/Cooker.ts Normal file
View File

@@ -0,0 +1,31 @@
export default class Cooker {
static set(name: string, value: string, ttlMS: number) {
let expires = "";
if (ttlMS) {
let date = new Date();
date.setTime(date.getTime() + ttlMS);
expires = "; expires=" + date.toUTCString();
}
document.cookie = name + "=" + (value || "") + expires + "; path=/";
}
static get(name: string) {
let nameSplit = name + "=";
let ca = document.cookie.split(";");
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) == " ") {
c = c.substring(1, c.length);
}
if (c.indexOf(nameSplit) == 0) {
return c.substring(nameSplit.length, c.length);
}
}
return undefined;
}
static remove(name: string) {
document.cookie = name + "=; Max-Age=-99999999;";
}
}

106
src/utils/Events.ts Normal file
View File

@@ -0,0 +1,106 @@
/**
* ### 全局事件管理
*
* ```js
* // 注册
* Events.register("eventName", () => {
* // 触发执行
* });
*
* // 触发
* Events.emit("eventName", '支持参数');
*
* // 移除
* Events.remove("eventName");
* ```
*/
export default class Events {
// 监听数组
private static listeners = new Map<any, Observer<any>[]>();
/**
* 注册事件(会叠加)
*
* @param key 事件名称
* @param callback 回调函数
*/
public static register<T>(key: T, callback: Function) {
const observers: Observer<T>[] | undefined = Events.listeners.get(key);
if (!observers) {
Events.listeners.set(key, []);
}
Events.listeners.get(key)?.push(new Observer<T>(key, callback));
}
/**
* 重置并注册(不会叠加)
*
* @param key 事件名称
* @param callback 回调函数
*/
public static reset<T>(key: T, callback: Function) {
Events.listeners.set(key, []);
this.register(key, callback);
}
/**
* 移除事件
*
* @param key 事件名称
*/
public static remove<T>(key: T) {
const observers: Observer<T>[] | undefined = Events.listeners.get(key);
if (observers) {
for (let i = 0, l = observers.length; i < l; i++) {
if (observers[i].equals(key)) {
observers.splice(i, 1);
break;
}
}
}
Events.listeners.delete(key);
}
/**
* 触发事件
*
* @param key 事件名称
* @param args 参数
*/
public static emit<T>(key: T, ...args: any[]) {
const observers: Observer<T>[] | undefined = Events.listeners.get(key);
if (observers) {
for (const observer of observers) {
// 通知
observer.notify(...args);
}
}
}
}
/** 观察者 */
class Observer<T> {
private callback: Function = () => {}; // 回调函数
private readonly key: T;
constructor(key: T, callback: Function) {
this.key = key;
this.callback = callback;
}
/**
* 发送通知
*
* @param args 不定参数
*/
notify(...args: any[]): void {
this.callback.call(this.key, ...args);
}
equals(name: any): boolean {
return name === this.key;
}
}

80
src/utils/IOSize.ts Normal file
View File

@@ -0,0 +1,80 @@
export enum Unit {
/** B */
B = "B",
/** KB */
KB = "KB",
/** MB */
MB = "MB",
/** GB */
GB = "GB",
/** TB */
TB = "TB",
/** PB */
PB = "PB",
/** EB */
EB = "EB"
}
/** 储存单位 */
export default class IOSize {
/** 1 字节 */
public static BYTE = 1;
/** 1 KB */
public static KB = IOSize.BYTE << 10;
/** 1 MB */
public static MB = IOSize.KB << 10;
/** 1 GB */
public static GB = IOSize.MB << 10;
/** 1 TB */
public static TB = IOSize.GB << 10;
/** 1 PB */
public static PB = IOSize.TB << 10;
/** 1 EB */
public static EB = IOSize.PB << 10;
public static Unit = Unit;
/**
* <p>格式化一个储存容量,保留两位小数
* <pre>
* // 返回 100.01 KB
* format(102411, 2);
* </pre>
*
* @param size 字节大小
* @param fixed 保留小数
* @param stopUnit 停止单位
* @return
*/
public static format(size: number, fixed = 2, stopUnit?: Unit): string {
const units = Object.keys(Unit);
if (0 < size) {
for (let i = 0; i < units.length; i++, size /= 1024) {
const unit = units[i];
if (size <= 1000 || i === units.length - 1 || unit === stopUnit) {
if (i === 0) {
// 最小单位不需要小数
return size + " B";
} else {
return `${size.toFixed(fixed)} ${unit}`;
}
}
}
}
return "0 B";
}
}

196
src/utils/Markdown.ts Normal file
View File

@@ -0,0 +1,196 @@
import { marked } from "marked";
import { gfmHeadingId } from "marked-gfm-heading-id";
import { markedHighlight } from "marked-highlight";
import { mangle } from "marked-mangle";
import Prism from "prismjs";
import "prismjs/themes/prism.css";
export default class Markdown {
private static instance: Markdown;
renderer = new marked.Renderer();
private constructor() {
marked.use(mangle());
marked.use(gfmHeadingId());
marked.use(markedHighlight({
highlight(code: string, lang: string) {
if (Prism.languages[lang]) {
return Prism.highlight(code, Prism.languages[lang], lang);
} else {
return code;
}
}
}));
// Markdown 解析器配置
marked.setOptions({
renderer: this.renderer,
pedantic: false,
gfm: true,
breaks: true
});
Prism.hooks.add("complete", (env: any) => {
if (!env.code) return;
// 行号渲染调整
const el = env.element;
const lineNumber = el.querySelector(".line-numbers-rows") as HTMLDivElement;
if (lineNumber) {
const clone = lineNumber.cloneNode(true);
el.removeChild(lineNumber);
// 加容器做滚动
el.innerHTML = `<span class="codes">${el.innerHTML}</span>`;
el.insertBefore(clone, el.firstChild);
if (el.parentNode) {
const markdownRoot = el.parentNode.parentNode;
if (markdownRoot) {
const maxHeight = markdownRoot.dataset.maxHeight;
if (maxHeight === "auto") {
return;
}
// 注册双击事件
const lines = lineNumber.children.length;
if (lines < 18) {
return;
}
const parent = el.parentNode;
parent.addEventListener("dblclick", (): void => {
const isExpand = parent.classList.contains("expand");
if (isExpand) {
parent.style.maxHeight = maxHeight;
parent.classList.remove("expand");
} else {
parent.style.maxHeight = lines * 22 + "px";
parent.classList.add("expand");
}
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
}
});
}
}
}
});
/**
* ### 超链渲染方式
*
* 1. 链接前加 ~ 符号会被渲染为新标签打开。例:`[文本](~链接)`
* 2. 没有标题的链接将使用文本作为标题
* 3. 没有链接的会被渲染为 span 标签
*/
this.renderer.link = function ({ href, title, text }) {
title = title ?? text;
if (!href) {
return `<span>${text}</span>`;
}
// 新标签打开
let target = "_self";
if (href.startsWith("~")) {
target = "_blank";
href = href.substring(1);
}
{
// 处理嵌套 markdown这可能不是最优解
const tokens = (marked.lexer(text, { inline: true } as any) as any)[0].tokens;
text = this.parser.parseInline(tokens);
}
return `<a href="${href}" target="${target}" title="${title}">${text}</a>`;
};
/**
* ### 重点内容扩展
*
* ```md
* 默认 `文本` 表现为红色
* 使用 `[red bold]文本` 可以自定义类
* ```
*/
this.renderer.codespan = ({ text }) => {
const clazz = text.match(/\[(.+?)]/);
if (clazz) {
return `<span class="${clazz[1]}">${text.substring(text.indexOf("]") + 1)}</span>`;
} else {
return `<span class="red">${text}</span>`;
}
};
/**
* ### 组件渲染方式(原为图像渲染方式)
*
* ```md
* [] 内文本以 # 开始时,该组件带边框
* ```
*
* 1. 渲染为网页:`![]($/html/index.html)`
* 2. 渲染为视频:`![](#/media/video.mp4)`
* 3. 渲染为音频:`![](~/media/music.mp3)`
* 4. 渲染为图片:`![](/image/photo.png)`
* 6. 带边框图片:`![#图片Alt](/image/photo.png)`
*/
this.renderer.image = ({ href, title, text }) => {
const clazz = ["media"];
const hasBorder = text[0] === "#";
if (hasBorder) {
clazz.push("border");
}
title = title ?? text;
const extendTags = ["~", "#", "$"];
let extendTag;
if (extendTags.indexOf(href.charAt(0)) !== -1) {
extendTag = href.charAt(0);
}
const clazzStr = clazz.join(" ");
let elStr;
switch (extendTag) {
case "~":
elStr = `<audio class="${clazzStr}" controls><source type="audio/mp3" src="${href}" title="${title}" /></audio>`;
break;
case "#":
elStr = `<video class="${clazzStr}" controls><source type="video/mp4" src="${href}" title="${title}" /></video>`;
break;
case "$":
elStr = `<iframe class="${clazzStr}" src="${href}" allowfullscreen title="${title}"></iframe>`;
break;
default:
elStr = `<img class="${clazzStr}" src="${href}" alt="${title}" />`;
break;
}
return elStr + `<span class="media-tips">${title}</span>`;
};
}
/**
* ### 解析 Markdown 文本为 HTML 节点
*
* ```js
* const html = toHTML('# Markdown Content');
* ```
*
* @param mkData Markdown 文本
* @returns HTML 节点
*/
public toHTML(mkData: string | undefined): string | Promise<string> {
if (mkData) {
return marked(mkData);
} else {
return "";
}
}
public static getInstance(): Markdown {
if (!Markdown.instance) {
Markdown.instance = new Markdown();
}
return Markdown.instance;
}
}

48
src/utils/MethodLocker.ts Normal file
View File

@@ -0,0 +1,48 @@
export class MethodLocker<R> {
private isLocked: boolean = false;
private queue: Array<() => Promise<any>> = [];
/**
* 执行被锁定的方法
* @param task 需要执行的任务,返回一个 Promise
*/
async execute(task: () => Promise<R>): Promise<R> {
// 如果当前没有被锁定,直接执行任务
if (!this.isLocked) {
this.isLocked = true;
try {
return await task();
} finally {
this.isLocked = false;
await this.dequeue();
}
} else {
// 如果被锁定,将任务加入队列并等待
return new Promise<R>((resolve, reject) => {
this.queue.push(async () => {
try {
const result = await task();
resolve(result);
} catch (error) {
reject(error);
} finally {
this.dequeue();
}
});
});
}
}
/**
* 处理队列中的下一个任务
*/
private dequeue(): Promise<R> | undefined {
if (this.queue.length > 0) {
const nextTask = this.queue.shift();
if (nextTask) {
return this.execute(nextTask);
}
}
return undefined;
}
}

37
src/utils/Network.ts Normal file
View File

@@ -0,0 +1,37 @@
import axios from "axios";
import { Response } from "~/types/Model";
axios.defaults.withCredentials = true;
axios.interceptors.response.use((response: any) => {
if (!response.config.responseType) {
// 服务端返回
const data = response.data as Response;
if (data.code < 40000) {
// 200 或 300 HTTP 状态段视为成功
return data.data;
} else {
// 由调用方处理
return Promise.reject(data.msg);
}
}
return response.data;
}, (error: any) => {
// 请求错误
if (error) {
if (error.response && error.response.status) {
throw error;
}
if (error.request) {
if (error.message.startsWith("timeout")) {
throw new Error("time out");
} else {
throw new Error(error.message);
}
}
}
throw error;
});
export default {
axios,
};

96
src/utils/Prismjs.ts Normal file
View File

@@ -0,0 +1,96 @@
export enum PrismjsType {
PlainText = "PlainText",
Markdown = "Markdown",
JavaScript = "JavaScript",
TypeScript = "TypeScript",
Initialization = "Initialization",
PHP = "PHP",
SQL = "SQL",
XML = "XML",
CSS = "CSS",
VUE = "VUE",
LESS = "LESS",
Markup = "Markup",
YAML = "YAML",
Json = "Json",
Java = "Java",
Properties = "Properties",
NginxConf = "NginxConf",
ApacheConf = "ApacheConf"
}
export type PrismjsProperties = {
extensions: string[]
prismjs: string;
viewer: PrismjsViewer;
}
export enum PrismjsViewer {
MARKDOWN = "MARKDOWN",
CODE = "CODE",
TEXT = "TEXT",
}
export default class Prismjs {
private static instance: Prismjs;
map = new Map<PrismjsType, PrismjsProperties>();
private constructor() {
this.map.set(PrismjsType.PlainText, {extensions: ["txt"], prismjs: "", viewer: PrismjsViewer.TEXT});
this.map.set(PrismjsType.Markdown, {extensions: ["md"], prismjs: "md", viewer: PrismjsViewer.MARKDOWN});
this.map.set(PrismjsType.JavaScript, {extensions: ["js"], prismjs: "js", viewer: PrismjsViewer.CODE});
this.map.set(PrismjsType.VUE, {extensions: ["vue"], prismjs: "html", viewer: PrismjsViewer.CODE});
this.map.set(PrismjsType.TypeScript, {extensions: ["ts"], prismjs: "ts", viewer: PrismjsViewer.CODE});
this.map.set(PrismjsType.Initialization, {extensions: ["ini"], prismjs: "ini", viewer: PrismjsViewer.CODE});
this.map.set(PrismjsType.PHP, {extensions: ["php"], prismjs: "php", viewer: PrismjsViewer.CODE});
this.map.set(PrismjsType.SQL, {extensions: ["sql"], prismjs: "sql", viewer: PrismjsViewer.CODE});
this.map.set(PrismjsType.XML, {extensions: ["xml", "fxml"], prismjs: "xml", viewer: PrismjsViewer.CODE});
this.map.set(PrismjsType.CSS, {extensions: ["css"], prismjs: "css", viewer: PrismjsViewer.CODE});
this.map.set(PrismjsType.LESS, {extensions: ["less"], prismjs: "less", viewer: PrismjsViewer.CODE});
this.map.set(PrismjsType.Markup, {extensions: ["htm", "html"], prismjs: "markup", viewer: PrismjsViewer.CODE});
this.map.set(PrismjsType.YAML, {extensions: ["yml", "yaml"], prismjs: "yaml", viewer: PrismjsViewer.CODE});
this.map.set(PrismjsType.Json, {extensions: ["json"], prismjs: "json", viewer: PrismjsViewer.CODE});
this.map.set(PrismjsType.Java, {extensions: ["java"], prismjs: "java", viewer: PrismjsViewer.CODE});
this.map.set(PrismjsType.Properties, {extensions: ["properties"], prismjs: "properties", viewer: PrismjsViewer.CODE});
this.map.set(PrismjsType.NginxConf, {extensions: [], prismjs: "nginx", viewer: PrismjsViewer.CODE});
this.map.set(PrismjsType.ApacheConf, {extensions: [], prismjs: "apacheconf", viewer: PrismjsViewer.CODE});
}
private static getInstance(): Prismjs {
if (!Prismjs.instance) {
Prismjs.instance = new Prismjs();
}
return Prismjs.instance;
}
public static typeFromFileName(fileName: string): PrismjsType | undefined {
const ext = fileName.substring(fileName.lastIndexOf(".") + 1);
if (!ext) {
return undefined;
}
const map = Prismjs.getInstance().map;
for (const key of map.keys()) {
const value = map.get(key);
if (value) {
const extensions = value.extensions;
for (let i = 0; i < extensions.length; i++) {
const extension = extensions[i];
if (extension === ext) {
return key;
}
}
}
}
return undefined;
}
public static getFileProperties(type: PrismjsType): PrismjsProperties | undefined {
return Prismjs.getInstance().map.get(type);
}
}

83
src/utils/Resizer.ts Normal file
View File

@@ -0,0 +1,83 @@
/**
* ### 浏览器缩放
*
* 此类对象由 Resizer 注册、触发和销毁
*
* ```js
* new ResizeListener("注册名", () => console.log("回调函数"));
* ```
*/
class ResizeListener {
/** 事件名 */
name: string;
/** 回调函数 */
listener: (width: number, height: number) => void;
constructor(name: string, listener: (width: number, height: number) => void) {
this.name = name;
this.listener = listener;
}
}
/**
* ### 浏览器窗体缩放监听触发器
*
* ```js
* Resizer.addListener("Comment", (width: number, height: number) => console.log("缩放中"));
* Resizer.removeListener("Comment");
* ```
*/
export default class Resizer {
private static instance: Resizer;
listeners: ResizeListener[];
private constructor() {
this.listeners = [];
window.addEventListener("resize", resizeEvent => {
const width = (resizeEvent.currentTarget as any).innerWidth;
const height = (resizeEvent.currentTarget as any).innerHeight;
for (const e of this.listeners) {
e.listener(width, height);
}
}, true);
}
private static getInstance(): Resizer {
if (!Resizer.instance) {
Resizer.instance = new Resizer();
}
return Resizer.instance;
}
// 添加事件
public static addListener(name: string, listener: (width: number, height: number) => void) {
const instance = Resizer.getInstance();
let e = instance.listeners.find((se) => se.name === name);
if (e) {
e.listener = listener;
} else {
instance.listeners.push(e = new ResizeListener(name, listener));
}
// 默认触发一次
e.listener(window.innerWidth, window.innerHeight);
}
// 移除事件
public static removeListener(name: string) {
const instance = Resizer.getInstance();
instance.listeners.splice(instance.listeners.findIndex(e => e.name === name), 1);
}
public static getWidth(): number {
return document.documentElement.clientWidth;
}
public static getHeight(): number {
return document.documentElement.clientHeight;
}
}

74
src/utils/Scroller.ts Normal file
View File

@@ -0,0 +1,74 @@
export type ScrollListener = {
source: Event;
viewWidth: number;
viewHeight: number;
top: number;
bottom: number;
}
export default class Scroller {
private static instance: Scroller;
listeners = new Map<string, Function>();
private constructor() {
window.addEventListener("scroll", source => {
// 滚动距离
const top = document.body.scrollTop || document.documentElement.scrollTop;
// 可视高度
const viewWidth = document.documentElement.clientWidth || document.body.clientWidth;
// 可视高度
const viewHeight = document.documentElement.clientHeight || document.body.clientHeight;
// 滚动高度
const sH = document.documentElement.scrollHeight || document.body.scrollHeight;
// 触发事件
const bottom = sH - top - viewHeight;
this.listeners.forEach(listener => listener({
source,
viewWidth,
viewHeight,
top,
bottom
} as ScrollListener));
}, true);
}
private static getInstance(): Scroller {
if (!Scroller.instance) {
Scroller.instance = new Scroller();
}
return Scroller.instance;
}
/**
* 添加监听
*
* @param name 事件名
* @param listener 监听事件
*/
public static addListener(name: string, listener: (event: ScrollListener) => void) {
Scroller.getInstance().listeners.set(name, listener);
}
/**
* 移除监听
*
* @param name 事件名
*/
public static removeListener(name: string) {
Scroller.getInstance().listeners.delete(name);
}
/** 滚动至顶(平滑地) */
public static toTop() {
document.body.scrollIntoView({behavior: "smooth"});
}
/** 滚动至指定节点(平滑地) */
public static toElement(el: HTMLElement) {
el.scrollIntoView({behavior: "smooth"});
}
}

129
src/utils/Storage.ts Normal file
View File

@@ -0,0 +1,129 @@
export default class Storage {
/**
* 获取为布尔值
*
* @param key 键
* @returns 布尔值
*/
public static is(key: string): boolean {
return this.getString(key) === "true";
}
/**
* 获取为布尔值并取反
*
* @param key 键
* @returns 布尔值
*/
public static not(key: string): boolean {
return !this.is(key);
}
/**
* 读取为指定对象
*
* @template T 对象类型
* @param key 键
* @returns {T | undefined} 返回对象
*/
public static getObject<T>(key: string): T {
if (this.has(key)) {
return this.getJSON(key) as T;
}
throw Error(`not found ${key}`);
}
/**
* 获取值,如果没有则储存并使用默认值
*
* @template T 默认值类型
* @param key 键
* @param def 默认值
* @return {T} 对象
*/
public static getDefault<T>(key: string, def: T): T {
if (this.has(key)) {
return this.getJSON(key) as T;
}
this.setObject(key, def);
return def;
}
/**
* 获取为 JSON
*
* @param key 键
* @returns JSON 对象
*/
public static getJSON(key: string) {
return JSON.parse(this.getString(key));
}
/**
* 获取为字符串(其他获取方式一般经过这个方法,找不到配置或配置值无效时会抛错)
*
* @param key 键
* @returns 字符串
*/
public static getString(key: string): string {
const value = localStorage.getItem(key);
if (value) {
return value;
}
throw new Error(`not found: ${key}, ${value}`);
}
/**
* 是否存在某配置
*
* @param key 键
* @returns true 为存在
*/
public static has(key: string): boolean {
return localStorage.getItem(key) !== undefined && localStorage.getItem(key) !== null;
}
/**
* 设置值
*
* @param key 键
* @param value 值
*/
public static setObject(key: string, value: any) {
if (value instanceof Object || value instanceof Array) {
this.setJSON(key, value);
} else {
this.setString(key, value);
}
}
/**
* 设置 JSON 值
*
* @param key 键
* @param json JSON 字符串
*/
public static setJSON(key: string, json: any) {
localStorage.setItem(key, JSON.stringify(json));
}
/**
* 设置值
*
* @param key 键
* @param value 值
*/
public static setString(key: string, value: any) {
localStorage.setItem(key, value);
}
/**
* 移除属性
*
* @param key 键
*/
public static remove(key: string) {
localStorage.removeItem(key);
}
}

97
src/utils/Time.ts Normal file
View File

@@ -0,0 +1,97 @@
export default class Time {
/** 1 秒时间戳 */
public static S = 1E3;
/** 1 分钟时间戳 */
public static M = Time.S * 60;
/** 1 小时时间戳 */
public static H = Time.M * 60;
/** 1 天时间戳 */
public static D = Time.H * 24;
public static now(): number {
return new Date().getTime();
}
/**
* Unix 时间戳转日期
*
* @param unix 时间戳
*/
public static toDate(unix?: number): string {
if (!unix) return "";
const d = new Date(unix);
return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, "0")}-${d.getDate().toString().padStart(2, "0")}`;
}
/**
* Unix 时间戳转时间
*
* @param unix 时间戳
*/
public static toTime(unix?: number): string {
if (!unix) return "";
const d = new Date(unix);
return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`;
}
/**
* Unix 时间戳转日期和时间
*
* @param unix 时间戳
*/
public static toDateTime(unix?: number): string {
if (!unix) return "";
return `${this.toDate(unix)} ${this.toTime(unix)}`;
}
public static toPassedDate(unix?: number): string {
return this.toPassedDateTime(unix, false);
}
public static toPassedDateTime(unix?: number, withDetailTime = true): string {
if (!unix) {
return "";
}
const now = new Date().getTime();
const between = now - unix;
if (Time.D * 4 <= between) {
return withDetailTime ? this.toDateTime(unix) : this.toDate(unix);
} else if (Time.D < between) {
return `${Math.floor(between / Time.D)} 天前`;
} else if (Time.H < between) {
return `${Math.floor(between / Time.H)} 小时前`;
} else if (Time.M < between) {
return `${Math.floor(between / Time.M)} 分钟前`;
} else {
return "刚刚";
}
}
public static between(begin: Date, end?: Date) : any {
if (!end) {
end = new Date();
}
const cs = 1000, cm = 6E4, ch = 36E5, cd = 864E5, cy = 31536E6;
const l = end.getTime() - begin.getTime();
const y = Math.floor(l / cy),
d = Math.floor((l / cd) - y * 365),
h = Math.floor((l - (y * 365 + d) * cd) / ch),
m = Math.floor((l - (y * 365 + d) * cd - h * ch) / cm),
s = Math.floor((l - (y * 365 + d) * cd - h * ch - m * cm) / cs),
ms = Math.floor(((l - (y * 365 + d) * cd - h * ch - m * cm) / cs - s) * cs);
return { l, y, d, h, m, s, ms };
}
public static toMediaTime(seconds: number): string {
seconds = Math.floor(seconds);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const second = seconds % 60;
if (0 < hours) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${second.toString().padStart(2, "0")}`;
}
return `${minutes.toString().padStart(2, "0")}:${second.toString().padStart(2, "0")}`;
}
}

324
src/utils/Toolkit.ts Normal file
View File

@@ -0,0 +1,324 @@
import type { App } from "vue";
import type { InstallRecord } from "~/types";
export default class Toolkit {
public static isFunction = (val: any) => typeof val === "function";
public static isArray = Array.isArray;
public static isString = (val: any) => typeof val === "string";
public static isSymbol = (val: any) => typeof val === "symbol";
public static isObject = (val: any) => val !== null && typeof val === "object";
public static isFile = (val: any) => val instanceof File;
/**
* 添加安装方法
* @example
* ```JS
* import { MeDemo } from './index.vue'
*
* addInstall(MeDemo)
* ```
*/
public static withInstall = <T>(comp: T): InstallRecord<T> => {
const _comp = comp as InstallRecord<T>;
_comp.install = (app: App) => {
app.component((comp as any).name, _comp);
};
return _comp;
};
public static guid() {
const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1);
return `${s4() + s4()}-${s4()}-${s4()}-${s4()}-${s4() + s4() + s4()}`;
}
public static className(...args: any[]) {
const classes = [];
for (let i = 0; i < args.length; i++) {
const value = args[i];
if (!value) continue;
if (this.isString(value)) {
classes.push(value);
} else if (this.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const inner: any = this.className(value[i]);
if (inner) {
classes.push(inner);
}
}
} else if (this.isObject(value)) {
for (const name in value) {
if (value[name]) {
classes.push(name);
}
}
}
}
return classes.join(" ");
}
public static isEmpty(obj: any): boolean {
if (this.isString(obj)) {
return (obj as string).trim().length === 0;
}
if (this.isArray(obj)) {
return (obj as []).length === 0;
}
if (this.isFunction(obj)) {
return this.isEmpty(obj());
}
if (this.isObject(obj)) {
return obj === undefined || obj === null;
}
return obj === undefined || obj === null;
}
public static isNotEmpty(obj: any): boolean {
return !this.isEmpty(obj);
}
/**
* ### 延时执行
*
* ```js
* await sleep(1E3)
* ```
*
* @param ms 延时毫秒
*/
public static async sleep(ms: number): Promise<unknown> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 获取节点属性
*
* @param el 节点
* @param name 属性名
* @returns 属性
*/
public static getAttribute(el: Element, name: string): string | null {
return el.hasAttribute(name) ? el.getAttribute(name) : null;
}
/**
* 转为数字
*
* @param string 字符串
* @param fallback 如果失败,返回该值
* @returns 转换后或转换失败数据
*/
public static toNumber(string: string, fallback?: number): number {
if (!string) return fallback as number;
const number = Number(string);
return isFinite(number) ? number : fallback as number;
}
/**
* ### 解析字符串为 DOM 节点。此 DOM 字符串必须有且仅有一个根节点
*
* ```js
* toDOM(`
* <div>
* <p>imyeyu.net</p>
* </div>
* `)
* ```
*
* @param string 字符串
* @returns DOM 节点
*/
public static toDOM(string: string): Document {
return new DOMParser().parseFromString(string, "text/xml");
}
/**
* 异步执行
*
* @param event 函数
*/
public static async(event: Function) {
setTimeout(event, 0);
}
/**
* 生成随机数
*
* @param min 最小值
* @param max 最大值
*/
public static random(min = 0, max = 100): number {
return Math.floor(Math.random() * (max + 1 - min)) + min;
}
/**
* Base64 数据转文件
*
* __需要头部元数据__
*
* __示例:__
* data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA
*
* @param data Base64 数据
* @param fileName 文件名
* @returns 文件对象
*/
public static base64ToFile(data: string, fileName: string): File {
const splitData = data.split(",");
const base64 = atob(splitData[1]);
let n = base64.length as number;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = base64.charCodeAt(n);
}
return new File([u8arr], fileName, {type: splitData[0].split(":")[1]});
}
/**
* 深克隆对象
*
* @param origin 源对象
* @param target 递归对象
* @returns 克隆对象
*/
public static deepClone(origin: any, target = {} as any) {
const toString = Object.prototype.toString;
const arrType = "[object Array]";
for (const key in origin) {
if (Object.prototype.hasOwnProperty.call(origin, key)) {
if (typeof origin[key] === "object" && origin[key] !== null) {
target[key] = toString.call(origin[key]) === arrType ? [] : {};
this.deepClone(origin[key], target[key]);
} else {
target[key] = origin[key];
}
}
}
return target;
}
public static keyValueString(obj: object, assign: string, split: string): string {
let result = "";
Object.entries(obj).forEach(([k, v]) => result += k + assign + v + split);
return result.substring(0, result.length - split.length);
}
public static toURLArgs(obj?: { [key: string]: any }): string {
if (!obj) {
return "";
}
const args: string[] = [];
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const value = obj[key];
if (Array.isArray(value)) {
value.forEach((item) => {
args.push(`${encodeURIComponent(key)}=${encodeURIComponent(item)}`);
});
} else {
args.push(`${encodeURIComponent(key)}=${encodeURIComponent(value.toString())}`);
}
}
}
return args.join("&");
}
public static toObject(map: Map<any, any>): object {
return Array.from(map.entries()).reduce((acc, [key, value]) => {
acc[key] = value ?? null;
return acc;
}, {} as { [key: string]: string | undefined });
}
public static uuid(): string {
const char = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const length = [8, 4, 4, 4, 12];
let result = "";
for (let i = 0; i < length.length; i++) {
for (let j = 0; j < length[i]; j++) {
result += char[this.random(0, char.length - 1)];
}
result += "-";
}
return result.substring(0, result.length - 1);
}
public static keyFromValue(e: any, value: any): string {
return Object.keys(e)[Object.values(e).indexOf(value)];
}
// 防抖
// eslint-disable-next-line
public static debounce<T extends (...args: any[]) => any>(callback: T, defaultImmediate = true, delay = 600): T & {
cancel(): void
} {
let timerId: ReturnType<typeof setTimeout> | null = null; // 存储定时器
let immediate = defaultImmediate;
// 定义一个 cancel 办法,用于勾销防抖
const cancel = (): void => {
if (timerId) {
clearTimeout(timerId);
timerId = null;
}
};
const debounced = function (this: ThisParameterType<T>, ...args: Parameters<T>): void {
const context = this;
if (timerId) {
cancel();
}
if (immediate) {
callback.apply(context, args);
immediate = false;
timerId = setTimeout(() => {
immediate = defaultImmediate;
}, delay);
} else {
// 设置定时器,在延迟时间后执行指标函数
timerId = setTimeout(() => {
callback.apply(context, args);
immediate = defaultImmediate;
}, delay);
}
};
// 将 cancel 方法附加到 debounced 函数上
(debounced as any).cancel = cancel;
return debounced as T & { cancel(): void };
}
public static toFormData(root?: object): FormData {
const form = new FormData();
if (!root) {
return form;
}
const run = (parent: string | null, obj: object) => {
for (const [key, value] of Object.entries(obj)) {
if (this.isObject(value) && !this.isFile(value)) {
if (parent) {
run(`${parent}.${key}`, value);
} else {
run(key, value);
}
} else {
if (parent) {
form.append(`${parent}.${key}`, value);
} else {
form.append(key, value);
}
}
}
};
run(null, root);
return form;
}
public static format(template: string, variables: { [key: string]: any }): string {
return template.replace(/\$\{(\w+)}/g, (_, key) => variables[key]);
}
public static leftClickCallback(event: MouseEvent, callback: Function): void {
if (event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
callback();
}
}
}

View File

@@ -0,0 +1,130 @@
import type { Directive, DirectiveBinding } from "vue";
export type DraggableConfig = {
/** 点下 */
onMouseDown?: (e: MouseEvent | TouchEvent) => void;
/** 中断条件(返回 true 时不触发 onDragging 及往后事件) */
interruptWhen?: (e: MouseEvent | TouchEvent) => boolean;
/** 拖动 */
onDragging: (e: MouseEvent | TouchEvent, relX: number, relY: number, offsetX: number, offsetY: number) => void;
/** 释放 */
onDragged?: (e: MouseEvent | TouchEvent, x: number, y: number) => void;
}
const getEventPosition = (e: MouseEvent | TouchEvent): { clientX: number, clientY: number, pageX: number, pageY: number } => {
if ("touches" in e && e.touches.length > 0) {
const touch = e.touches[0];
return {
clientX: touch.clientX,
clientY: touch.clientY,
pageX: touch.pageX,
pageY: touch.pageY
};
} else if (e instanceof MouseEvent) {
return {
clientX: e.clientX,
clientY: e.clientY,
pageX: e.pageX,
pageY: e.pageY
};
}
return { clientX: 0, clientY: 0, pageX: 0, pageY: 0 };
};
const VDraggable: Directive = {
// 挂载
mounted(el: HTMLElement, binding: DirectiveBinding<DraggableConfig>) {
const config = binding.value as DraggableConfig;
let isClicked = false, ox = 0, oy = 0, ol = 0, ot = 0, opx = 0, opy = 0;
// 按下
const handleStart = (e: MouseEvent | TouchEvent) => {
e.preventDefault();
const pos = getEventPosition(e);
ox = pos.clientX;
oy = pos.clientY;
ol = el.offsetLeft;
ot = el.offsetTop;
opx = pos.pageX;
opy = pos.pageY;
config.onMouseDown?.(e);
if (config.interruptWhen?.(e)) return;
isClicked = true;
};
// 移动
const handleMove = (e: MouseEvent | TouchEvent) => {
if (!isClicked) {
return;
}
e.preventDefault();
const pos = getEventPosition(e);
const relX = pos.clientX - (ox - ol);
const relY = pos.clientY - (oy - ot);
const offsetX = pos.pageX - opx;
const offsetY = pos.pageY - opy;
config.onDragging(e, relX, relY, offsetX, offsetY);
};
// 释放
const handleEnd = (e: MouseEvent | TouchEvent) => {
if (!isClicked) {
return;
}
const pos = getEventPosition(e);
config.onDragged?.(e, pos.clientX - ox, pos.clientY - oy);
isClicked = false;
};
(el.style as any)["user-drag"] = "none";
(el.style as any)["touch-action"] = "none";
// 鼠标
el.addEventListener("mousedown", handleStart as EventListener);
document.addEventListener("mousemove", handleMove as EventListener);
document.addEventListener("mouseup", handleEnd as EventListener);
// 触控
el.addEventListener("touchstart", handleStart as EventListener, { passive: false });
document.addEventListener("touchmove", handleMove as EventListener, { passive: false });
document.addEventListener("touchend", handleEnd as EventListener, { passive: false });
// 保存事件处理器以便卸载时使用
(el as any)._vDraggableHandlers = {
handleStart,
handleMove,
handleEnd
};
},
// 卸载
unmounted(el: HTMLElement) {
const handlers = (el as any)._vDraggableHandlers as {
handleStart: EventListener,
handleMove: EventListener,
handleEnd: EventListener
};
if (handlers) {
// 鼠标
el.removeEventListener("mousedown", handlers.handleStart);
document.removeEventListener("mousemove", handlers.handleMove);
document.removeEventListener("mouseup", handlers.handleEnd);
// 触控
el.removeEventListener("touchstart", handlers.handleStart);
document.removeEventListener("touchmove", handlers.handleMove);
document.removeEventListener("touchend", handlers.handleEnd);
// 引用
delete (el as any)._vDraggableHandlers;
}
}
};
export default VDraggable;

View File

@@ -0,0 +1,120 @@
import type { Directive, DirectiveBinding } from "vue";
import Toolkit from "../Toolkit";
export enum PopupType {
TEXT,
IMG,
HTML,
EL
}
/** */
export type PopupConfig = {
type: PopupType,
value?: string | HTMLElement;
canShow?: () => boolean;
beforeShow?: (type: PopupType, value: string | HTMLElement) => Promise<void>;
afterHidden?: (type: PopupType, value: string | HTMLElement) => Promise<void>;
}
// Popup 弹出提示 DOM 节点,全局唯一
let popup: HTMLElement | null;
const VPopup: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding<PopupConfig>) {
// 转配置
let config: PopupConfig;
if (binding.arg && binding.arg === "config") {
config = binding.value as PopupConfig;
} else {
config = {
type: PopupType.TEXT,
value: binding.value as any as string,
canShow: () => true
};
}
// Popup 节点
if (!popup) {
popup = document.getElementById("tui-popup");
}
let isShowing = false;
// 显示
el.addEventListener("mouseenter", async e => {
if (!config.value) {
console.warn("not found popup value", config);
return;
}
if (config.beforeShow) {
await config.beforeShow(config.type, config.value);
}
if (config.canShow && config.canShow() && popup) {
let el: HTMLElement | null = null;
if (!config) {
el = document.createElement("div");
el.className = "text";
el.textContent = config as string;
popup.appendChild(el);
}
switch (config.type) {
case PopupType.TEXT:
// 文本
el = document.createElement("div");
el.className = "text";
el.textContent = config.value as string;
popup.appendChild(el);
break;
case PopupType.IMG:
// 图片
el = document.createElement("img");
(el as HTMLImageElement).src = config.value as string;
popup.appendChild(el);
break;
case PopupType.HTML:
// HTML 字符串
popup.appendChild(Toolkit.toDOM(config.value as string));
break;
case PopupType.EL:
// DOM 节点
if (config.value instanceof HTMLElement) {
const valueEl = config.value as HTMLElement;
valueEl.style.display = "block";
popup.appendChild(valueEl);
break;
} else {
console.error(config);
throw new Error("Vue 指令错误v-popup:el 的值不是 HTML 元素");
}
}
popup.style.left = (e.x + 20) + "px";
popup.style.top = (e.y + 14) + "px";
popup.style.visibility = "visible";
isShowing = true;
}
}, false);
// 移动
el.addEventListener("mousemove", async (e) => {
if (config.canShow && config.canShow() && isShowing && popup) {
popup.style.left = (e.x + 20) + "px";
popup.style.top = (e.y + 14) + "px";
}
}, false);
// 隐藏
el.addEventListener("mouseleave", async () => {
if (popup) {
popup.style.visibility = "hidden";
popup.innerText = "";
popup.style.left = "0px";
popup.style.top = "0px";
// 隐藏后事件
if (config.afterHidden && config.value) {
await config.afterHidden(config.type, config.value);
}
}
}, false);
}
};
export default VPopup;

44
tsconfig.json Normal file
View File

@@ -0,0 +1,44 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Node",
"useDefineForClassFields": true,
"jsx": "preserve",
"strict": true,
"sourceMap": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"lib": [
"ESNext",
"DOM",
"DOM.Iterable"
],
"paths": {
"~/*": [
"./src/*"
],
"@/*": [
"./examples/*"
],
"common4web": [
"./src/index.ts"
]
}
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.vue",
"examples/**/*.ts",
"examples/**/*.d.ts",
"examples/**/*.vue"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

11
tsconfig.node.json Normal file
View File

@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node"
},
"include": [
"vite.config.ts"
]
}

10
tsconfig.types.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true
},
"include": [
"src"
]
}

110
vite.config.ts Normal file
View File

@@ -0,0 +1,110 @@
import { resolve } from "path";
import { Alias, defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import VueSetupExtend from "vite-plugin-vue-setup-extend";
import { prismjsPlugin } from "vite-plugin-prismjs";
import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import dts from "vite-plugin-dts";
const alias: Alias[] = [
{
find: "@",
replacement: resolve(__dirname, "./examples")
},
{
find: "~",
replacement: resolve(__dirname, "./src")
},
{
find: "*",
replacement: resolve("")
},
{
find: /^common4web(\/(es|lib))?$/,
replacement: resolve(__dirname, "./src/index.ts")
}
];
export default defineConfig({
server: {
port: 3003,
host: true
},
resolve: {
alias
},
build: {
outDir: "dist",
lib: {
entry: resolve(__dirname, "./src/index.ts"),
name: "Common4web",
fileName: "common4web"
},
rollupOptions: {
external: [
"vue"
],
output: {
globals: {
vue: "Vue"
}
}
},
minify: "terser",
terserOptions: {
compress: {
// eslint-disable-next-line camelcase
drop_console: false,
// eslint-disable-next-line camelcase
drop_debugger: false
}
}
},
plugins: [
vue({
include: [/\.vue$/, /\.md$/]
}),
VueSetupExtend(),
dts(),
prismjsPlugin({
languages: [
"ini",
"php",
"sql",
"xml",
"css",
"less",
"html",
"json",
"yaml",
"java",
"nginx",
"javascript",
"typescript",
"apacheconf",
"properties"
],
plugins: [
"line-numbers"
],
theme: "default",
css: true
}),
AutoImport({
imports: [
"vue"
],
dts: "examples/auto-imports.d.ts",
eslintrc: {
enabled: true,
globalsPropValue: true
}
}),
Components({
dirs: [
"src/components"
]
})
]
});