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 # ---> Vue
# gitignore template for Vue.js projects # 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 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 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 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"
]
})
]
});