Compare commits
4
Commits
ccbb924c9a
...
cf62eb2d41
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf62eb2d41 | ||
|
|
eef7a78fe9 | ||
|
|
6fa75b3045 | ||
|
|
0c5a4d4fd9 |
@@ -1,18 +1,20 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
# timi-web
|
||||
|
||||
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.
|
||||
Vue 3 通用组件库与工具库。业务 API、领域类型和用户状态位于 [timi-web-api](https://gitea.imyeyu.com/timi/timi-web-api)。
|
||||
|
||||
## 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).
|
||||
平台无关的 `Events`、`IOSize`、`MethodLocker`、`Prismjs`、`Text`、`Time`、`Toolkit` 和通用类型统一由 `timi-ts` 提供。`timi-web` 直接依赖并从入口转出这些能力,业务项目只依赖 `timi-web` 时可以间接使用它们;Vue、DOM、浏览器文件和组件安装相关能力仍由 `timi-web` 提供。
|
||||
|
||||
## Type Support For `.vue` Imports in TS
|
||||
Web 专属方法使用 `WebToolkit`,通用方法使用 `timi-ts` 导出的 `Toolkit`。
|
||||
|
||||
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.
|
||||
```ts
|
||||
import { Text, Toolkit } from "timi-web";
|
||||
```
|
||||
|
||||
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.
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
@@ -9,15 +9,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {axios, Copyright, SettingMapper} from "timi-web";
|
||||
import CommonAPI from "../src/api/CommonAPI";
|
||||
import PassTimeLabel from "../src/components/passtime-label/index.vue";
|
||||
|
||||
onMounted(async () => {
|
||||
})
|
||||
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.root {
|
||||
width: 80%;
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import {createApp} from "vue";
|
||||
import Root from "./Root.vue";
|
||||
import {axios, Network, VPopup} from "timi-web";
|
||||
import axios from "axios";
|
||||
import {VPopup} from "timi-web";
|
||||
|
||||
axios.defaults.baseURL = import.meta.env.VITE_API;
|
||||
axios.interceptors.request.use((config) => {
|
||||
|
||||
+8
-4
@@ -33,12 +33,18 @@
|
||||
"import": "./dist/timi-web.mjs",
|
||||
"require": "./dist/timi-web.umd.js"
|
||||
},
|
||||
"./editor": {
|
||||
"types": "./dist/src/editor/index.d.ts",
|
||||
"import": "./dist/timi-web.mjs",
|
||||
"require": "./dist/timi-web.umd.js"
|
||||
},
|
||||
"./style.css": "./dist/timi-web.css"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"timi-ts": "^0.1.0",
|
||||
"@tiptap/extension-code-block": "^3.22.5",
|
||||
"@tiptap/extension-color": "^3.22.4",
|
||||
"@tiptap/extension-image": "^3.22.4",
|
||||
@@ -57,9 +63,7 @@
|
||||
"@tiptap/starter-kit": "^3.22.4",
|
||||
"@tiptap/vue-3": "^3.22.4",
|
||||
"@marsio/vue-split-pane": "^1.0.0",
|
||||
"axios": "1.18.0",
|
||||
"less": "4.5.1",
|
||||
"pinia": "^3.0.2",
|
||||
"marked": "^17.0.1",
|
||||
"marked-gfm-heading-id": "^4.1.3",
|
||||
"marked-highlight": "^2.2.3",
|
||||
@@ -73,6 +77,7 @@
|
||||
"@types/prismjs": "1.26.5",
|
||||
"@typescript-eslint/eslint-plugin": "^8.53.0",
|
||||
"@typescript-eslint/parser": "^8.53.0",
|
||||
"axios": "1.18.0",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vitest/coverage-v8": "^4.1.4",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
@@ -93,7 +98,6 @@
|
||||
"vitest": "^4.1.4",
|
||||
"vue": "^3.5.26",
|
||||
"vue-tsc": "^3.2.2",
|
||||
"vue-eslint-parser": "^10.0.0",
|
||||
"tdesign-vue-next": "^1.20.6"
|
||||
"vue-eslint-parser": "^10.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+7
-196
@@ -62,9 +62,6 @@ importers:
|
||||
'@tiptap/vue-3':
|
||||
specifier: ^3.22.4
|
||||
version: 3.30.2(@floating-ui/dom@1.8.0)(@tiptap/core@3.30.2(@tiptap/pm@3.30.2))(@tiptap/pm@3.30.2)(vue@3.5.26(typescript@5.9.3))
|
||||
axios:
|
||||
specifier: 1.18.0
|
||||
version: 1.18.0
|
||||
less:
|
||||
specifier: 4.5.1
|
||||
version: 4.5.1
|
||||
@@ -80,15 +77,15 @@ importers:
|
||||
marked-mangle:
|
||||
specifier: ^1.1.12
|
||||
version: 1.1.12(marked@17.0.1)
|
||||
pinia:
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3))
|
||||
prismjs:
|
||||
specifier: 1.30.0
|
||||
version: 1.30.0
|
||||
terser:
|
||||
specifier: ^5.44.1
|
||||
version: 5.44.1
|
||||
timi-ts:
|
||||
specifier: ^0.1.0
|
||||
version: link:../timi-ts
|
||||
devDependencies:
|
||||
'@types/marked':
|
||||
specifier: ^6.0.0
|
||||
@@ -114,6 +111,9 @@ importers:
|
||||
'@vue/test-utils':
|
||||
specifier: ^2.4.6
|
||||
version: 2.4.6
|
||||
axios:
|
||||
specifier: 1.18.0
|
||||
version: 1.18.0
|
||||
eslint:
|
||||
specifier: ^9.39.2
|
||||
version: 9.39.2
|
||||
@@ -135,9 +135,6 @@ importers:
|
||||
prettier:
|
||||
specifier: ^3.8.0
|
||||
version: 3.8.0
|
||||
tdesign-vue-next:
|
||||
specifier: ^1.20.6
|
||||
version: 1.20.6(vue@3.5.26(typescript@5.9.3))
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
@@ -506,9 +503,6 @@ packages:
|
||||
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
|
||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||
|
||||
'@popperjs/core@2.11.8':
|
||||
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0-rc.12':
|
||||
resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -1009,12 +1003,6 @@ packages:
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/lodash-es@4.17.12':
|
||||
resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
|
||||
|
||||
'@types/lodash@4.17.25':
|
||||
resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==}
|
||||
|
||||
'@types/marked@6.0.0':
|
||||
resolution: {integrity: sha512-jmjpa4BwUsmhxcfsgUit/7A9KbrC48Q0q8KvnY107ogcjGgTFDlIL3RpihNpx2Mu1hM4mdFQjoVc4O6JoGKHsA==}
|
||||
deprecated: This is a stub types definition. marked provides its own type definitions, so you do not need this installed.
|
||||
@@ -1028,15 +1016,6 @@ packages:
|
||||
'@types/prismjs@1.26.5':
|
||||
resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
|
||||
|
||||
'@types/sortablejs@1.15.9':
|
||||
resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==}
|
||||
|
||||
'@types/tinycolor2@1.4.6':
|
||||
resolution: {integrity: sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==}
|
||||
|
||||
'@types/validator@13.15.10':
|
||||
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
|
||||
|
||||
'@types/whatwg-mimetype@3.0.2':
|
||||
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
||||
|
||||
@@ -1198,15 +1177,6 @@ packages:
|
||||
'@vue/compiler-vue2@2.7.16':
|
||||
resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
|
||||
|
||||
'@vue/devtools-api@7.7.10':
|
||||
resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==}
|
||||
|
||||
'@vue/devtools-kit@7.7.10':
|
||||
resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==}
|
||||
|
||||
'@vue/devtools-shared@7.7.10':
|
||||
resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==}
|
||||
|
||||
'@vue/language-core@2.2.0':
|
||||
resolution: {integrity: sha512-O1ZZFaaBGkKbsRfnVH1ifOK1/1BUkyK+3SQsfnh6PmMmD4qJcTU8godCeA96jjDRTL6zgnK7YzCHfaUlH2r0Mw==}
|
||||
peerDependencies:
|
||||
@@ -1349,9 +1319,6 @@ packages:
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
birpc@2.9.0:
|
||||
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
|
||||
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
@@ -1448,10 +1415,6 @@ packages:
|
||||
copy-anything@2.0.6:
|
||||
resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==}
|
||||
|
||||
copy-anything@4.0.5:
|
||||
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cosmiconfig@7.1.0:
|
||||
resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1471,9 +1434,6 @@ packages:
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
dayjs@1.11.23:
|
||||
resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==}
|
||||
|
||||
de-indent@1.0.2:
|
||||
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
|
||||
|
||||
@@ -1644,6 +1604,7 @@ packages:
|
||||
eslint@9.39.2:
|
||||
resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
jiti: '*'
|
||||
@@ -1826,9 +1787,6 @@ packages:
|
||||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||
hasBin: true
|
||||
|
||||
hookable@5.5.3:
|
||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
@@ -1895,10 +1853,6 @@ packages:
|
||||
is-what@3.14.1:
|
||||
resolution: {integrity: sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==}
|
||||
|
||||
is-what@5.5.0:
|
||||
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
@@ -2077,9 +2031,6 @@ packages:
|
||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
lodash-es@4.18.1:
|
||||
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
@@ -2171,9 +2122,6 @@ packages:
|
||||
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
mlly@1.7.4:
|
||||
resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==}
|
||||
|
||||
@@ -2276,9 +2224,6 @@ packages:
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
perfect-debounce@1.0.0:
|
||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||
|
||||
picocolors@1.0.0:
|
||||
resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
|
||||
|
||||
@@ -2301,15 +2246,6 @@ packages:
|
||||
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
pinia@3.0.4:
|
||||
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
|
||||
peerDependencies:
|
||||
typescript: '>=4.5.0'
|
||||
vue: ^3.5.11
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
pkg-types@1.3.1:
|
||||
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
||||
|
||||
@@ -2427,9 +2363,6 @@ packages:
|
||||
resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==}
|
||||
hasBin: true
|
||||
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
rolldown@1.0.0-rc.12:
|
||||
resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -2490,9 +2423,6 @@ packages:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
sortablejs@1.15.7:
|
||||
resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==}
|
||||
|
||||
source-map-js@1.2.0:
|
||||
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2516,10 +2446,6 @@ packages:
|
||||
resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
|
||||
deprecated: Please use @jridgewell/sourcemap-codec instead
|
||||
|
||||
speakingurl@14.0.1:
|
||||
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
sprintf-js@1.0.3:
|
||||
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
||||
|
||||
@@ -2559,10 +2485,6 @@ packages:
|
||||
stylis@4.2.0:
|
||||
resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==}
|
||||
|
||||
superjson@2.2.6:
|
||||
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
supports-color@5.5.0:
|
||||
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -2583,17 +2505,6 @@ packages:
|
||||
resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
|
||||
tdesign-icons-vue-next@0.4.10:
|
||||
resolution: {integrity: sha512-TOmy4cP5tXiLk5/rC6ZheRw+qeokqTytl4HLE+7qZYc185kPq659ATyn9a1i+c5ZSU3eHPY79kIaJUEOl7OoyQ==}
|
||||
peerDependencies:
|
||||
vue: ^3.0.0
|
||||
|
||||
tdesign-vue-next@1.20.6:
|
||||
resolution: {integrity: sha512-k9pKaWyyCPv1RGO8YEqhmOoAkS/WZ+CbS0CLWGMtuIltS+363dCZ4ggKhmu/y7Zvnv6Wdzoy8R5+ZIsuwsdvkw==}
|
||||
engines: {node: '>= 18'}
|
||||
peerDependencies:
|
||||
vue: '>=3.1.0'
|
||||
|
||||
terser@5.44.1:
|
||||
resolution: {integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2602,9 +2513,6 @@ packages:
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
tinycolor2@1.6.0:
|
||||
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
|
||||
|
||||
tinyexec@1.1.1:
|
||||
resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2703,10 +2611,6 @@ packages:
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
validator@13.15.35:
|
||||
resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
vite-plugin-dts@4.5.4:
|
||||
resolution: {integrity: sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg==}
|
||||
peerDependencies:
|
||||
@@ -3319,8 +3223,6 @@ snapshots:
|
||||
|
||||
'@pkgr/core@0.2.9': {}
|
||||
|
||||
'@popperjs/core@2.11.8': {}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0-rc.12':
|
||||
optional: true
|
||||
|
||||
@@ -3727,12 +3629,6 @@ snapshots:
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/lodash-es@4.17.12':
|
||||
dependencies:
|
||||
'@types/lodash': 4.17.25
|
||||
|
||||
'@types/lodash@4.17.25': {}
|
||||
|
||||
'@types/marked@6.0.0':
|
||||
dependencies:
|
||||
marked: 17.0.1
|
||||
@@ -3745,12 +3641,6 @@ snapshots:
|
||||
|
||||
'@types/prismjs@1.26.5': {}
|
||||
|
||||
'@types/sortablejs@1.15.9': {}
|
||||
|
||||
'@types/tinycolor2@1.4.6': {}
|
||||
|
||||
'@types/validator@13.15.10': {}
|
||||
|
||||
'@types/whatwg-mimetype@3.0.2': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
@@ -4011,24 +3901,6 @@ snapshots:
|
||||
de-indent: 1.0.2
|
||||
he: 1.2.0
|
||||
|
||||
'@vue/devtools-api@7.7.10':
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 7.7.10
|
||||
|
||||
'@vue/devtools-kit@7.7.10':
|
||||
dependencies:
|
||||
'@vue/devtools-shared': 7.7.10
|
||||
birpc: 2.9.0
|
||||
hookable: 5.5.3
|
||||
mitt: 3.0.1
|
||||
perfect-debounce: 1.0.0
|
||||
speakingurl: 14.0.1
|
||||
superjson: 2.2.6
|
||||
|
||||
'@vue/devtools-shared@7.7.10':
|
||||
dependencies:
|
||||
rfdc: 1.4.1
|
||||
|
||||
'@vue/language-core@2.2.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@volar/language-core': 2.4.11
|
||||
@@ -4186,8 +4058,6 @@ snapshots:
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
birpc@2.9.0: {}
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
brace-expansion@1.1.11:
|
||||
@@ -4277,10 +4147,6 @@ snapshots:
|
||||
dependencies:
|
||||
is-what: 3.14.1
|
||||
|
||||
copy-anything@4.0.5:
|
||||
dependencies:
|
||||
is-what: 5.5.0
|
||||
|
||||
cosmiconfig@7.1.0:
|
||||
dependencies:
|
||||
'@types/parse-json': 4.0.2
|
||||
@@ -4303,8 +4169,6 @@ snapshots:
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
dayjs@1.11.23: {}
|
||||
|
||||
de-indent@1.0.2: {}
|
||||
|
||||
debug@4.3.4:
|
||||
@@ -4623,8 +4487,6 @@ snapshots:
|
||||
|
||||
he@1.2.0: {}
|
||||
|
||||
hookable@5.5.3: {}
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
@@ -4679,8 +4541,6 @@ snapshots:
|
||||
|
||||
is-what@3.14.1: {}
|
||||
|
||||
is-what@5.5.0: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
istanbul-lib-coverage@3.2.2: {}
|
||||
@@ -4838,8 +4698,6 @@ snapshots:
|
||||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
|
||||
lodash-es@4.18.1: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
lodash@4.17.21: {}
|
||||
@@ -4926,8 +4784,6 @@ snapshots:
|
||||
|
||||
minipass@7.1.3: {}
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
mlly@1.7.4:
|
||||
dependencies:
|
||||
acorn: 8.14.1
|
||||
@@ -5023,8 +4879,6 @@ snapshots:
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
perfect-debounce@1.0.0: {}
|
||||
|
||||
picocolors@1.0.0: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
@@ -5038,13 +4892,6 @@ snapshots:
|
||||
pify@4.0.1:
|
||||
optional: true
|
||||
|
||||
pinia@3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 7.7.10
|
||||
vue: 3.5.26(typescript@5.9.3)
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
pkg-types@1.3.1:
|
||||
dependencies:
|
||||
confbox: 0.1.8
|
||||
@@ -5195,8 +5042,6 @@ snapshots:
|
||||
path-parse: 1.0.7
|
||||
supports-preserve-symlinks-flag: 1.0.0
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
rolldown@1.0.0-rc.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2):
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.122.0
|
||||
@@ -5286,8 +5131,6 @@ snapshots:
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
sortablejs@1.15.7: {}
|
||||
|
||||
source-map-js@1.2.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
@@ -5303,8 +5146,6 @@ snapshots:
|
||||
|
||||
sourcemap-codec@1.4.8: {}
|
||||
|
||||
speakingurl@14.0.1: {}
|
||||
|
||||
sprintf-js@1.0.3: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
@@ -5341,10 +5182,6 @@ snapshots:
|
||||
|
||||
stylis@4.2.0: {}
|
||||
|
||||
superjson@2.2.6:
|
||||
dependencies:
|
||||
copy-anything: 4.0.5
|
||||
|
||||
supports-color@5.5.0:
|
||||
dependencies:
|
||||
has-flag: 3.0.0
|
||||
@@ -5363,28 +5200,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@pkgr/core': 0.2.9
|
||||
|
||||
tdesign-icons-vue-next@0.4.10(vue@3.5.26(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
vue: 3.5.26(typescript@5.9.3)
|
||||
|
||||
tdesign-vue-next@1.20.6(vue@3.5.26(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
'@popperjs/core': 2.11.8
|
||||
'@types/lodash-es': 4.17.12
|
||||
'@types/sortablejs': 1.15.9
|
||||
'@types/tinycolor2': 1.4.6
|
||||
'@types/validator': 13.15.10
|
||||
dayjs: 1.11.23
|
||||
lodash-es: 4.18.1
|
||||
mitt: 3.0.1
|
||||
sortablejs: 1.15.7
|
||||
tdesign-icons-vue-next: 0.4.10(vue@3.5.26(typescript@5.9.3))
|
||||
tinycolor2: 1.6.0
|
||||
validator: 13.15.35
|
||||
vue: 3.5.26(typescript@5.9.3)
|
||||
|
||||
terser@5.44.1:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.6
|
||||
@@ -5394,8 +5209,6 @@ snapshots:
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinycolor2@1.6.0: {}
|
||||
|
||||
tinyexec@1.1.1: {}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
@@ -5492,8 +5305,6 @@ snapshots:
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
validator@13.15.35: {}
|
||||
|
||||
vite-plugin-dts@4.5.4(@types/node@25.0.8)(rollup@4.55.1)(typescript@5.9.3)(vite@8.0.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.0.8)(less@4.5.1)(terser@5.44.1)):
|
||||
dependencies:
|
||||
'@microsoft/api-extractor': 7.50.1(@types/node@25.0.8)
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { Article } from "../types";
|
||||
import { axios } from "./BaseAPI";
|
||||
|
||||
export const ArticleAPI = {
|
||||
/**
|
||||
* 获取文章
|
||||
*
|
||||
* @param id 文章 ID
|
||||
* @returns 文章数据
|
||||
*/
|
||||
async view(id: string): Promise<Article> {
|
||||
return axios.get(`/article/${id}`);
|
||||
},
|
||||
/**
|
||||
* 喜欢文章,后端有限调用,1240ms 一次
|
||||
*
|
||||
* @param id 文章 ID
|
||||
* @returns 最新喜欢数量
|
||||
*/
|
||||
async like(id: string): Promise<number> {
|
||||
return axios.get(`/article/like/${id}`);
|
||||
}
|
||||
};
|
||||
|
||||
export default ArticleAPI;
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Attachment, BizIdUpdate, TempFile } from "../types";
|
||||
import { axios } from "./BaseAPI";
|
||||
import CommonAPI from "./CommonAPI";
|
||||
|
||||
const BASE_URI = "/attach";
|
||||
|
||||
export const AttachmentAPI = {
|
||||
/**
|
||||
* 上传临时附件
|
||||
*
|
||||
* @param file 文件或文件列表
|
||||
* @param ttl 有效期
|
||||
* @returns 临时附件列表
|
||||
*/
|
||||
async upload(file: File | File[], ttl?: string): Promise<TempFile[]> {
|
||||
const formData = new FormData();
|
||||
const fileList = Array.isArray(file) ? file : [file];
|
||||
for (const item of fileList) {
|
||||
formData.append("file", item);
|
||||
}
|
||||
if (ttl) {
|
||||
formData.append("ttl", ttl);
|
||||
}
|
||||
return await axios.post(`${BASE_URI}/temp/upload`, formData);
|
||||
},
|
||||
/**
|
||||
* 按业务差分更新附件
|
||||
*
|
||||
* @param req 更新请求
|
||||
*/
|
||||
async updateByBizId(req: BizIdUpdate): Promise<void> {
|
||||
return await axios.post(`${BASE_URI}/update/biz/id`, req);
|
||||
},
|
||||
/**
|
||||
* 按业务查询附件列表
|
||||
*
|
||||
* @param bizType 业务类型
|
||||
* @param bizId 业务 ID
|
||||
* @param attachTypeList 附件类型列表
|
||||
*/
|
||||
async listByBizId(bizType: string, bizId: string, attachTypeList?: string[]): Promise<Attachment[]> {
|
||||
return await axios.get(`${BASE_URI}/list/biz/id`, {
|
||||
params: {
|
||||
bizType,
|
||||
bizId,
|
||||
attachTypeList
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getReadURL(id: string): string {
|
||||
return CommonAPI.getAttachmentReadAPI(id);
|
||||
},
|
||||
|
||||
getTempReadURL(id: string): string {
|
||||
return CommonAPI.getAttachmentTempReadAPI(id);
|
||||
}
|
||||
};
|
||||
|
||||
export default AttachmentAPI;
|
||||
@@ -1,16 +0,0 @@
|
||||
import Network from "../utils/Network";
|
||||
|
||||
let serviceAxios: any | null = null;
|
||||
|
||||
function getServiceAxios(): any {
|
||||
if (serviceAxios === null) {
|
||||
serviceAxios = Network.axios;
|
||||
}
|
||||
return serviceAxios;
|
||||
}
|
||||
|
||||
export const axios = new Proxy({} as any, {
|
||||
get(_target: any, prop: string | symbol, receiver: any): any {
|
||||
return Reflect.get(getServiceAxios(), prop, receiver);
|
||||
}
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { CaptchaData, Comment, Page, PageResult } from "../types";
|
||||
import { axios } from "./BaseAPI";
|
||||
|
||||
const BASE_URI = "/comment";
|
||||
|
||||
export const CommentAPI = {
|
||||
async page(page: Page<Comment>): Promise<PageResult<Comment>> {
|
||||
return axios.post(`${BASE_URI}/list`, page);
|
||||
},
|
||||
async create(captchaData: CaptchaData<Comment>): Promise<void> {
|
||||
return axios.post(`${BASE_URI}/create`, captchaData);
|
||||
}
|
||||
};
|
||||
|
||||
export default CommentAPI;
|
||||
@@ -1,45 +0,0 @@
|
||||
import { CaptchaResult, Setting } from "../types";
|
||||
import { axios } from "./BaseAPI";
|
||||
|
||||
function getBaseURI(): string {
|
||||
const baseURL = axios.defaults.baseURL;
|
||||
if (!baseURL || baseURL === "undefined") {
|
||||
return "";
|
||||
}
|
||||
return baseURL.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export const CommonAPI = {
|
||||
getCaptchaAPI(): string {
|
||||
return `${getBaseURI()}/captcha`;
|
||||
},
|
||||
async captcha(width: number, height: number): Promise<CaptchaResult> {
|
||||
return await axios.get(`/captcha?width=${width}&height=${height}`);
|
||||
},
|
||||
getAttachmentReadAPI(id: string): string {
|
||||
return `${getBaseURI()}/attach/read/${id}`;
|
||||
},
|
||||
getAttachmentTempReadAPI(id: string): string {
|
||||
return `${getBaseURI()}/attach/temp/read?id=${id}`;
|
||||
},
|
||||
async settingMap(map: Record<string, string[]>): Promise<Map<string, Map<string, Setting>>> {
|
||||
const raw = await axios.post("/setting/map", map);
|
||||
const moduleMap = new Map<string, Map<string, Setting>>();
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return moduleMap;
|
||||
}
|
||||
for (const [module, settingRawMap] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (!settingRawMap || typeof settingRawMap !== "object") {
|
||||
continue;
|
||||
}
|
||||
const settingMap = new Map<string, Setting>();
|
||||
for (const [key, setting] of Object.entries(settingRawMap as Record<string, Setting>)) {
|
||||
settingMap.set(key, setting);
|
||||
}
|
||||
moduleMap.set(module, settingMap);
|
||||
}
|
||||
return moduleMap;
|
||||
}
|
||||
};
|
||||
|
||||
export default CommonAPI;
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Developer } from "../types";
|
||||
import { axios } from "./BaseAPI";
|
||||
|
||||
const BASE_URI = "/git/developer";
|
||||
|
||||
export const DeveloperAPI = {
|
||||
async get(): Promise<Developer> {
|
||||
return axios.post(`${BASE_URI}`);
|
||||
}
|
||||
};
|
||||
|
||||
export default DeveloperAPI;
|
||||
@@ -1,51 +0,0 @@
|
||||
import { axios } from "./BaseAPI";
|
||||
import type { CaptchaData, Notify, NotifyDetail, Page, PageResult } from "../types";
|
||||
|
||||
const BASE_URI = "/notify/internal";
|
||||
|
||||
export const NotifyAPI = {
|
||||
|
||||
async smsSend(captchaData: CaptchaData<Notify>) {
|
||||
return await axios.post("/notify/sms/captcha/create", captchaData);
|
||||
},
|
||||
|
||||
/** 查询当前用户的站内通知详情。 */
|
||||
async internalDetail(id: string): Promise<NotifyDetail> {
|
||||
return await axios.post(`${BASE_URI}/detail`, undefined, {
|
||||
params: { id }
|
||||
});
|
||||
},
|
||||
|
||||
/** 查询当前用户的站内通知。 */
|
||||
async listInternal(page?: Page<NotifyDetail>, unreadOnly = false): Promise<PageResult<NotifyDetail>> {
|
||||
return await axios.post(`${BASE_URI}/internal`, page, {
|
||||
params: { unreadOnly }
|
||||
});
|
||||
},
|
||||
|
||||
/** 查询当前用户未读站内通知数量。 */
|
||||
async countUnreadInternal(): Promise<number> {
|
||||
return await axios.post(`${BASE_URI}/unread/count`);
|
||||
},
|
||||
|
||||
/** 标记当前用户的一条站内通知已读。 */
|
||||
async markInternalRead(id: string): Promise<void> {
|
||||
await axios.post(`${BASE_URI}/read`, undefined, {
|
||||
params: { id }
|
||||
});
|
||||
},
|
||||
|
||||
/** 删除当前用户的一条站内通知。 */
|
||||
async deleteInternal(id: string): Promise<void> {
|
||||
await axios.post(`${BASE_URI}/delete`, undefined, {
|
||||
params: { id }
|
||||
});
|
||||
},
|
||||
|
||||
/** 标记当前用户全部站内通知已读。 */
|
||||
async markAllInternalRead(): Promise<void> {
|
||||
await axios.post(`${BASE_URI}/read/all`);
|
||||
}
|
||||
};
|
||||
|
||||
export default NotifyAPI;
|
||||
@@ -1,29 +0,0 @@
|
||||
import { axios } from "./BaseAPI";
|
||||
import type { ModuleCode, Page, PageResult, Permission, PermissionPayload } from "../types";
|
||||
|
||||
const BASE_URI = "/user/permission";
|
||||
|
||||
export const PermissionAPI = {
|
||||
async list(req: Page<Permission>): Promise<PageResult<Permission>> {
|
||||
return axios.post(`${BASE_URI}/list`, req);
|
||||
},
|
||||
async create(req: PermissionPayload): Promise<void> {
|
||||
return axios.post(`${BASE_URI}/create`, req);
|
||||
},
|
||||
async update(req: PermissionPayload): Promise<void> {
|
||||
return axios.post(`${BASE_URI}/update`, req);
|
||||
},
|
||||
async remove(id: string): Promise<void> {
|
||||
return axios.post(`${BASE_URI}/delete`, undefined, {
|
||||
params: { id }
|
||||
});
|
||||
},
|
||||
/** 查询当前登录用户在指定模块内可转授的权限。 */
|
||||
async grantableList(moduleCode: ModuleCode): Promise<Permission[]> {
|
||||
return await axios.post(`${BASE_URI}/grantable/list`, undefined, {
|
||||
params: { moduleCode }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default PermissionAPI;
|
||||
@@ -1,94 +0,0 @@
|
||||
import { axios } from "./BaseAPI";
|
||||
import type { ModuleCode, Page, PageResult, Permission, Role, RolePayload, AuthorizeUserRole } from "../types";
|
||||
|
||||
const BASE_URI = "/user/role";
|
||||
|
||||
export const RoleAPI = {
|
||||
async list(req: Page<Role>): Promise<PageResult<Role>> {
|
||||
return axios.post(`${BASE_URI}/list`, req);
|
||||
},
|
||||
async detail(id: string): Promise<Role> {
|
||||
return axios.post(`${BASE_URI}/detail`, undefined, {
|
||||
params: { id }
|
||||
});
|
||||
},
|
||||
async create(req: RolePayload): Promise<void> {
|
||||
return axios.post(`${BASE_URI}/create`, req);
|
||||
},
|
||||
async update(req: RolePayload): Promise<void> {
|
||||
return axios.post(`${BASE_URI}/update`, req);
|
||||
},
|
||||
async remove(id: string): Promise<void> {
|
||||
return axios.post(`${BASE_URI}/delete`, undefined, {
|
||||
params: { id }
|
||||
});
|
||||
},
|
||||
/** 查询当前登录用户在模块内可管理的角色。 */
|
||||
async manageableList(moduleCode: ModuleCode): Promise<Role[]> {
|
||||
return await axios.post(`${BASE_URI}/manageable/list`, undefined, {
|
||||
params: { moduleCode }
|
||||
});
|
||||
},
|
||||
/** 查询当前登录用户在模块内可授权给账号的角色。 */
|
||||
async grantableList(moduleCode: ModuleCode): Promise<Role[]> {
|
||||
return await axios.post(`${BASE_URI}/grantable/list`, undefined, {
|
||||
params: { moduleCode }
|
||||
});
|
||||
},
|
||||
/** 查询当前登录用户直属角色详情,不按可管理范围过滤。 */
|
||||
async currentDetailList(moduleCode?: ModuleCode): Promise<Role[]> {
|
||||
return await axios.post(`${BASE_URI}/current/detail/list`, undefined, {
|
||||
params: { moduleCode }
|
||||
});
|
||||
},
|
||||
/** 查询当前登录用户可以给目标角色配置的直接权限 P 候选项。 */
|
||||
async grantablePermissionList(roleId: string): Promise<Permission[]> {
|
||||
return await axios.post(`${BASE_URI}/permission/grantable/list`, undefined, {
|
||||
params: { roleId }
|
||||
});
|
||||
},
|
||||
/** 查询目标角色当前可继续转授的权限 D。 */
|
||||
async delegationList(roleId: string): Promise<Permission[]> {
|
||||
return await axios.post(`${BASE_URI}/delegation/list`, undefined, {
|
||||
params: { roleId }
|
||||
});
|
||||
},
|
||||
/** 查询当前登录用户可以给目标角色配置的 D 候选项。 */
|
||||
async grantableDelegationList(roleId: string): Promise<Permission[]> {
|
||||
return await axios.post(`${BASE_URI}/delegation/grantable/list`, undefined, {
|
||||
params: { roleId }
|
||||
});
|
||||
},
|
||||
/** 覆盖保存目标角色可继续转授的权限 D。 */
|
||||
async updateDelegation(req: Pick<RolePayload, "id" | "delegationPermissionIdList">): Promise<void> {
|
||||
await axios.post(`${BASE_URI}/delegation/update`, req);
|
||||
},
|
||||
/** 查询目标角色可选管理父级。 */
|
||||
async parentGrantableList(roleId: string): Promise<Role[]> {
|
||||
return await axios.post(`${BASE_URI}/parent/grantable/list`, undefined, {
|
||||
params: { roleId }
|
||||
});
|
||||
},
|
||||
/** 保存目标角色管理父级。 */
|
||||
async updateParent(req: Pick<RolePayload, "id" | "parentRoleId">): Promise<void> {
|
||||
await axios.post(`${BASE_URI}/parent/update`, req);
|
||||
},
|
||||
async authorizedList(userId: string, moduleCode?: ModuleCode): Promise<Role[]> {
|
||||
return axios.post(`${BASE_URI}/authorized/list`, undefined, {
|
||||
params: {
|
||||
userId,
|
||||
moduleCode
|
||||
}
|
||||
});
|
||||
},
|
||||
async authorize(req: AuthorizeUserRole): Promise<void> {
|
||||
return axios.post(`${BASE_URI}/authorized/create`, req);
|
||||
},
|
||||
async authorizedPermission(userId: string): Promise<Permission[]> {
|
||||
return axios.post(`${BASE_URI}/authorized/permission`, undefined, {
|
||||
params: { userId }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default RoleAPI;
|
||||
@@ -1,83 +0,0 @@
|
||||
import type {Attachment, CaptchaData, Gender, LoginRequest, LoginResponse, RegisterRequest, SmsLoginRequest, UpdatePasswordRequest, User, UserAttachType} from "../types";
|
||||
import {axios} from "./BaseAPI";
|
||||
import CommonAPI from "./CommonAPI";
|
||||
|
||||
const BASE_URI = "/user";
|
||||
type UserAttachmentOwner = {
|
||||
attachmentList?: readonly Attachment[];
|
||||
};
|
||||
type UpdateCurrentRequest = Omit<Partial<User>, "gender"> & {
|
||||
gender?: Gender | "";
|
||||
};
|
||||
|
||||
function findAttachmentByType(attachmentList: readonly Attachment[], ...types: UserAttachType[]) {
|
||||
for (let i = 0; i < attachmentList.length; i++) {
|
||||
const attachType = attachmentList[i].attachType as UserAttachType | undefined;
|
||||
const id = attachmentList[i].id;
|
||||
if (id && attachType && types.includes(attachType)) {
|
||||
return CommonAPI.getAttachmentReadAPI(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const UserAPI = {
|
||||
async register(req: CaptchaData<RegisterRequest>): Promise<LoginResponse> {
|
||||
return await axios.post(`${BASE_URI}/register`, req);
|
||||
},
|
||||
/**
|
||||
* 登录
|
||||
*
|
||||
* @param req 验证码登录对象
|
||||
* @returns LoginResponse
|
||||
*/
|
||||
async login(req: CaptchaData<LoginRequest>): Promise<LoginResponse> {
|
||||
return await axios.post(`${BASE_URI}/login`, req);
|
||||
},
|
||||
/**
|
||||
* 短信验证码登录
|
||||
*
|
||||
* @param req 短信验证码登录请求
|
||||
* @returns LoginResponse
|
||||
*/
|
||||
async loginBySms(req: SmsLoginRequest): Promise<LoginResponse> {
|
||||
return await axios.post(`${BASE_URI}/login/sms`, req);
|
||||
},
|
||||
/**
|
||||
* 验证是否已登录
|
||||
*
|
||||
* @returns true 为已登录
|
||||
*/
|
||||
async loginByToken(): Promise<LoginResponse> {
|
||||
return await axios.post(`${BASE_URI}/login/token`);
|
||||
},
|
||||
async logout(): Promise<void> {
|
||||
return await axios.post(`${BASE_URI}/logout`);
|
||||
},
|
||||
async updateCurrent(req: UpdateCurrentRequest): Promise<LoginResponse> {
|
||||
return await axios.post(`${BASE_URI}/current/update`, req);
|
||||
},
|
||||
async updatePassword(req: UpdatePasswordRequest): Promise<void> {
|
||||
return await axios.post(`${BASE_URI}/update/password`, req);
|
||||
},
|
||||
/**
|
||||
* 获取用户数据
|
||||
*
|
||||
* @param id 用户 ID
|
||||
* @returns 用户数据
|
||||
*/
|
||||
async view(id: string): Promise<User> {
|
||||
return await axios.post(`${BASE_URI}/view/${id}`);
|
||||
},
|
||||
getAvatarURL(user?: UserAttachmentOwner) {
|
||||
if (user?.attachmentList) {
|
||||
return findAttachmentByType(user.attachmentList, "AVATAR");
|
||||
}
|
||||
},
|
||||
getWrapperURL(user?: UserAttachmentOwner) {
|
||||
if (user?.attachmentList) {
|
||||
return findAttachmentByType(user.attachmentList, "WRAPPER");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default UserAPI;
|
||||
@@ -1,9 +0,0 @@
|
||||
export { ArticleAPI } from "./ArticleAPI";
|
||||
export { AttachmentAPI } from "./AttachmentAPI";
|
||||
export { CommentAPI } from "./CommentAPI";
|
||||
export { CommonAPI } from "./CommonAPI";
|
||||
export { DeveloperAPI } from "./DeveloperAPI";
|
||||
export { NotifyAPI } from "./NotifyAPI";
|
||||
export { PermissionAPI } from "./PermissionAPI";
|
||||
export { RoleAPI } from "./RoleAPI";
|
||||
export { UserAPI } from "./UserAPI";
|
||||
@@ -3,18 +3,16 @@
|
||||
.custom-scroll-bar *::-webkit-scrollbar {
|
||||
width: 10px !important;
|
||||
height: 10px !important;
|
||||
cursor: var(--tui-cur-default);
|
||||
background: #CFD2E0;
|
||||
}
|
||||
|
||||
.custom-scroll-bar *::-webkit-scrollbar-corner {
|
||||
background: #CFD2E0;
|
||||
cursor: var(--tui-cur-default);
|
||||
}
|
||||
|
||||
.custom-scroll-bar *::-webkit-scrollbar-thumb {
|
||||
cursor: var(--tui-cur-default);
|
||||
background: #525870;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
*::selection {
|
||||
@@ -23,12 +21,11 @@
|
||||
}
|
||||
|
||||
html {
|
||||
cursor: var(--tui-cur-default);
|
||||
font-family: pingfang sc, microsoft yahei, arial regular, serif;
|
||||
text-underline-offset: .16rem;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100% !important;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
@@ -38,17 +35,10 @@ body {
|
||||
&::-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 {
|
||||
@@ -71,20 +61,11 @@ input, select, textarea {
|
||||
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,
|
||||
|
||||
@@ -58,3 +58,13 @@ each(@tuiColors, {
|
||||
background: @value;
|
||||
}
|
||||
});
|
||||
|
||||
.build-width-class(@width, @max) when (@width =< @max) {
|
||||
.w-@{width} {
|
||||
width: unit((@width / 16), rem);
|
||||
}
|
||||
|
||||
.build-width-class((@width + 10), @max);
|
||||
}
|
||||
|
||||
.build-width-class(80, 600);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import view from "./index.vue";
|
||||
import WebToolkit from "../../utils/WebToolkit";
|
||||
|
||||
export const AnimatedNumber = WebToolkit.withInstall(view);
|
||||
export default AnimatedNumber;
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<span class="tui-animated-number">{{ displayValue }}</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineOptions({
|
||||
name: "AnimatedNumber"
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 目标数值 */
|
||||
value: number;
|
||||
/** 动画持续时间(毫秒),默认 1000ms */
|
||||
duration?: number;
|
||||
/** 小数位数,undefined 表示自动 */
|
||||
decimals?: number;
|
||||
/** 缓动函数类型 */
|
||||
easing?: "linear" | "easeOut" | "easeInOut";
|
||||
/** 是否启用千分位分隔符 */
|
||||
thousands?: boolean;
|
||||
/** 千分位分隔符,默认逗号 */
|
||||
separator?: string;
|
||||
}>(), {
|
||||
duration: 1000,
|
||||
easing: "easeOut",
|
||||
thousands: false,
|
||||
separator: ","
|
||||
});
|
||||
|
||||
const { value, duration, decimals, easing, thousands, separator } = toRefs(props);
|
||||
|
||||
// 当前显示的数值
|
||||
const currentValue = ref(value.value);
|
||||
// 格式化后的显示值
|
||||
const displayValue = ref(formatNumber(value.value));
|
||||
// 动画相关
|
||||
let animationFrameId: number | null = null;
|
||||
let startValue = value.value;
|
||||
let startTime: number | null = null;
|
||||
|
||||
/**
|
||||
* 缓动函数
|
||||
*/
|
||||
function ease(t: number, type: string): number {
|
||||
switch (type) {
|
||||
case "linear":
|
||||
return t;
|
||||
case "easeOut":
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
case "easeInOut":
|
||||
return t < 0.5
|
||||
? 4 * t * t * t
|
||||
: 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
default:
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加千分位分隔符
|
||||
*/
|
||||
function addThousandsSeparator(numStr: string, sep: string): string {
|
||||
const parts = numStr.split(".");
|
||||
const integerPart = parts[0];
|
||||
const decimalPart = parts[1] ? "." + parts[1] : "";
|
||||
|
||||
// 处理负号
|
||||
const isNegative = integerPart.startsWith("-");
|
||||
const absoluteInteger = isNegative ? integerPart.slice(1) : integerPart;
|
||||
|
||||
// 对整数部分添加千分位分隔符
|
||||
const formatted = absoluteInteger.replace(/\B(?=(\d{3})+(?!\d))/g, sep);
|
||||
|
||||
return (isNegative ? "-" : "") + formatted + decimalPart;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字
|
||||
*/
|
||||
function formatNumber(num: number): string {
|
||||
let result: string;
|
||||
|
||||
if (decimals.value !== undefined) {
|
||||
result = num.toFixed(decimals.value);
|
||||
} else {
|
||||
// 自动检测小数位数
|
||||
const str = num.toString();
|
||||
if (str.includes(".")) {
|
||||
// 保留原有的小数位数,但最多保留 6 位
|
||||
const decimalPart = str.split(".")[1];
|
||||
const decimalLength = Math.min(decimalPart.length, 6);
|
||||
result = num.toFixed(decimalLength);
|
||||
} else {
|
||||
result = num.toString();
|
||||
}
|
||||
}
|
||||
|
||||
// 应用千分位分隔符
|
||||
if (thousands.value) {
|
||||
result = addThousandsSeparator(result, separator.value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 动画更新函数
|
||||
*/
|
||||
function animate(timestamp: number) {
|
||||
if (startTime === null) {
|
||||
startTime = timestamp;
|
||||
}
|
||||
|
||||
const elapsed = timestamp - startTime;
|
||||
const progress = Math.min(elapsed / duration.value, 1);
|
||||
const easedProgress = ease(progress, easing.value);
|
||||
|
||||
// 计算当前值
|
||||
currentValue.value = startValue + (value.value - startValue) * easedProgress;
|
||||
displayValue.value = formatNumber(currentValue.value);
|
||||
|
||||
if (progress < 1) {
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
// 动画结束,确保显示精确的目标值
|
||||
currentValue.value = value.value;
|
||||
displayValue.value = formatNumber(value.value);
|
||||
animationFrameId = null;
|
||||
startTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始动画
|
||||
*/
|
||||
function startAnimation() {
|
||||
// 取消之前的动画
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
|
||||
startValue = currentValue.value;
|
||||
startTime = null;
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
// 监听 value 变化
|
||||
watch(value, (newValue) => {
|
||||
if (newValue !== currentValue.value) {
|
||||
startAnimation();
|
||||
}
|
||||
});
|
||||
|
||||
// 监听 decimals 变化,立即更新显示格式
|
||||
watch(decimals, () => {
|
||||
displayValue.value = formatNumber(currentValue.value);
|
||||
});
|
||||
|
||||
// 监听 thousands 和 separator 变化,立即更新显示格式
|
||||
watch([thousands, separator], () => {
|
||||
displayValue.value = formatNumber(currentValue.value);
|
||||
});
|
||||
|
||||
// 组件卸载时清理动画
|
||||
onUnmounted(() => {
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
currentValue.value = value.value;
|
||||
displayValue.value = formatNumber(value.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.tui-animated-number {
|
||||
display: inline-block;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: "tnum";
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import view from "./index.vue";
|
||||
import Toolkit from "../../utils/Toolkit";
|
||||
import type { Article } from "../../types";
|
||||
|
||||
export * from "./shared";
|
||||
export type ArticleEditorExpose = {
|
||||
submit: () => Promise<Article>;
|
||||
getPendingMediaCount: () => number;
|
||||
};
|
||||
|
||||
export const ArticleEditor = Toolkit.withInstall(view);
|
||||
export default ArticleEditor;
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
<template>
|
||||
<t-textarea
|
||||
v-if="isTextContent"
|
||||
:model-value="editorContent"
|
||||
:autosize="textareaAutosize"
|
||||
:placeholder="placeholder"
|
||||
@update:model-value="onSimpleEditorCommit"
|
||||
/>
|
||||
<markdown-editor
|
||||
v-else-if="isMarkdownContent"
|
||||
:data="editorContent"
|
||||
@update:data="onSimpleEditorCommit"
|
||||
/>
|
||||
<tiptap-editor
|
||||
v-else
|
||||
ref="tiptapEditorRef"
|
||||
:model-value="editorContent"
|
||||
:placeholder="placeholder"
|
||||
:upload-image="handleUploadImage"
|
||||
:upload-video="handleUploadVideo"
|
||||
@change="onEditorChange"
|
||||
@update:model-value="onEditorCommit"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Article, ArticleContentType, Attachment } from "../../types";
|
||||
import AttachmentAPI from "../../api/AttachmentAPI";
|
||||
import { MarkdownEditor } from "../markdown-editor";
|
||||
import type { TiptapEditorExpose, UploadResult } from "../tiptap-editor";
|
||||
import { TiptapEditor } from "../tiptap-editor";
|
||||
import {
|
||||
ATTACHMENT_ID_ATTR,
|
||||
TEMP_FILE_ID_ATTR,
|
||||
buildAttachmentListFromContent,
|
||||
getAttachmentIdFromSrc,
|
||||
getTempFileIdFromSrc,
|
||||
normalizeArticleContentType,
|
||||
normalizeContent,
|
||||
normalizeContentForStore,
|
||||
normalizeRawArticleContent,
|
||||
sameAttachmentList
|
||||
} from "./shared";
|
||||
|
||||
defineOptions({
|
||||
name: "ArticleEditor"
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: Article;
|
||||
placeholder?: string;
|
||||
uploadImage?: (file: File) => Promise<UploadResult>;
|
||||
uploadVideo?: (file: File) => Promise<UploadResult>;
|
||||
resolveAttachmentUrl?: (attachmentId: string, attachment?: Attachment) => string | Promise<string>;
|
||||
resolveTempUrl?: (tempFileId: string) => string | Promise<string>;
|
||||
}>(), {
|
||||
modelValue: () => ({}),
|
||||
placeholder: "请输入文章正文内容"
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"change": [value: Article];
|
||||
"update:modelValue": [value: Article];
|
||||
}>();
|
||||
|
||||
const textareaAutosize = {
|
||||
minRows: 8,
|
||||
maxRows: 32
|
||||
};
|
||||
|
||||
const editorContent = ref("");
|
||||
const tiptapEditorRef = ref<TiptapEditorExpose>();
|
||||
const lastOutgoingContent = ref("");
|
||||
const lastOutgoingAttachmentList = ref<Attachment[]>([]);
|
||||
|
||||
const contentType = computed(() => {
|
||||
return normalizeArticleContentType(props.modelValue?.contentType);
|
||||
});
|
||||
const isTextContent = computed(() => contentType.value === "TEXT");
|
||||
const isMarkdownContent = computed(() => contentType.value === "MARKDOWN");
|
||||
|
||||
const attachmentMap = computed(() => {
|
||||
return new Map((props.modelValue?.attachmentList || []).map(item => [item.id, item]));
|
||||
});
|
||||
|
||||
async function defaultUploadImage(file: File) {
|
||||
return defaultUploadMedia(file);
|
||||
}
|
||||
|
||||
async function defaultUploadVideo(file: File) {
|
||||
return defaultUploadMedia(file);
|
||||
}
|
||||
|
||||
async function defaultUploadMedia(file: File) {
|
||||
const tempFileList = await AttachmentAPI.upload([file]);
|
||||
const tempFileId = tempFileList[0]?.id || "";
|
||||
if (!tempFileId) {
|
||||
throw new Error("上传临时文件失败");
|
||||
}
|
||||
return {
|
||||
id: tempFileId,
|
||||
url: AttachmentAPI.getTempReadURL(tempFileId),
|
||||
name: file.name
|
||||
};
|
||||
}
|
||||
|
||||
async function handleUploadImage(file: File) {
|
||||
if (props.uploadImage) {
|
||||
return props.uploadImage(file);
|
||||
}
|
||||
return defaultUploadImage(file);
|
||||
}
|
||||
|
||||
async function handleUploadVideo(file: File) {
|
||||
if (props.uploadVideo) {
|
||||
return props.uploadVideo(file);
|
||||
}
|
||||
return defaultUploadVideo(file);
|
||||
}
|
||||
|
||||
async function resolveAttachmentPreviewUrl(attachmentId: string) {
|
||||
const attachment = attachmentMap.value.get(attachmentId);
|
||||
if (props.resolveAttachmentUrl) {
|
||||
const customUrl = await props.resolveAttachmentUrl(attachmentId, attachment);
|
||||
if (customUrl) {
|
||||
return customUrl;
|
||||
}
|
||||
}
|
||||
const previewUrl = `${attachment?.metadata?.previewUrl || ""}`;
|
||||
if (previewUrl) {
|
||||
return previewUrl;
|
||||
}
|
||||
return attachmentId ? AttachmentAPI.getReadURL(attachmentId) : "";
|
||||
}
|
||||
|
||||
async function resolveTempPreviewUrl(tempFileId: string) {
|
||||
if (props.resolveTempUrl) {
|
||||
return props.resolveTempUrl(tempFileId);
|
||||
}
|
||||
return AttachmentAPI.getTempReadURL(tempFileId);
|
||||
}
|
||||
|
||||
async function convertContentForEditor(value?: string) {
|
||||
if (contentType.value !== "TIP_TAP") {
|
||||
return normalizeRawArticleContent(value);
|
||||
}
|
||||
const rawContent = normalizeContent(value);
|
||||
if (!rawContent) {
|
||||
return "";
|
||||
}
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(rawContent, "text/html");
|
||||
const mediaList = Array.from(doc.querySelectorAll("img, video"));
|
||||
for (const media of mediaList) {
|
||||
const src = media.getAttribute("src") || "";
|
||||
const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || getAttachmentIdFromSrc(src);
|
||||
const tempFileId = media.getAttribute(TEMP_FILE_ID_ATTR) || getTempFileIdFromSrc(src);
|
||||
if (tempFileId) {
|
||||
media.setAttribute(TEMP_FILE_ID_ATTR, tempFileId);
|
||||
media.setAttribute(ATTACHMENT_ID_ATTR, attachmentId || tempFileId);
|
||||
media.setAttribute("src", await resolveTempPreviewUrl(tempFileId));
|
||||
continue;
|
||||
}
|
||||
if (!attachmentId) {
|
||||
continue;
|
||||
}
|
||||
media.setAttribute(ATTACHMENT_ID_ATTR, attachmentId);
|
||||
const previewUrl = await resolveAttachmentPreviewUrl(attachmentId);
|
||||
if (previewUrl) {
|
||||
media.setAttribute("src", previewUrl);
|
||||
}
|
||||
}
|
||||
return normalizeContent(doc.body.innerHTML);
|
||||
}
|
||||
|
||||
function normalizeIncomingArticleContent(content: string | undefined, currentContentType: ArticleContentType) {
|
||||
if (currentContentType === "TIP_TAP") {
|
||||
return normalizeContent(content);
|
||||
}
|
||||
return normalizeRawArticleContent(content);
|
||||
}
|
||||
|
||||
function buildStoredContent(content: string, currentContentType: ArticleContentType) {
|
||||
if (currentContentType === "TIP_TAP") {
|
||||
return normalizeContentForStore(content);
|
||||
}
|
||||
return normalizeRawArticleContent(content);
|
||||
}
|
||||
|
||||
function buildArticleAttachmentList(content: string, currentContentType: ArticleContentType) {
|
||||
if (currentContentType === "TIP_TAP") {
|
||||
return buildAttachmentListFromContent(content);
|
||||
}
|
||||
return props.modelValue?.attachmentList || [];
|
||||
}
|
||||
|
||||
function buildArticleValue(content: string) {
|
||||
const nextContentType = contentType.value;
|
||||
return {
|
||||
...(props.modelValue || {}),
|
||||
contentType: nextContentType,
|
||||
content: buildStoredContent(content, nextContentType),
|
||||
attachmentList: buildArticleAttachmentList(content, nextContentType)
|
||||
};
|
||||
}
|
||||
|
||||
function syncLastOutgoingArticle(value: Article) {
|
||||
const nextContentType = normalizeArticleContentType(value.contentType);
|
||||
lastOutgoingContent.value = normalizeIncomingArticleContent(value.content, nextContentType);
|
||||
lastOutgoingAttachmentList.value = value.attachmentList || [];
|
||||
}
|
||||
|
||||
// 实时变更只给预览态使用,不能反向驱动编辑器提交态
|
||||
function onEditorChange(value: string) {
|
||||
const nextValue = buildArticleValue(value);
|
||||
syncLastOutgoingArticle(nextValue);
|
||||
emit("change", {
|
||||
...nextValue
|
||||
});
|
||||
}
|
||||
|
||||
// 提交态只在离开代码块后的同步时机更新
|
||||
function onEditorCommit(value: string) {
|
||||
editorContent.value = value;
|
||||
const nextValue = buildArticleValue(value);
|
||||
syncLastOutgoingArticle(nextValue);
|
||||
emit("update:modelValue", {
|
||||
...nextValue
|
||||
});
|
||||
}
|
||||
|
||||
function onSimpleEditorCommit(value: string) {
|
||||
editorContent.value = value;
|
||||
const nextValue = buildArticleValue(value);
|
||||
syncLastOutgoingArticle(nextValue);
|
||||
emit("change", {
|
||||
...nextValue
|
||||
});
|
||||
emit("update:modelValue", {
|
||||
...nextValue
|
||||
});
|
||||
}
|
||||
|
||||
watch(() => [props.modelValue?.content, contentType.value] as const, async ([value, nextContentType]) => {
|
||||
const nextValue = normalizeIncomingArticleContent(value, nextContentType);
|
||||
if (nextValue === lastOutgoingContent.value) {
|
||||
return;
|
||||
}
|
||||
if (buildStoredContent(editorContent.value, nextContentType) === nextValue) {
|
||||
return;
|
||||
}
|
||||
const nextContent = await convertContentForEditor(nextValue);
|
||||
if (nextContent === editorContent.value) {
|
||||
return;
|
||||
}
|
||||
editorContent.value = nextContent;
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => props.modelValue?.attachmentList, async (value) => {
|
||||
if (contentType.value !== "TIP_TAP") {
|
||||
return;
|
||||
}
|
||||
if (sameAttachmentList(lastOutgoingAttachmentList.value, value)) {
|
||||
return;
|
||||
}
|
||||
if (sameAttachmentList(buildAttachmentListFromContent(editorContent.value), value)) {
|
||||
return;
|
||||
}
|
||||
const nextContent = await convertContentForEditor(props.modelValue?.content);
|
||||
if (nextContent === editorContent.value) {
|
||||
return;
|
||||
}
|
||||
editorContent.value = nextContent;
|
||||
}, { deep: true });
|
||||
|
||||
async function submit() {
|
||||
const nextEditorContent = contentType.value === "TIP_TAP" && tiptapEditorRef.value ? await tiptapEditorRef.value.submit() : editorContent.value;
|
||||
const nextValue = buildArticleValue(nextEditorContent);
|
||||
editorContent.value = nextEditorContent;
|
||||
syncLastOutgoingArticle(nextValue);
|
||||
emit("update:modelValue", nextValue);
|
||||
return nextValue;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
submit,
|
||||
getPendingMediaCount: () => tiptapEditorRef.value?.getPendingMediaCount() || 0
|
||||
});
|
||||
</script>
|
||||
@@ -1,138 +0,0 @@
|
||||
import type { ArticleContentType, Attachment } from "../../types";
|
||||
import { ATTACHMENT_ID_ATTR, TEMP_FILE_ID_ATTR, isLocalMediaId, normalizeContent } from "../tiptap-editor/shared";
|
||||
|
||||
export const ATTACHMENT_ID_PREFIX = "attachment-id:";
|
||||
export const TEMP_FILE_ID_PREFIX = "temp-file-id:";
|
||||
export const ARTICLE_IMAGE_ATTACH_TYPE = "CONTENT_IMAGE";
|
||||
export const ARTICLE_VIDEO_ATTACH_TYPE = "CONTENT_VIDEO";
|
||||
export const DEFAULT_ARTICLE_CONTENT_TYPE: ArticleContentType = "TIP_TAP";
|
||||
export const ARTICLE_CONTENT_TYPE_LIST: ArticleContentType[] = ["TEXT", "MARKDOWN", "TIP_TAP"];
|
||||
|
||||
export { ATTACHMENT_ID_ATTR, TEMP_FILE_ID_ATTR, normalizeContent };
|
||||
|
||||
export function normalizeArticleContentType(contentType?: string): ArticleContentType {
|
||||
if (contentType === "TIPTAP") {
|
||||
return "TIP_TAP";
|
||||
}
|
||||
return ARTICLE_CONTENT_TYPE_LIST.includes(contentType as ArticleContentType) ? contentType as ArticleContentType : DEFAULT_ARTICLE_CONTENT_TYPE;
|
||||
}
|
||||
|
||||
export function normalizeRawArticleContent(content?: string) {
|
||||
return content || "";
|
||||
}
|
||||
|
||||
export function getAttachmentIdFromSrc(src?: string) {
|
||||
if (!src?.startsWith(ATTACHMENT_ID_PREFIX)) {
|
||||
return "";
|
||||
}
|
||||
return src.slice(ATTACHMENT_ID_PREFIX.length);
|
||||
}
|
||||
|
||||
export function getTempFileIdFromSrc(src?: string) {
|
||||
if (!src?.startsWith(TEMP_FILE_ID_PREFIX)) {
|
||||
return "";
|
||||
}
|
||||
return src.slice(TEMP_FILE_ID_PREFIX.length);
|
||||
}
|
||||
|
||||
export function buildAttachmentIdSrc(attachmentId: string) {
|
||||
return `${ATTACHMENT_ID_PREFIX}${attachmentId}`;
|
||||
}
|
||||
|
||||
function getMediaAttachType(tagName: string) {
|
||||
return tagName.toLowerCase() === "video" ? ARTICLE_VIDEO_ATTACH_TYPE : ARTICLE_IMAGE_ATTACH_TYPE;
|
||||
}
|
||||
|
||||
function buildAttachmentMetadata(src: string) {
|
||||
if (!src.startsWith("blob:")) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
previewUrl: src
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAttachmentTempFileId(tempFileId?: string) {
|
||||
if (!tempFileId || isLocalMediaId(tempFileId)) {
|
||||
return undefined;
|
||||
}
|
||||
return tempFileId;
|
||||
}
|
||||
|
||||
export function buildAttachmentListFromContent(content?: string) {
|
||||
const rawContent = normalizeContent(content);
|
||||
if (!rawContent) {
|
||||
return [] as Attachment[];
|
||||
}
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(rawContent, "text/html");
|
||||
const mediaList = Array.from(doc.querySelectorAll("img, video"));
|
||||
const attachmentMap = new Map<string, Attachment>();
|
||||
|
||||
for (const media of mediaList) {
|
||||
const src = media.getAttribute("src") || "";
|
||||
const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || getAttachmentIdFromSrc(src);
|
||||
const tempFileId = media.getAttribute(TEMP_FILE_ID_ATTR) || getTempFileIdFromSrc(src);
|
||||
const key = attachmentId || tempFileId;
|
||||
if (!key || attachmentMap.has(key)) {
|
||||
continue;
|
||||
}
|
||||
attachmentMap.set(key, {
|
||||
id: attachmentId || key,
|
||||
tempFileId: normalizeAttachmentTempFileId(tempFileId),
|
||||
name: media.getAttribute("title") || media.getAttribute("alt") || "",
|
||||
attachType: getMediaAttachType(media.tagName),
|
||||
metadata: buildAttachmentMetadata(src)
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(attachmentMap.values());
|
||||
}
|
||||
|
||||
export function normalizeContentForStore(content?: string) {
|
||||
const rawContent = normalizeContent(content);
|
||||
if (!rawContent) {
|
||||
return "";
|
||||
}
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(rawContent, "text/html");
|
||||
const mediaList = Array.from(doc.querySelectorAll("img, video"));
|
||||
for (const media of mediaList) {
|
||||
const src = media.getAttribute("src") || "";
|
||||
const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || getAttachmentIdFromSrc(src);
|
||||
const tempFileId = media.getAttribute(TEMP_FILE_ID_ATTR) || getTempFileIdFromSrc(src);
|
||||
const finalAttachmentId = attachmentId || tempFileId;
|
||||
if (!finalAttachmentId) {
|
||||
continue;
|
||||
}
|
||||
media.setAttribute(ATTACHMENT_ID_ATTR, finalAttachmentId);
|
||||
media.removeAttribute(TEMP_FILE_ID_ATTR);
|
||||
media.setAttribute("src", buildAttachmentIdSrc(finalAttachmentId));
|
||||
}
|
||||
return normalizeContent(doc.body.innerHTML);
|
||||
}
|
||||
|
||||
export function sameAttachmentList(left?: Attachment[], right?: Attachment[]) {
|
||||
const leftList = left || [];
|
||||
const rightList = right || [];
|
||||
if (leftList.length !== rightList.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < leftList.length; i += 1) {
|
||||
const leftItem = leftList[i];
|
||||
const rightItem = rightList[i];
|
||||
if (!rightItem) {
|
||||
return false;
|
||||
}
|
||||
if (leftItem.id !== rightItem.id) {
|
||||
return false;
|
||||
}
|
||||
if ((leftItem.tempFileId || "") !== (rightItem.tempFileId || "")) {
|
||||
return false;
|
||||
}
|
||||
if ((leftItem.accessKey?.value || "") !== (rightItem.accessKey?.value || "")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import view from "./index.vue";
|
||||
import Toolkit from "../../utils/Toolkit";
|
||||
|
||||
export const ArticleView = Toolkit.withInstall(view);
|
||||
export default ArticleView;
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
<template>
|
||||
<t-textarea
|
||||
v-if="isTextContent && hasRawContent"
|
||||
class="text-view"
|
||||
:model-value="rawContent"
|
||||
:autosize="textareaAutosize"
|
||||
readonly
|
||||
/>
|
||||
<t-empty v-else-if="isTextContent" :description="emptyText" />
|
||||
<markdown-view v-else-if="isMarkdownContent && hasRawContent" :content="rawContent" />
|
||||
<t-empty v-else-if="isMarkdownContent" :description="emptyText" />
|
||||
<tiptap-view v-else :content="renderContent" :empty-text="emptyText" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Article, Attachment } from "../../types";
|
||||
import AttachmentAPI from "../../api/AttachmentAPI";
|
||||
import { MarkdownView } from "../markdown-view";
|
||||
import { TiptapView } from "../tiptap-view";
|
||||
import {
|
||||
ATTACHMENT_ID_ATTR,
|
||||
getAttachmentIdFromSrc,
|
||||
normalizeArticleContentType,
|
||||
normalizeContent,
|
||||
normalizeRawArticleContent
|
||||
} from "../article-editor/shared";
|
||||
|
||||
defineOptions({
|
||||
name: "ArticleView"
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
article?: Article;
|
||||
emptyText?: string;
|
||||
resolveAttachmentUrl?: (attachmentId: string, attachment?: Attachment) => string | Promise<string>;
|
||||
}>(), {
|
||||
article: () => ({}),
|
||||
emptyText: "暂无正文内容"
|
||||
});
|
||||
|
||||
const textareaAutosize = {
|
||||
minRows: 8,
|
||||
maxRows: 32
|
||||
};
|
||||
|
||||
const renderContent = ref("");
|
||||
|
||||
const contentType = computed(() => {
|
||||
return normalizeArticleContentType(props.article?.contentType);
|
||||
});
|
||||
const isTextContent = computed(() => contentType.value === "TEXT");
|
||||
const isMarkdownContent = computed(() => contentType.value === "MARKDOWN");
|
||||
const rawContent = computed(() => {
|
||||
return normalizeRawArticleContent(props.article?.content);
|
||||
});
|
||||
const hasRawContent = computed(() => {
|
||||
return 0 < rawContent.value.trim().length;
|
||||
});
|
||||
|
||||
const attachmentMap = computed(() => {
|
||||
return new Map((props.article?.attachmentList || []).map(item => [item.id, item]));
|
||||
});
|
||||
|
||||
async function buildAttachmentPreviewUrl(attachmentId: string) {
|
||||
const attachment = attachmentMap.value.get(attachmentId);
|
||||
if (props.resolveAttachmentUrl) {
|
||||
const customUrl = await props.resolveAttachmentUrl(attachmentId, attachment);
|
||||
if (customUrl) {
|
||||
return customUrl;
|
||||
}
|
||||
}
|
||||
const previewUrl = `${attachment?.metadata?.previewUrl || ""}`;
|
||||
if (previewUrl) {
|
||||
return previewUrl;
|
||||
}
|
||||
return attachmentId ? AttachmentAPI.getReadURL(attachmentId) : "";
|
||||
}
|
||||
|
||||
async function buildRenderContent(content?: string) {
|
||||
const rawContent = normalizeContent(content);
|
||||
if (!rawContent) {
|
||||
return "";
|
||||
}
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(rawContent, "text/html");
|
||||
const nodes = Array.from(doc.querySelectorAll("img, video, source"));
|
||||
for (const node of nodes) {
|
||||
const src = node.getAttribute("src") || "";
|
||||
const attachmentId = node.getAttribute(ATTACHMENT_ID_ATTR) || getAttachmentIdFromSrc(src);
|
||||
if (!attachmentId) {
|
||||
continue;
|
||||
}
|
||||
const previewUrl = await buildAttachmentPreviewUrl(attachmentId);
|
||||
if (previewUrl) {
|
||||
node.setAttribute("src", previewUrl);
|
||||
}
|
||||
}
|
||||
return normalizeContent(doc.body.innerHTML);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.article?.content, props.article?.attachmentList, contentType.value] as const,
|
||||
async ([, , nextContentType]) => {
|
||||
if (nextContentType !== "TIP_TAP") {
|
||||
renderContent.value = "";
|
||||
return;
|
||||
}
|
||||
renderContent.value = await buildRenderContent(props.article?.content);
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.text-view {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
import view from "./index.vue";
|
||||
import Toolkit from "../../../utils/Toolkit";
|
||||
import WebToolkit from "../../../utils/WebToolkit";
|
||||
|
||||
export const BEFlowerFall = Toolkit.withInstall(view);
|
||||
export const BEFlowerFall = WebToolkit.withInstall(view);
|
||||
export default BEFlowerFall;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Resizer from "../../../utils/Resizer";
|
||||
import Toolkit from "../../../utils/Toolkit";
|
||||
import { Toolkit } from "timi-ts";
|
||||
|
||||
defineOptions({
|
||||
name: "BEFlowerFall"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import view from "./index.vue";
|
||||
import Toolkit from "../../utils/Toolkit";
|
||||
import WebToolkit from "../../utils/WebToolkit";
|
||||
|
||||
export const Captcha = Toolkit.withInstall(view);
|
||||
export const Captcha = WebToolkit.withInstall(view);
|
||||
export default Captcha;
|
||||
export type { CaptchaRequest, CaptchaResult } from "./types";
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {CommonAPI} from "../../api";
|
||||
import type {CaptchaResult} from "../../types";
|
||||
import type {CaptchaRequest, CaptchaResult} from "./types";
|
||||
|
||||
defineOptions({
|
||||
name: "Captcha"
|
||||
@@ -25,6 +24,7 @@ const props = withDefaults(defineProps<{
|
||||
height?: number,
|
||||
title?: string,
|
||||
placeholder?: string,
|
||||
request?: CaptchaRequest,
|
||||
}>(), {
|
||||
width: 90,
|
||||
height: 40,
|
||||
@@ -49,9 +49,12 @@ const captchaStyle = computed(() => {
|
||||
});
|
||||
|
||||
async function update(): Promise<void> {
|
||||
if (!props.request) {
|
||||
return;
|
||||
}
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const result = await CommonAPI.captcha(width.value, height.value);
|
||||
const result: CaptchaResult = await props.request(width.value, height.value);
|
||||
captchaId.value = result.id;
|
||||
src.value = result.data;
|
||||
emit("update", result);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/** 验证码组件展示数据。 */
|
||||
export type CaptchaResult = {
|
||||
id: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
/** 验证码组件的数据请求函数。 */
|
||||
export type CaptchaRequest = (width: number, height: number) => Promise<CaptchaResult>;
|
||||
@@ -1,26 +0,0 @@
|
||||
import view from "./index.vue";
|
||||
import Toolkit from "../../utils/Toolkit";
|
||||
import "./style.less";
|
||||
|
||||
export type CodeEditorScrollState = {
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
|
||||
export type CodeEditorExpose = {
|
||||
focus: () => void;
|
||||
blur: () => void;
|
||||
getScrollState: () => CodeEditorScrollState;
|
||||
setScrollState: (state: Partial<CodeEditorScrollState>) => void;
|
||||
findNext: () => boolean;
|
||||
findPrevious: () => boolean;
|
||||
replaceCurrent: () => boolean;
|
||||
replaceAll: () => number;
|
||||
openSearch: (showReplace: boolean) => void;
|
||||
closeSearch: () => void;
|
||||
textareaRef?: HTMLTextAreaElement;
|
||||
};
|
||||
|
||||
export const CodeEditor = Toolkit.withInstall(view);
|
||||
export default CodeEditor;
|
||||
|
||||
@@ -1,450 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
ref="rootRef"
|
||||
class="tui-code-editor"
|
||||
:class="{
|
||||
readonly,
|
||||
disabled,
|
||||
'with-line-numbers': lineNumbers
|
||||
}"
|
||||
>
|
||||
<div v-if="lineNumbers" ref="gutterRef" class="gutter diselect" aria-hidden="true">
|
||||
<span v-for="line in lineCount" :key="line" v-text="line" />
|
||||
</div>
|
||||
<div class="body">
|
||||
<editor-search-bar
|
||||
ref="searchBarRef"
|
||||
:visible="searchVisible"
|
||||
:expanded="replaceExpanded"
|
||||
:keyword="searchKeyword"
|
||||
:replace-value="replaceKeyword"
|
||||
:status-error="searchError"
|
||||
:match-index="searchMatchIndex"
|
||||
:match-count="searchMatchList.length"
|
||||
:case-sensitive="searchOptions.caseSensitive"
|
||||
:use-regex="searchOptions.useRegex"
|
||||
@update:expanded="replaceExpanded = $event"
|
||||
@update:keyword="searchKeyword = $event"
|
||||
@update:replace-value="replaceKeyword = $event"
|
||||
@update:case-sensitive="searchOptions.caseSensitive = $event"
|
||||
@update:use-regex="searchOptions.useRegex = $event"
|
||||
@previous="findPrevious"
|
||||
@next="onSearchNext"
|
||||
@replace="replaceCurrent"
|
||||
@replace-all="replaceAll"
|
||||
@close="closeSearch"
|
||||
/>
|
||||
<pre
|
||||
ref="preRef"
|
||||
class="preview cur-text"
|
||||
:class="languageClass"
|
||||
@scroll="onPreviewScroll"
|
||||
><code :class="languageClass" v-html="highlightedHTML"></code></pre>
|
||||
<textarea
|
||||
v-if="!readonly"
|
||||
ref="textareaRef"
|
||||
v-model="currentValue"
|
||||
class="input"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
wrap="off"
|
||||
:spellcheck="false"
|
||||
@input="onInput"
|
||||
@scroll="onScroll"
|
||||
@keydown="onKeydown"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Prism from "prismjs";
|
||||
import EditorSearchBar from "../editor-search/index.vue";
|
||||
import {
|
||||
findTextMatches,
|
||||
replaceAllText,
|
||||
replaceMatchText,
|
||||
type TextSearchMatch,
|
||||
type TextSearchOptions
|
||||
} from "../editor-search/search";
|
||||
import { resolveCodeEditorKeydown } from "./input";
|
||||
import { resolvePrismLanguage } from "../../utils/Prism";
|
||||
|
||||
defineOptions({
|
||||
name: "CodeEditor"
|
||||
});
|
||||
|
||||
type ScrollState = {
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
|
||||
type EditorSearchBarExpose = {
|
||||
focusKeyword: (selectAll?: boolean) => void;
|
||||
focusReplace: (selectAll?: boolean) => void;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: string;
|
||||
value?: string;
|
||||
language?: string;
|
||||
placeholder?: string;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
lineNumbers?: boolean;
|
||||
}>(), {
|
||||
modelValue: "",
|
||||
language: "typescript",
|
||||
placeholder: "",
|
||||
readonly: false,
|
||||
disabled: false,
|
||||
lineNumbers: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", value: string): void;
|
||||
(event: "change", value: string): void;
|
||||
}>();
|
||||
|
||||
const bindValue = computed(() => props.value ?? props.modelValue);
|
||||
const currentValue = ref(bindValue.value);
|
||||
const rootRef = ref<HTMLDivElement>();
|
||||
const gutterRef = ref<HTMLDivElement>();
|
||||
const preRef = ref<HTMLPreElement>();
|
||||
const textareaRef = ref<HTMLTextAreaElement>();
|
||||
const searchBarRef = ref<EditorSearchBarExpose>();
|
||||
const searchVisible = ref(false);
|
||||
const replaceExpanded = ref(false);
|
||||
const searchKeyword = ref("");
|
||||
const replaceKeyword = ref("");
|
||||
const searchError = ref("");
|
||||
const searchMatchIndex = ref(-1);
|
||||
const searchMatchList = ref<TextSearchMatch[]>([]);
|
||||
const scrollState = reactive<ScrollState>({
|
||||
top: 0,
|
||||
left: 0
|
||||
});
|
||||
const searchOptions = reactive<TextSearchOptions>({
|
||||
caseSensitive: false,
|
||||
useRegex: false
|
||||
});
|
||||
|
||||
const resolvedLanguage = computed(() => resolvePrismLanguage(props.language));
|
||||
const languageClass = computed(() => resolvedLanguage.value ? `language-${resolvedLanguage.value}` : "");
|
||||
const lineCount = computed(() => Math.max(currentValue.value.split("\n").length, 1));
|
||||
const highlightedHTML = computed(() => {
|
||||
const value = currentValue.value || " ";
|
||||
if (!resolvedLanguage.value) {
|
||||
return escapeHTML(value);
|
||||
}
|
||||
return Prism.highlight(value, Prism.languages[resolvedLanguage.value], resolvedLanguage.value);
|
||||
});
|
||||
|
||||
watch(bindValue, (value) => {
|
||||
if (value === currentValue.value) {
|
||||
return;
|
||||
}
|
||||
currentValue.value = value;
|
||||
refreshSearchMatchState();
|
||||
nextTick(syncScrollPosition);
|
||||
});
|
||||
|
||||
watch([searchKeyword, () => searchOptions.caseSensitive, () => searchOptions.useRegex], () => {
|
||||
refreshSearchMatchState();
|
||||
});
|
||||
|
||||
const onInput = () => {
|
||||
emit("update:modelValue", currentValue.value);
|
||||
emit("change", currentValue.value);
|
||||
refreshSearchMatchState();
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
syncScrollPosition();
|
||||
};
|
||||
|
||||
const onPreviewScroll = () => {
|
||||
if (!props.readonly || !preRef.value || !gutterRef.value) {
|
||||
return;
|
||||
}
|
||||
scrollState.top = preRef.value.scrollTop;
|
||||
scrollState.left = preRef.value.scrollLeft;
|
||||
gutterRef.value.scrollTop = preRef.value.scrollTop;
|
||||
};
|
||||
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && !event.altKey) {
|
||||
const key = event.key.toLowerCase();
|
||||
if ("f" === key) {
|
||||
event.preventDefault();
|
||||
openSearch(false);
|
||||
return;
|
||||
}
|
||||
if ("h" === key && !props.readonly) {
|
||||
event.preventDefault();
|
||||
openSearch(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (props.readonly || props.disabled || !textareaRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = textareaRef.value;
|
||||
const nextState = resolveCodeEditorKeydown(currentValue.value, {
|
||||
start: textarea.selectionStart,
|
||||
end: textarea.selectionEnd
|
||||
}, event);
|
||||
if (!nextState) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
currentValue.value = nextState.value;
|
||||
emit("update:modelValue", currentValue.value);
|
||||
emit("change", currentValue.value);
|
||||
refreshSearchMatchState();
|
||||
nextTick(() => {
|
||||
textarea.selectionStart = nextState.start;
|
||||
textarea.selectionEnd = nextState.end;
|
||||
syncScrollPosition();
|
||||
});
|
||||
};
|
||||
|
||||
const getScrollState = (): ScrollState => ({
|
||||
top: scrollState.top,
|
||||
left: scrollState.left
|
||||
});
|
||||
|
||||
const setScrollState = (nextState: Partial<ScrollState>) => {
|
||||
scrollState.top = Math.max(0, nextState.top || 0);
|
||||
scrollState.left = Math.max(0, nextState.left || 0);
|
||||
applyScrollState();
|
||||
};
|
||||
|
||||
const syncScrollPosition = () => {
|
||||
if (textareaRef.value) {
|
||||
scrollState.top = textareaRef.value.scrollTop;
|
||||
scrollState.left = textareaRef.value.scrollLeft;
|
||||
applyScrollState();
|
||||
return;
|
||||
}
|
||||
if (props.readonly && preRef.value) {
|
||||
scrollState.top = preRef.value.scrollTop;
|
||||
scrollState.left = preRef.value.scrollLeft;
|
||||
applyScrollState();
|
||||
}
|
||||
};
|
||||
|
||||
const applyScrollState = () => {
|
||||
if (textareaRef.value) {
|
||||
textareaRef.value.scrollTop = scrollState.top;
|
||||
textareaRef.value.scrollLeft = scrollState.left;
|
||||
}
|
||||
if (preRef.value) {
|
||||
preRef.value.scrollTop = scrollState.top;
|
||||
preRef.value.scrollLeft = scrollState.left;
|
||||
}
|
||||
if (gutterRef.value) {
|
||||
gutterRef.value.scrollTop = scrollState.top;
|
||||
}
|
||||
};
|
||||
|
||||
const getSelectionKeyword = () => {
|
||||
if (!textareaRef.value) {
|
||||
return "";
|
||||
}
|
||||
const { selectionStart, selectionEnd } = textareaRef.value;
|
||||
if (selectionStart === selectionEnd) {
|
||||
return "";
|
||||
}
|
||||
return currentValue.value.slice(selectionStart, selectionEnd);
|
||||
};
|
||||
|
||||
const refreshSearchMatchState = () => {
|
||||
const result = findTextMatches(currentValue.value, searchKeyword.value, searchOptions);
|
||||
searchError.value = result.error;
|
||||
searchMatchList.value = result.matches;
|
||||
if (searchError.value || !textareaRef.value || 1 > searchMatchList.value.length) {
|
||||
searchMatchIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
const { selectionStart, selectionEnd } = textareaRef.value;
|
||||
searchMatchIndex.value = searchMatchList.value.findIndex((match) => (
|
||||
match.start === selectionStart && match.end === selectionEnd
|
||||
));
|
||||
if (0 > searchMatchIndex.value) {
|
||||
searchMatchIndex.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const openSearch = (showReplace: boolean) => {
|
||||
searchVisible.value = true;
|
||||
replaceExpanded.value = showReplace;
|
||||
const selectedText = getSelectionKeyword();
|
||||
if (selectedText) {
|
||||
searchKeyword.value = selectedText;
|
||||
}
|
||||
refreshSearchMatchState();
|
||||
nextTick(() => {
|
||||
searchBarRef.value?.focusKeyword(true);
|
||||
});
|
||||
};
|
||||
|
||||
const closeSearch = () => {
|
||||
searchVisible.value = false;
|
||||
replaceExpanded.value = false;
|
||||
focus();
|
||||
};
|
||||
|
||||
const selectMatch = (match: TextSearchMatch, matchIndex: number) => {
|
||||
if (!textareaRef.value) {
|
||||
return false;
|
||||
}
|
||||
textareaRef.value.focus();
|
||||
textareaRef.value.selectionStart = match.start;
|
||||
textareaRef.value.selectionEnd = match.end;
|
||||
textareaRef.value.setSelectionRange(match.start, match.end);
|
||||
textareaRef.value.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
scrollState.top = textareaRef.value.scrollTop;
|
||||
scrollState.left = textareaRef.value.scrollLeft;
|
||||
applyScrollState();
|
||||
searchMatchIndex.value = matchIndex;
|
||||
return true;
|
||||
};
|
||||
|
||||
const findNext = () => {
|
||||
if (searchError.value || !textareaRef.value || 1 > searchMatchList.value.length) {
|
||||
return false;
|
||||
}
|
||||
const { selectionEnd } = textareaRef.value;
|
||||
const nextIndex = searchMatchList.value.findIndex((match) => match.start >= selectionEnd);
|
||||
const targetIndex = 0 <= nextIndex ? nextIndex : 0;
|
||||
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
|
||||
};
|
||||
|
||||
const findPrevious = () => {
|
||||
if (searchError.value || !textareaRef.value || 1 > searchMatchList.value.length) {
|
||||
return false;
|
||||
}
|
||||
const { selectionStart } = textareaRef.value;
|
||||
let targetIndex = searchMatchList.value.length - 1;
|
||||
for (let index = searchMatchList.value.length - 1; 0 <= index; index -= 1) {
|
||||
if (searchMatchList.value[index].start < selectionStart) {
|
||||
targetIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
|
||||
};
|
||||
|
||||
const onSearchNext = (shiftKey: boolean) => {
|
||||
if (shiftKey) {
|
||||
findPrevious();
|
||||
return;
|
||||
}
|
||||
findNext();
|
||||
};
|
||||
|
||||
const replaceSelection = (match: TextSearchMatch) => {
|
||||
const nextText = replaceMatchText(match.text, searchKeyword.value, replaceKeyword.value, searchOptions);
|
||||
currentValue.value = currentValue.value.slice(0, match.start) + nextText + currentValue.value.slice(match.end);
|
||||
emit("update:modelValue", currentValue.value);
|
||||
emit("change", currentValue.value);
|
||||
};
|
||||
|
||||
const replaceCurrent = () => {
|
||||
if (props.readonly || !textareaRef.value || !searchKeyword.value) {
|
||||
return false;
|
||||
}
|
||||
const { selectionStart, selectionEnd } = textareaRef.value;
|
||||
let currentMatchIndex = searchMatchList.value.findIndex((match) => (
|
||||
match.start === selectionStart && match.end === selectionEnd
|
||||
));
|
||||
if (0 > currentMatchIndex) {
|
||||
if (!findNext()) {
|
||||
return false;
|
||||
}
|
||||
currentMatchIndex = searchMatchIndex.value;
|
||||
}
|
||||
const currentMatch = searchMatchList.value[currentMatchIndex];
|
||||
if (!currentMatch) {
|
||||
return false;
|
||||
}
|
||||
replaceSelection(currentMatch);
|
||||
refreshSearchMatchState();
|
||||
nextTick(() => {
|
||||
const nextMatch = searchMatchList.value[currentMatchIndex] || searchMatchList.value[currentMatchIndex - 1];
|
||||
if (!nextMatch) {
|
||||
focus();
|
||||
return;
|
||||
}
|
||||
selectMatch(nextMatch, Math.min(currentMatchIndex, searchMatchList.value.length - 1));
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const replaceAll = () => {
|
||||
if (props.readonly || !searchKeyword.value) {
|
||||
return 0;
|
||||
}
|
||||
const result = replaceAllText(currentValue.value, searchKeyword.value, replaceKeyword.value, searchOptions);
|
||||
searchError.value = result.error;
|
||||
if (result.error || 1 > result.count) {
|
||||
refreshSearchMatchState();
|
||||
return 0;
|
||||
}
|
||||
currentValue.value = result.value;
|
||||
emit("update:modelValue", currentValue.value);
|
||||
emit("change", currentValue.value);
|
||||
refreshSearchMatchState();
|
||||
nextTick(() => {
|
||||
if (searchMatchList.value[0]) {
|
||||
selectMatch(searchMatchList.value[0], 0);
|
||||
return;
|
||||
}
|
||||
focus();
|
||||
});
|
||||
return result.count;
|
||||
};
|
||||
|
||||
const focus = () => {
|
||||
textareaRef.value?.focus();
|
||||
};
|
||||
|
||||
const blur = () => {
|
||||
textareaRef.value?.blur();
|
||||
};
|
||||
|
||||
onActivated(() => {
|
||||
nextTick(() => {
|
||||
applyScrollState();
|
||||
});
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
rootRef,
|
||||
textareaRef,
|
||||
focus,
|
||||
blur,
|
||||
getScrollState,
|
||||
setScrollState,
|
||||
findNext,
|
||||
findPrevious,
|
||||
replaceCurrent,
|
||||
replaceAll,
|
||||
openSearch,
|
||||
closeSearch
|
||||
});
|
||||
|
||||
function escapeHTML(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("\"", """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
.tui-code-editor {
|
||||
width: calc(100% - 2px);
|
||||
min-width: 0;
|
||||
border: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
border-radius: var(--td-radius-medium, 0);
|
||||
|
||||
.gutter {
|
||||
flex: none;
|
||||
color: var(--td-text-color-placeholder, #999);
|
||||
overflow: hidden;
|
||||
text-align: right;
|
||||
background: #F2F2F2E6;
|
||||
border-right: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||
|
||||
> span {
|
||||
width: 3.25rem;
|
||||
height: 1.5rem;
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
|
||||
line-height: 1.5rem;
|
||||
padding-right: .25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
top: .5rem;
|
||||
right: .5rem;
|
||||
gap: .5rem;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
padding: .5rem;
|
||||
position: absolute;
|
||||
align-items: center;
|
||||
border-radius: var(--td-radius-default, .375rem);
|
||||
background: var(--td-bg-color-container, #fff);
|
||||
border: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .12);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 10rem;
|
||||
height: 2rem;
|
||||
padding: 0 .5rem;
|
||||
color: var(--td-text-color-primary, #111);
|
||||
border: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||
background: var(--td-bg-color-container, #fff);
|
||||
border-radius: var(--td-radius-default, .375rem);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.search-actions {
|
||||
gap: .25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
height: 2rem;
|
||||
padding: 0 .625rem;
|
||||
color: var(--td-text-color-primary, #111);
|
||||
border: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||
background: var(--td-bg-color-container, #fff);
|
||||
border-radius: var(--td-radius-default, .375rem);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--td-bg-color-container-hover, #f6f6f6);
|
||||
}
|
||||
}
|
||||
|
||||
.search-status {
|
||||
color: var(--td-text-color-secondary, #666);
|
||||
white-space: nowrap;
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
.preview,
|
||||
.input {
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
tab-size: 4;
|
||||
overflow: auto;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
line-height: 1.5rem;
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box;
|
||||
font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
||||
.preview {
|
||||
top: 0;
|
||||
left: 0;
|
||||
border: none;
|
||||
position: absolute;
|
||||
word-break: normal;
|
||||
background: transparent;
|
||||
transition: none;
|
||||
white-space: pre;
|
||||
border-radius: 0;
|
||||
pointer-events: none;
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
display: block;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
text-shadow: none;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
top: 0;
|
||||
left: 0;
|
||||
color: transparent;
|
||||
resize: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
position: absolute;
|
||||
background: transparent;
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
overflow-wrap: normal;
|
||||
caret-color: var(--td-text-color-primary, #111);
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--td-text-color-placeholder, #999);
|
||||
-webkit-text-fill-color: var(--td-text-color-placeholder, #999);
|
||||
}
|
||||
|
||||
.input::selection {
|
||||
background: rgb(0 122 255 / 18%);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: .65;
|
||||
}
|
||||
|
||||
&.readonly {
|
||||
.preview {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.tui-code-editor {
|
||||
border-color: var(--td-component-stroke, #434343);
|
||||
|
||||
.gutter {
|
||||
border-right-color: var(--td-component-stroke, #434343);
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
background: var(--td-bg-color-container, #1f1f1f);
|
||||
border-color: var(--td-component-stroke, #434343);
|
||||
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .28);
|
||||
}
|
||||
|
||||
.search-input,
|
||||
.search-button {
|
||||
color: var(--td-text-color-primary, #F5F5F5);
|
||||
border-color: var(--td-component-stroke, #434343);
|
||||
background: var(--td-bg-color-container, #1f1f1f);
|
||||
}
|
||||
|
||||
.search-button:hover {
|
||||
background: var(--td-bg-color-container-hover, #2b2b2b);
|
||||
}
|
||||
|
||||
.input {
|
||||
caret-color: var(--td-text-color-primary, #F5F5F5);
|
||||
}
|
||||
|
||||
.input::selection {
|
||||
background: rgb(64 153 255 / 28%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import view from "./index.vue";
|
||||
import Toolkit from "../../utils/Toolkit";
|
||||
import WebToolkit from "../../utils/WebToolkit";
|
||||
|
||||
export const ContactNumber = Toolkit.withInstall(view);
|
||||
export const ContactNumber = WebToolkit.withInstall(view);
|
||||
export default ContactNumber;
|
||||
|
||||
@@ -78,6 +78,7 @@ const formattedValue = computed(() => formatNumber(String(props.value ?? "").tri
|
||||
|
||||
<style lang="less" scoped>
|
||||
.tui-contact-number {
|
||||
width: fit-content;
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import view from "./index.vue";
|
||||
import Toolkit from "../../utils/Toolkit";
|
||||
import WebToolkit from "../../utils/WebToolkit";
|
||||
|
||||
export const Copyright = Toolkit.withInstall(view);
|
||||
export const Copyright = WebToolkit.withInstall(view);
|
||||
export default Copyright;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import view from "./index.vue";
|
||||
import WebToolkit from "../../utils/WebToolkit";
|
||||
|
||||
export type {
|
||||
DescriptionGridAlign,
|
||||
DescriptionGridItem,
|
||||
DescriptionGridProps
|
||||
} from "./type";
|
||||
|
||||
export const DescriptionGrid = WebToolkit.withInstall(view);
|
||||
export default DescriptionGrid;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div
|
||||
class="cw-description-grid"
|
||||
:class="{
|
||||
bordered,
|
||||
'mobile-single': singleColumnOnMobile
|
||||
}"
|
||||
:style="gridStyle"
|
||||
>
|
||||
<template v-for="item in items" :key="item.key">
|
||||
<div class="cell label" :class="item.labelClass">
|
||||
<slot :name="`label-${item.key}`" :item="item">
|
||||
<span v-text="item.label" />
|
||||
</slot>
|
||||
</div>
|
||||
<div class="cell value" :class="item.valueClass">
|
||||
<slot :name="`value-${item.key}`" :item="item">
|
||||
<span v-text="formatValue(item.value)" />
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import type { CSSProperties, PropType } from "vue";
|
||||
import type { DescriptionGridAlign, DescriptionGridItem } from "./type";
|
||||
|
||||
defineOptions({
|
||||
name: "DescriptionGrid"
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array as PropType<DescriptionGridItem[]>,
|
||||
default: () => []
|
||||
},
|
||||
columns: {
|
||||
type: Number,
|
||||
default: 2
|
||||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: "-"
|
||||
},
|
||||
bordered: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
singleColumnOnMobile: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
labelAlign: {
|
||||
type: String as PropType<DescriptionGridAlign>,
|
||||
default: "left"
|
||||
},
|
||||
valueAlign: {
|
||||
type: String as PropType<DescriptionGridAlign>,
|
||||
default: "left"
|
||||
}
|
||||
});
|
||||
|
||||
const normalizedColumns = computed(() => {
|
||||
if (!props.columns || props.columns < 1) {
|
||||
return 1;
|
||||
}
|
||||
return Math.floor(props.columns);
|
||||
});
|
||||
|
||||
const gridStyle = computed<CSSProperties>(() => {
|
||||
return {
|
||||
"--cw-description-columns": String(normalizedColumns.value),
|
||||
"--cw-description-grid-template": Array
|
||||
.from({ length: normalizedColumns.value }, () => "max-content minmax(0, 1fr)")
|
||||
.join(" "),
|
||||
"--cw-description-label-align": props.labelAlign,
|
||||
"--cw-description-value-align": props.valueAlign
|
||||
} as CSSProperties;
|
||||
});
|
||||
|
||||
function formatValue(value: DescriptionGridItem["value"]): string {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return props.emptyText;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.cw-description-grid {
|
||||
display: grid;
|
||||
max-width: 100%;
|
||||
grid-template-columns: var(--cw-description-grid-template);
|
||||
|
||||
.cell {
|
||||
min-width: 0;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--t-color-text-placeholder, var(--td-text-color-placeholder));
|
||||
text-align: var(--cw-description-label-align);
|
||||
white-space: nowrap;
|
||||
background: var(--t-color-bg-container-secondary, var(--td-bg-color-secondarycontainer));
|
||||
}
|
||||
|
||||
.value {
|
||||
text-align: var(--cw-description-value-align);
|
||||
word-break: break-all;
|
||||
background: var(--t-color-bg-container, var(--td-bg-color-container));
|
||||
}
|
||||
|
||||
&.bordered {
|
||||
border-top: 1px solid var(--t-color-border-1, var(--td-component-border));
|
||||
border-left: 1px solid var(--t-color-border-1, var(--td-component-border));
|
||||
|
||||
.cell {
|
||||
border-right: 1px solid var(--t-color-border-1, var(--td-component-border));
|
||||
border-bottom: 1px solid var(--t-color-border-1, var(--td-component-border));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
&.mobile-single {
|
||||
grid-template-columns: max-content minmax(0, max-content);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export type DescriptionGridAlign = "left" | "center" | "right";
|
||||
|
||||
export interface DescriptionGridItem {
|
||||
/** 唯一标识,同时用于生成具名插槽名称 */
|
||||
key: string;
|
||||
/** 标签文本 */
|
||||
label: string;
|
||||
/** 默认展示值 */
|
||||
value?: string | number | boolean | null;
|
||||
/** 标签单元格自定义类名 */
|
||||
labelClass?: string;
|
||||
/** 内容单元格自定义类名 */
|
||||
valueClass?: string;
|
||||
}
|
||||
|
||||
export interface DescriptionGridProps {
|
||||
/** 描述项列表 */
|
||||
items: DescriptionGridItem[];
|
||||
/** 一行显示几组 key/value */
|
||||
columns?: number;
|
||||
/** 空值占位文本 */
|
||||
emptyText?: string;
|
||||
/** 是否显示边框 */
|
||||
bordered?: boolean;
|
||||
/** 是否在窄屏下自动降为单列 */
|
||||
singleColumnOnMobile?: boolean;
|
||||
/** 标签内容对齐方式 */
|
||||
labelAlign?: DescriptionGridAlign;
|
||||
/** 值内容对齐方式 */
|
||||
valueAlign?: DescriptionGridAlign;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
<template>
|
||||
<div v-if="visible" class="tui-editor-search" @mousedown.stop>
|
||||
<div class="row">
|
||||
<t-button
|
||||
class="toggle-btn"
|
||||
shape="square"
|
||||
size="small"
|
||||
type="button"
|
||||
variant="outline"
|
||||
:title="expanded ? '收起替换' : '展开替换'"
|
||||
@click="emit('update:expanded', !expanded)"
|
||||
>
|
||||
<t-icon :name="expanded ? 'chevron-down-s' : 'chevron-right-s'" />
|
||||
</t-button>
|
||||
<t-input
|
||||
ref="keywordInputRef"
|
||||
:value="keyword"
|
||||
class="text-input"
|
||||
placeholder="查找"
|
||||
size="small"
|
||||
@change="onKeywordChange"
|
||||
@enter="onKeywordEnter"
|
||||
@keydown="onKeywordKeydown"
|
||||
>
|
||||
<template #suffix-icon>
|
||||
<t-button
|
||||
shape="square"
|
||||
size="small"
|
||||
type="button"
|
||||
variant="text"
|
||||
:theme="caseSensitive ? 'primary' : 'default'"
|
||||
title="大小写匹配"
|
||||
@click="emit('update:case-sensitive', !caseSensitive)"
|
||||
>
|
||||
Aa
|
||||
</t-button>
|
||||
<t-button
|
||||
shape="square"
|
||||
size="small"
|
||||
type="button"
|
||||
variant="text"
|
||||
:theme="useRegex ? 'primary' : 'default'"
|
||||
title="正则搜索"
|
||||
@click="emit('update:use-regex', !useRegex)"
|
||||
>
|
||||
.*
|
||||
</t-button>
|
||||
</template>
|
||||
</t-input>
|
||||
<span class="status" v-text="resolvedStatusText" />
|
||||
<t-button shape="square" size="small" type="button" variant="text" title="上一个" @click="emit('previous')">
|
||||
<t-icon name="arrow-up" />
|
||||
</t-button>
|
||||
<t-button shape="square" size="small" type="button" variant="text" title="下一个" @click="emit('next', false)">
|
||||
<t-icon name="arrow-down" />
|
||||
</t-button>
|
||||
<t-button shape="square" size="small" type="button" variant="text" title="关闭" @click="emit('close')">
|
||||
<t-icon name="close" />
|
||||
</t-button>
|
||||
</div>
|
||||
<div v-if="expanded" class="row replace-row">
|
||||
<div class="toggle-holder" />
|
||||
<t-input
|
||||
ref="replaceInputRef"
|
||||
:value="replaceValue"
|
||||
class="text-input"
|
||||
placeholder="替换"
|
||||
size="small"
|
||||
@change="onReplaceChange"
|
||||
@enter="emit('replace')"
|
||||
@keydown="onReplaceKeydown"
|
||||
/>
|
||||
<div class="replace-actions">
|
||||
<t-button size="small" type="button" theme="primary" @click="emit('replace')">替换</t-button>
|
||||
<t-button size="small" type="button" variant="outline" @click="emit('replace-all')">全部替换</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { ComponentPublicInstance } from "vue";
|
||||
|
||||
defineOptions({
|
||||
name: "EditorSearchBar"
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
visible?: boolean;
|
||||
expanded?: boolean;
|
||||
keyword?: string;
|
||||
replaceValue?: string;
|
||||
statusText?: string;
|
||||
statusError?: string;
|
||||
matchIndex?: number;
|
||||
matchCount?: number;
|
||||
caseSensitive?: boolean;
|
||||
useRegex?: boolean;
|
||||
}>(), {
|
||||
visible: false,
|
||||
expanded: false,
|
||||
keyword: "",
|
||||
replaceValue: "",
|
||||
statusText: undefined,
|
||||
statusError: "",
|
||||
matchIndex: -1,
|
||||
matchCount: 0,
|
||||
caseSensitive: false,
|
||||
useRegex: false
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:expanded", value: boolean): void;
|
||||
(event: "update:keyword", value: string): void;
|
||||
(event: "update:replace-value", value: string): void;
|
||||
(event: "update:case-sensitive", value: boolean): void;
|
||||
(event: "update:use-regex", value: boolean): void;
|
||||
(event: "previous"): void;
|
||||
(event: "next", shiftKey: boolean): void;
|
||||
(event: "replace"): void;
|
||||
(event: "replace-all"): void;
|
||||
(event: "close"): void;
|
||||
}>();
|
||||
|
||||
type InputInstance = ComponentPublicInstance & {
|
||||
$el: HTMLElement;
|
||||
};
|
||||
|
||||
type InputKeyboardContext = {
|
||||
e: KeyboardEvent;
|
||||
};
|
||||
|
||||
const keywordInputRef = ref<InputInstance>();
|
||||
const replaceInputRef = ref<InputInstance>();
|
||||
|
||||
const resolvedStatusText = computed(() => {
|
||||
if (undefined !== props.statusText) {
|
||||
return props.statusText;
|
||||
}
|
||||
if (props.statusError) {
|
||||
return props.statusError;
|
||||
}
|
||||
if (!props.keyword) {
|
||||
return "无结果";
|
||||
}
|
||||
if (1 > props.matchCount) {
|
||||
return "未找到";
|
||||
}
|
||||
return `${props.matchIndex + 1} / ${props.matchCount}`;
|
||||
});
|
||||
|
||||
const onKeywordChange = (value: string | number) => {
|
||||
emit("update:keyword", `${value}`);
|
||||
};
|
||||
|
||||
const onReplaceChange = (value: string | number) => {
|
||||
emit("update:replace-value", `${value}`);
|
||||
};
|
||||
|
||||
const onKeywordEnter = (_value: string | number, context: InputKeyboardContext) => {
|
||||
emit("next", context.e.shiftKey);
|
||||
};
|
||||
|
||||
const onKeywordKeydown = (_value: string | number, context: InputKeyboardContext) => {
|
||||
if ("Escape" !== context.e.key) {
|
||||
return;
|
||||
}
|
||||
context.e.preventDefault();
|
||||
emit("close");
|
||||
};
|
||||
|
||||
const onReplaceKeydown = (_value: string | number, context: InputKeyboardContext) => {
|
||||
if ("Escape" !== context.e.key) {
|
||||
return;
|
||||
}
|
||||
context.e.preventDefault();
|
||||
emit("close");
|
||||
};
|
||||
|
||||
const getNativeInput = (instance?: InputInstance) => instance?.$el.querySelector("input") || undefined;
|
||||
|
||||
const focusKeyword = (selectAll = false) => {
|
||||
const input = getNativeInput(keywordInputRef.value);
|
||||
input?.focus();
|
||||
if (selectAll && input) {
|
||||
input.selectionStart = 0;
|
||||
input.selectionEnd = input.value.length;
|
||||
}
|
||||
};
|
||||
|
||||
const focusReplace = (selectAll = false) => {
|
||||
const input = getNativeInput(replaceInputRef.value);
|
||||
input?.focus();
|
||||
if (selectAll && input) {
|
||||
input.selectionStart = 0;
|
||||
input.selectionEnd = input.value.length;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
focusKeyword,
|
||||
focusReplace,
|
||||
keywordInputRef,
|
||||
replaceInputRef
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.tui-editor-search {
|
||||
top: .5rem;
|
||||
right: .5rem;
|
||||
gap: .375rem;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
padding: .5rem;
|
||||
position: absolute;
|
||||
min-width: 20rem;
|
||||
max-width: calc(100% - 1rem);
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--td-radius-default, .375rem);
|
||||
background: var(--td-bg-color-container, #fff);
|
||||
border: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .12);
|
||||
flex-direction: column;
|
||||
|
||||
.row {
|
||||
gap: .375rem;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
|
||||
.toggle-holder {
|
||||
flex: none;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 12rem;
|
||||
|
||||
:deep(.t-input) {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
width: 3rem;
|
||||
color: var(--td-text-color-secondary, #666);
|
||||
font-size: .875rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.replace-actions {
|
||||
gap: .375rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.tui-editor-search {
|
||||
background: var(--td-bg-color-container, #1f1f1f);
|
||||
border-color: var(--td-component-stroke, #434343);
|
||||
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .28);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
import view from "./index.vue";
|
||||
import Toolkit from "../../utils/Toolkit";
|
||||
import WebToolkit from "../../utils/WebToolkit";
|
||||
|
||||
export const EmptyTips = Toolkit.withInstall(view);
|
||||
export const EmptyTips = WebToolkit.withInstall(view);
|
||||
export default EmptyTips;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import view from "./index.vue";
|
||||
import Toolkit from "../../utils/Toolkit";
|
||||
import WebToolkit from "../../utils/WebToolkit";
|
||||
|
||||
export const Icon = Toolkit.withInstall(view);
|
||||
export const Icon = WebToolkit.withInstall(view);
|
||||
export default Icon;
|
||||
|
||||
+12
-32
@@ -1,9 +1,9 @@
|
||||
/** 导出所有组件 */
|
||||
import Icon from "./icon";
|
||||
import Popup from "./popup";
|
||||
import HoverPopup, { VPopupComponent } from "./v-popup";
|
||||
import Captcha from "./captcha";
|
||||
import Loading from "./loading";
|
||||
import UserLevel from "./user-level";
|
||||
import Copyright from "./copyright";
|
||||
import EmptyTips from "./empty-tips";
|
||||
import MarkdownView from "./markdown-view";
|
||||
@@ -11,18 +11,15 @@ import BEFlowerFall from "./background-effect/flower-fall";
|
||||
import MarkdownEditor from "./markdown-editor";
|
||||
import PassTimeLabel from "./passtime-label";
|
||||
import ContactNumber from "./contact-number";
|
||||
import CodeEditor from "./code-editor";
|
||||
import TiptapEditor from "./tiptap-editor";
|
||||
import TiptapView from "./tiptap-view";
|
||||
import ArticleEditor from "./article-editor";
|
||||
import ArticleView from "./article-view";
|
||||
import AnimatedNumber from "./animated-number";
|
||||
import DescriptionGrid from "./description-grid";
|
||||
|
||||
export default [
|
||||
Icon,
|
||||
Popup,
|
||||
HoverPopup,
|
||||
Captcha,
|
||||
Loading,
|
||||
UserLevel,
|
||||
Copyright,
|
||||
EmptyTips,
|
||||
MarkdownView,
|
||||
@@ -30,19 +27,17 @@ export default [
|
||||
MarkdownEditor,
|
||||
PassTimeLabel,
|
||||
ContactNumber,
|
||||
CodeEditor,
|
||||
TiptapEditor,
|
||||
TiptapView,
|
||||
ArticleEditor,
|
||||
ArticleView
|
||||
AnimatedNumber,
|
||||
DescriptionGrid
|
||||
];
|
||||
|
||||
export {
|
||||
Icon,
|
||||
Popup,
|
||||
HoverPopup,
|
||||
VPopupComponent,
|
||||
Captcha,
|
||||
Loading,
|
||||
UserLevel,
|
||||
Copyright,
|
||||
EmptyTips,
|
||||
MarkdownView,
|
||||
@@ -50,24 +45,9 @@ export {
|
||||
MarkdownEditor,
|
||||
PassTimeLabel,
|
||||
ContactNumber,
|
||||
CodeEditor,
|
||||
TiptapEditor,
|
||||
TiptapView,
|
||||
ArticleEditor,
|
||||
ArticleView
|
||||
AnimatedNumber,
|
||||
DescriptionGrid
|
||||
};
|
||||
|
||||
export type {
|
||||
CodeEditorExpose
|
||||
} from "./code-editor";
|
||||
|
||||
export type {
|
||||
UploadResult,
|
||||
AlignType,
|
||||
TiptapEditorExpose,
|
||||
TiptapEditorInstance
|
||||
} from "./tiptap-editor";
|
||||
|
||||
export type {
|
||||
ArticleEditorExpose
|
||||
} from "./article-editor";
|
||||
export type { CaptchaRequest, CaptchaResult } from "./captcha";
|
||||
export type { DescriptionGridAlign, DescriptionGridItem, DescriptionGridProps } from "./description-grid";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import view from "./index.vue";
|
||||
import Toolkit from "../../utils/Toolkit";
|
||||
import WebToolkit from "../../utils/WebToolkit";
|
||||
|
||||
export const Loading = Toolkit.withInstall(view);
|
||||
export const Loading = WebToolkit.withInstall(view);
|
||||
export default Loading;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Toolkit from "../../utils/Toolkit";
|
||||
import { Toolkit } from "timi-ts";
|
||||
defineOptions({
|
||||
name: "Loading"
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import view from "./index.vue";
|
||||
import Toolkit from "../../utils/Toolkit";
|
||||
import WebToolkit from "../../utils/WebToolkit";
|
||||
|
||||
export const MarkdownEditor = Toolkit.withInstall(view);
|
||||
export const MarkdownEditor = WebToolkit.withInstall(view);
|
||||
export default MarkdownEditor;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import view from "./index.vue";
|
||||
import Toolkit from "../../utils/Toolkit";
|
||||
import WebToolkit from "../../utils/WebToolkit";
|
||||
import "./style.less";
|
||||
|
||||
export const MarkdownView = Toolkit.withInstall(view);
|
||||
export const MarkdownView = WebToolkit.withInstall(view);
|
||||
export default MarkdownView;
|
||||
|
||||
@@ -262,7 +262,6 @@
|
||||
background: transparent;
|
||||
max-height: var(--data-max-height);
|
||||
transition: max-height .5s var(--tui-bezier);
|
||||
font-family: var(--td-font);
|
||||
line-height: 1;
|
||||
border-radius: 0;
|
||||
|
||||
@@ -270,7 +269,6 @@
|
||||
color: #333;
|
||||
background: transparent;
|
||||
text-shadow: none !important;
|
||||
font-family: var(--td-font);
|
||||
|
||||
.line-numbers-rows {
|
||||
left: 0;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import view from "./index.vue";
|
||||
import Toolkit from "../../utils/Toolkit";
|
||||
import WebToolkit from "../../utils/WebToolkit";
|
||||
|
||||
export const PassTimeLabel = Toolkit.withInstall(view);
|
||||
export const PassTimeLabel = WebToolkit.withInstall(view);
|
||||
export default PassTimeLabel;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Time from "../../utils/Time";
|
||||
import { Time } from "timi-ts";
|
||||
|
||||
defineOptions({
|
||||
name: "PassTimeLabel"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import view from "./index.vue";
|
||||
import Toolkit from "../../utils/Toolkit";
|
||||
import WebToolkit from "../../utils/WebToolkit";
|
||||
|
||||
export const Popup = Toolkit.withInstall(view);
|
||||
export const Popup = WebToolkit.withInstall(view);
|
||||
export default Popup;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<div id="tui-popup"></div>
|
||||
<div id="tui-popup">
|
||||
<div id="tui-popup-content"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@@ -11,7 +13,7 @@ defineOptions({
|
||||
<style lang="less" scoped>
|
||||
#tui-popup {
|
||||
border: var(--tui-border);
|
||||
z-index: 20;
|
||||
z-index: 3000;
|
||||
position: fixed;
|
||||
font-size: 13px;
|
||||
visibility: hidden;
|
||||
@@ -20,7 +22,10 @@ defineOptions({
|
||||
|
||||
:deep(.text) {
|
||||
padding: 3px 6px 5px 6px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
#tui-popup-content {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
|
||||
img {
|
||||
height: auto;
|
||||
border-radius: var(--td-radius-default, .375rem);
|
||||
border-radius: var(--tui-radius-default, .375rem);
|
||||
}
|
||||
|
||||
video,
|
||||
iframe.tp-iframe {
|
||||
width: min(100%, 56rem);
|
||||
overflow: hidden;
|
||||
border-radius: var(--td-radius-default, .375rem);
|
||||
background: var(--td-bg-color-container, #fff);
|
||||
border: 1px solid var(--td-component-border, #dcdcdc);
|
||||
border-radius: var(--tui-radius-default, .375rem);
|
||||
background: var(--tui-bg-color-container, #fff);
|
||||
border: 1px solid var(--tui-component-border, #dcdcdc);
|
||||
}
|
||||
|
||||
iframe.tp-iframe {
|
||||
@@ -38,20 +38,20 @@
|
||||
td {
|
||||
padding: .5rem .75rem;
|
||||
vertical-align: middle;
|
||||
border: 1px solid var(--td-component-border, #dcdcdc);
|
||||
border: 1px solid var(--tui-component-border, #dcdcdc);
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--td-bg-color-container-hover, #f6f6f6);
|
||||
background: var(--tui-bg-color-container-hover, #f6f6f6);
|
||||
}
|
||||
|
||||
pre[class*="language-"] {
|
||||
padding: 0;
|
||||
border: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||
border: 1px solid var(--tui-component-stroke, #B8BBC9);
|
||||
overflow: auto;
|
||||
background: transparent;
|
||||
transition: max-height .5s ease;
|
||||
border-radius: var(--td-radius-medium, 0);
|
||||
border-radius: var(--tui-radius-medium, 0);
|
||||
|
||||
code {
|
||||
color: #333;
|
||||
@@ -71,11 +71,11 @@
|
||||
display: block;
|
||||
text-align: right;
|
||||
background: #F2F2F2E6;
|
||||
border-right: 1px solid var(--td-component-stroke, #B8BBC9);
|
||||
border-right: 1px solid var(--tui-component-stroke, #B8BBC9);
|
||||
padding-right: .25rem;
|
||||
|
||||
> span {
|
||||
color: var(--td-text-color-placeholder, #999);
|
||||
color: var(--tui-text-color-placeholder, #999);
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -178,4 +178,3 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
.tp-content-theme() {
|
||||
color: var(--td-text-color-primary);
|
||||
line-height: 1.6;
|
||||
|
||||
p {
|
||||
margin: 0 0 .75rem;
|
||||
}
|
||||
|
||||
p:empty:before {
|
||||
content: "\00a0";
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 0 0 .75rem;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
margin: .75rem 0;
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid var(--td-component-border);
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
}
|
||||
|
||||
img,
|
||||
video,
|
||||
iframe {
|
||||
margin: 0 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.tp-content-body {
|
||||
.tp-content-theme();
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<template>
|
||||
<t-popconfirm
|
||||
v-model:visible="visible"
|
||||
trigger="click"
|
||||
:confirm-btn="{ content: '插入', theme: 'primary' }"
|
||||
:cancel-btn="{ content: '取消', theme: 'default' }"
|
||||
placement="bottom"
|
||||
@visible-change="onVisibleChange"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<template #icon><span></span></template>
|
||||
<template #content>
|
||||
<t-input v-model="codeLanguage" placeholder="代码语言,可留空" />
|
||||
</template>
|
||||
<t-button v-popup="'代码块'" variant="outline" :theme="active ? 'primary' : 'default'">
|
||||
<t-icon name="code" />
|
||||
</t-button>
|
||||
</t-popconfirm>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { TiptapEditorInstance } from "./useTiptapEditor";
|
||||
|
||||
defineOptions({
|
||||
name: "TiptapCodeBlockButton"
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
editor?: TiptapEditorInstance;
|
||||
active: boolean;
|
||||
}>();
|
||||
|
||||
const visible = ref(false);
|
||||
const codeLanguage = ref("");
|
||||
|
||||
function onVisibleChange(nextVisible: boolean) {
|
||||
if (!nextVisible || !props.editor) {
|
||||
return;
|
||||
}
|
||||
codeLanguage.value = "";
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
if (!props.editor) {
|
||||
return;
|
||||
}
|
||||
const language = codeLanguage.value.trim();
|
||||
if (!language) {
|
||||
props.editor.chain().focus().setCodeBlock().run();
|
||||
visible.value = false;
|
||||
return;
|
||||
}
|
||||
props.editor.chain().focus().setCodeBlock({ language }).run();
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -1,113 +0,0 @@
|
||||
<template>
|
||||
<t-popconfirm
|
||||
class="tiptap-iframe-button"
|
||||
v-model:visible="visible"
|
||||
trigger="click"
|
||||
:confirm-btn="{ content: '插入', theme: 'primary' }"
|
||||
:cancel-btn="{ content: '取消', theme: 'default' }"
|
||||
placement="bottom"
|
||||
@visible-change="onVisibleChange"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<template #icon><span></span></template>
|
||||
<template #content>
|
||||
<t-form class="popconfirm-content" label-align="left" label-width="auto">
|
||||
<t-form-item label="网页地址">
|
||||
<t-input v-model="iframeForm.src" placeholder="请输入网页地址" />
|
||||
</t-form-item>
|
||||
<t-form-item label="网页标题">
|
||||
<t-input v-model="iframeForm.title" placeholder="请输入网页标题,可留空" />
|
||||
</t-form-item>
|
||||
<t-form-item label="宽度">
|
||||
<t-input v-model="iframeForm.width" placeholder="请输入宽度,默认 100%" />
|
||||
</t-form-item>
|
||||
<t-form-item label="高度">
|
||||
<t-input v-model="iframeForm.height" placeholder="请输入高度,默认 28rem" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</template>
|
||||
<t-button v-popup="'插入内嵌网页'" variant="outline" :theme="active ? 'primary' : 'default'">
|
||||
<t-icon name="web" />
|
||||
</t-button>
|
||||
</t-popconfirm>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { showTDesignMessage } from "../../utils/TDesignMessage";
|
||||
import type { TiptapEditorInstance } from "./useTiptapEditor";
|
||||
|
||||
defineOptions({
|
||||
name: "TiptapIframeButton"
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
editor?: TiptapEditorInstance;
|
||||
active: boolean;
|
||||
}>();
|
||||
|
||||
const appContext = getCurrentInstance()?.appContext;
|
||||
const visible = ref(false);
|
||||
const iframeForm = reactive({
|
||||
src: "",
|
||||
title: "",
|
||||
width: "100%",
|
||||
height: "28rem"
|
||||
});
|
||||
|
||||
function onVisibleChange(nextVisible: boolean) {
|
||||
if (!nextVisible || !props.editor) {
|
||||
return;
|
||||
}
|
||||
const attributes = props.editor.getAttributes("iframe");
|
||||
iframeForm.src = (attributes.src as string) || "https://";
|
||||
iframeForm.title = (attributes.title as string) || "";
|
||||
iframeForm.width = (attributes.width as string) || "100%";
|
||||
iframeForm.height = (attributes.height as string) || "28rem";
|
||||
}
|
||||
|
||||
function isValidHttpUrl(value: string) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
if (!props.editor) {
|
||||
return;
|
||||
}
|
||||
const src = iframeForm.src.trim();
|
||||
if (!src) {
|
||||
visible.value = false;
|
||||
return;
|
||||
}
|
||||
if (!isValidHttpUrl(src)) {
|
||||
await showTDesignMessage(appContext, "warning", "请输入有效的 http 或 https 地址");
|
||||
return;
|
||||
}
|
||||
props.editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent({
|
||||
type: "iframe",
|
||||
attrs: {
|
||||
src,
|
||||
title: iframeForm.title.trim() || "内嵌网页",
|
||||
width: iframeForm.width.trim() || "100%",
|
||||
height: iframeForm.height.trim() || "28rem"
|
||||
}
|
||||
})
|
||||
.run();
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.popconfirm-content {
|
||||
--td-comp-margin-xxl: .5rem;
|
||||
|
||||
padding: 0 .5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,95 +0,0 @@
|
||||
<template>
|
||||
<t-popconfirm
|
||||
v-model:visible="visible"
|
||||
trigger="click"
|
||||
:confirm-btn="{ content: '插入', theme: 'primary' }"
|
||||
:cancel-btn="{ content: '取消', theme: 'default' }"
|
||||
placement="bottom"
|
||||
@visible-change="onVisibleChange"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<template #icon><span></span></template>
|
||||
<template #content>
|
||||
<div class="link-form">
|
||||
<t-input v-model="linkForm.href" placeholder="请输入链接地址" />
|
||||
<t-input v-model="linkForm.text" placeholder="请输入显示文本,可留空" />
|
||||
<t-checkbox v-model="linkForm.syncToHref">显示文本同步为链接</t-checkbox>
|
||||
</div>
|
||||
</template>
|
||||
<t-button v-popup="'插入链接'" variant="outline" :theme="active ? 'primary' : 'default'">
|
||||
<t-icon name="link-1" />
|
||||
</t-button>
|
||||
</t-popconfirm>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { TiptapEditorInstance } from "./useTiptapEditor";
|
||||
|
||||
defineOptions({
|
||||
name: "TiptapLinkButton"
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
editor?: TiptapEditorInstance;
|
||||
active: boolean;
|
||||
}>();
|
||||
|
||||
const visible = ref(false);
|
||||
const linkForm = reactive({
|
||||
href: "",
|
||||
text: "",
|
||||
syncToHref: false
|
||||
});
|
||||
|
||||
function onVisibleChange(nextVisible: boolean) {
|
||||
if (!nextVisible || !props.editor) {
|
||||
return;
|
||||
}
|
||||
const previousUrl = (props.editor.getAttributes("link").href as string) || "";
|
||||
const selectedText = props.editor.state.doc.textBetween(
|
||||
props.editor.state.selection.from,
|
||||
props.editor.state.selection.to,
|
||||
""
|
||||
);
|
||||
linkForm.href = previousUrl || "https://";
|
||||
linkForm.text = selectedText || "";
|
||||
linkForm.syncToHref = false;
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
if (!props.editor) {
|
||||
return;
|
||||
}
|
||||
const href = linkForm.href.trim();
|
||||
if (!href) {
|
||||
props.editor.chain().focus().unsetLink().run();
|
||||
visible.value = false;
|
||||
return;
|
||||
}
|
||||
const text = linkForm.syncToHref ? href : linkForm.text.trim();
|
||||
if (text) {
|
||||
props.editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent({
|
||||
type: "text",
|
||||
text,
|
||||
marks: [{ type: "link", attrs: { href } }]
|
||||
})
|
||||
.run();
|
||||
visible.value = false;
|
||||
return;
|
||||
}
|
||||
props.editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.link-form {
|
||||
gap: .75rem;
|
||||
width: 20rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -1,184 +0,0 @@
|
||||
<template>
|
||||
<t-popconfirm
|
||||
class="tiptap-media-button"
|
||||
v-model:visible="visible"
|
||||
trigger="click"
|
||||
:confirm-btn="{ content: '插入', theme: 'primary' }"
|
||||
:cancel-btn="{ content: '取消', theme: 'default' }"
|
||||
placement="bottom"
|
||||
@visible-change="onVisibleChange"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<template #icon><span></span></template>
|
||||
<template #content>
|
||||
<t-form class="popconfirm-content" label-align="left" label-width="auto">
|
||||
<t-form-item label="文件">
|
||||
<t-button size="small" variant="outline" @click="pickFile">
|
||||
<span
|
||||
class="file-name keep-text"
|
||||
v-text="selectedFile ? '重新选择' : `选择${typeLabel}`"
|
||||
/>
|
||||
</t-button>
|
||||
<span class="word-space gray" v-text="selectedFile?.name || `未选择${typeLabel}文件`" />
|
||||
</t-form-item>
|
||||
<t-form-item label="标题">
|
||||
<t-input v-model="mediaForm.title" :placeholder="`请输入${typeLabel}标题,可留空`" />
|
||||
</t-form-item>
|
||||
<t-form-item label="宽度">
|
||||
<t-input v-model="mediaForm.width" placeholder="请输入宽度,默认 100%" />
|
||||
</t-form-item>
|
||||
<t-form-item label="高度">
|
||||
<t-input v-model="mediaForm.height" placeholder="请输入高度,默认 auto" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</template>
|
||||
<t-button variant="outline" :theme="active ? 'primary' : 'default'" :loading="uploading">
|
||||
<t-icon :name="iconName" />
|
||||
</t-button>
|
||||
</t-popconfirm>
|
||||
<input ref="fileInputRef" class="file-input" type="file" :accept="accept" @change="onFileChange" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { showTDesignMessage } from "../../utils/TDesignMessage";
|
||||
import type { TiptapEditorInstance } from "./useTiptapEditor";
|
||||
import type { UploadResult } from "./shared";
|
||||
|
||||
defineOptions({
|
||||
name: "TiptapMediaButton"
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
editor?: TiptapEditorInstance;
|
||||
active: boolean;
|
||||
type: "image" | "video";
|
||||
upload?: (file: File) => Promise<UploadResult>;
|
||||
}>();
|
||||
|
||||
const appContext = getCurrentInstance()?.appContext;
|
||||
const visible = ref(false);
|
||||
const uploading = ref(false);
|
||||
const selectedFile = ref<File>();
|
||||
const fileInputRef = ref<HTMLInputElement>();
|
||||
const mediaForm = reactive({
|
||||
title: "",
|
||||
width: "100%",
|
||||
height: ""
|
||||
});
|
||||
|
||||
const typeLabel = computed(() => {
|
||||
return props.type === "image" ? "图片" : "视频";
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
return props.type === "image" ? "image-add" : "video";
|
||||
});
|
||||
|
||||
const accept = computed(() => {
|
||||
return props.type === "image" ? "image/*" : "video/*";
|
||||
});
|
||||
|
||||
function buildDefaultHeight() {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
function pickFile(event?: MouseEvent) {
|
||||
event?.stopPropagation();
|
||||
fileInputRef.value?.click();
|
||||
}
|
||||
|
||||
function onVisibleChange(nextVisible: boolean) {
|
||||
if (!nextVisible || !props.editor) {
|
||||
return;
|
||||
}
|
||||
selectedFile.value = undefined;
|
||||
const attributes = props.editor.getAttributes(props.type);
|
||||
mediaForm.title = (attributes.title as string) || "";
|
||||
mediaForm.width = (attributes.width as string) || "100%";
|
||||
mediaForm.height = (attributes.height as string) || buildDefaultHeight();
|
||||
}
|
||||
|
||||
function onFileChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
selectedFile.value = target.files?.[0];
|
||||
target.value = "";
|
||||
if (!selectedFile.value) {
|
||||
return;
|
||||
}
|
||||
if (!mediaForm.title.trim()) {
|
||||
mediaForm.title = selectedFile.value.name;
|
||||
}
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
if (!props.editor) {
|
||||
return;
|
||||
}
|
||||
if (!props.upload) {
|
||||
await showTDesignMessage(appContext, "warning", `${typeLabel.value}上传功能未启用`);
|
||||
return;
|
||||
}
|
||||
if (!selectedFile.value) {
|
||||
await showTDesignMessage(appContext, "warning", `请选择${typeLabel.value}文件`);
|
||||
return;
|
||||
}
|
||||
uploading.value = true;
|
||||
try {
|
||||
const file = selectedFile.value;
|
||||
const result = await props.upload(file);
|
||||
const title = mediaForm.title.trim() || result.name || file.name;
|
||||
const attrs = {
|
||||
src: result.url,
|
||||
title,
|
||||
width: mediaForm.width.trim() || "100%",
|
||||
height: mediaForm.height.trim() || buildDefaultHeight(),
|
||||
attachmentId: result.id,
|
||||
tempFileId: result.id
|
||||
};
|
||||
if (props.type === "image") {
|
||||
props.editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({
|
||||
src: attrs.src,
|
||||
alt: title,
|
||||
title
|
||||
})
|
||||
.updateAttributes("image", {
|
||||
attachmentId: attrs.attachmentId,
|
||||
tempFileId: attrs.tempFileId,
|
||||
width: attrs.width,
|
||||
height: attrs.height
|
||||
})
|
||||
.run();
|
||||
} else {
|
||||
props.editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent({
|
||||
type: "video",
|
||||
attrs
|
||||
})
|
||||
.run();
|
||||
}
|
||||
visible.value = false;
|
||||
} catch (error) {
|
||||
console.error(`上传${typeLabel.value}失败:`, error);
|
||||
await showTDesignMessage(appContext, "error", `上传${typeLabel.value}失败`);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.popconfirm-content {
|
||||
--td-comp-margin-xxl: .5rem;
|
||||
|
||||
padding: 0 .5rem;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,99 +0,0 @@
|
||||
<template>
|
||||
<t-popconfirm
|
||||
v-model:visible="visible"
|
||||
trigger="click"
|
||||
placement="bottom-right"
|
||||
:show-arrow="false"
|
||||
:confirm-btn="null"
|
||||
:cancel-btn="null"
|
||||
:popup-props="{ overlayClassName: 'tp-padding-pop' }"
|
||||
@visible-change="onVisibleChange"
|
||||
>
|
||||
<template #icon><span></span></template>
|
||||
<template #content>
|
||||
<div class="padding-form">
|
||||
<div class="item">
|
||||
<div class="head">
|
||||
<span class="label">上下内边距</span>
|
||||
<span class="value" v-text="formatPaddingValue(paddingTB)" />
|
||||
</div>
|
||||
<t-slider :min="0" :max="6" :step=".25" :value="paddingTB" @change="onPaddingTBChange" />
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="head">
|
||||
<span class="label">左右内边距</span>
|
||||
<span class="value" v-text="formatPaddingValue(paddingLR)" />
|
||||
</div>
|
||||
<t-slider :min="0" :max="16" :step=".25" :value="paddingLR" @change="onPaddingLRChange" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<t-button v-popup="'内边距'" variant="outline">
|
||||
<t-icon name="fullscreen-exit-1" />
|
||||
</t-button>
|
||||
</t-popconfirm>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { formatPaddingValue, normalizeSliderValue } from "./shared";
|
||||
|
||||
defineOptions({
|
||||
name: "TiptapPaddingButton"
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
paddingTB: number;
|
||||
paddingLR: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:paddingTB": [value: number];
|
||||
"update:paddingLR": [value: number];
|
||||
}>();
|
||||
|
||||
const visible = ref(false);
|
||||
|
||||
function onVisibleChange(nextVisible: boolean) {
|
||||
if (nextVisible) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function onPaddingTBChange(value: number | number[]) {
|
||||
emit("update:paddingTB", normalizeSliderValue(value, props.paddingTB));
|
||||
}
|
||||
|
||||
function onPaddingLRChange(value: number | number[]) {
|
||||
emit("update:paddingLR", normalizeSliderValue(value, props.paddingLR));
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.padding-form {
|
||||
gap: .75rem;
|
||||
width: 18rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.item {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.label {
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: .75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,126 +0,0 @@
|
||||
<template>
|
||||
<t-popconfirm
|
||||
v-model:visible="visible"
|
||||
trigger="click"
|
||||
placement="bottom"
|
||||
:show-arrow="false"
|
||||
:popup-props="{ overlayClassName: 'tp-table-pop' }"
|
||||
@visible-change="onVisibleChange"
|
||||
>
|
||||
<template #icon><span></span></template>
|
||||
<template #content>
|
||||
<div class="table-picker" @mouseleave="onReset">
|
||||
<div class="tip" v-text="`${hoverRow} × ${hoverCol}`" />
|
||||
<div class="grid">
|
||||
<button
|
||||
v-for="item in tablePickerItems"
|
||||
:key="item.key"
|
||||
class="cell"
|
||||
:class="{ active: item.active }"
|
||||
type="button"
|
||||
@mousemove="onHover(item.row, item.col)"
|
||||
@click="onSelect(item.row, item.col)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<t-button v-popup="'插入表格'" variant="outline" :theme="active ? 'primary' : 'default'">
|
||||
<t-icon name="table" />
|
||||
</t-button>
|
||||
</t-popconfirm>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { TABLE_PICKER_SIZE } from "./shared";
|
||||
import type { TiptapEditorInstance } from "./useTiptapEditor";
|
||||
|
||||
defineOptions({
|
||||
name: "TiptapTableButton"
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
editor?: TiptapEditorInstance;
|
||||
active: boolean;
|
||||
}>();
|
||||
|
||||
const visible = ref(false);
|
||||
const hoverRow = ref(1);
|
||||
const hoverCol = ref(1);
|
||||
|
||||
const tablePickerItems = computed(() => {
|
||||
const items: { key: string; row: number; col: number; active: boolean }[] = [];
|
||||
for (let row = 1; row < TABLE_PICKER_SIZE + 1; row += 1) {
|
||||
for (let col = 1; col < TABLE_PICKER_SIZE + 1; col += 1) {
|
||||
items.push({
|
||||
key: `${row}-${col}`,
|
||||
row,
|
||||
col,
|
||||
active: row < hoverRow.value + 1 && col < hoverCol.value + 1
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
function onVisibleChange(nextVisible: boolean) {
|
||||
if (!nextVisible) {
|
||||
return;
|
||||
}
|
||||
hoverRow.value = 1;
|
||||
hoverCol.value = 1;
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
hoverRow.value = 1;
|
||||
hoverCol.value = 1;
|
||||
}
|
||||
|
||||
function onHover(row: number, col: number) {
|
||||
hoverRow.value = row;
|
||||
hoverCol.value = col;
|
||||
}
|
||||
|
||||
function onSelect(row: number, col: number) {
|
||||
if (!props.editor) {
|
||||
return;
|
||||
}
|
||||
hoverRow.value = row;
|
||||
hoverCol.value = col;
|
||||
props.editor.chain().focus().insertTable({ rows: row, cols: col, withHeaderRow: true }).run();
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.table-picker {
|
||||
width: 10.5rem;
|
||||
|
||||
.tip {
|
||||
height: 1.5rem;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: .75rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
gap: .125rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
}
|
||||
|
||||
.cell {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0;
|
||||
border: 1px solid var(--td-component-border);
|
||||
cursor: pointer;
|
||||
background: var(--td-bg-color-container);
|
||||
transition: background .15s ease, border-color .15s ease;
|
||||
|
||||
&.active {
|
||||
border-color: var(--td-brand-color);
|
||||
background: var(--td-brand-color-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,431 +0,0 @@
|
||||
<template>
|
||||
<div class="toolbar">
|
||||
<div class="left" :class="{ 'toolbar-disabled': sourceCodeMode }">
|
||||
<div class="fixed">
|
||||
<t-button v-popup="'撤销'" variant="outline" :disabled="!canExec('undo')" @click="run('undo')">
|
||||
<t-icon name="rollback" />
|
||||
</t-button>
|
||||
<t-button v-popup="'重做'" variant="outline" :disabled="!canExec('redo')" @click="run('redo')">
|
||||
<t-icon name="rollfront" />
|
||||
</t-button>
|
||||
<t-button v-popup="'清除格式'" variant="outline" @click="run('clear')">
|
||||
<t-icon name="clear-formatting" />
|
||||
</t-button>
|
||||
</div>
|
||||
<div class="scroll">
|
||||
<t-dropdown trigger="click" :options="headingOptions" :min-column-width="120" @click="onHeadingSelect">
|
||||
<t-button v-popup="'标题'" variant="outline" :theme="isHeadingActive ? 'primary' : 'default'">
|
||||
<t-icon name="text" />
|
||||
</t-button>
|
||||
</t-dropdown>
|
||||
<t-button v-popup="'加粗'" variant="outline" :theme="isActive('bold') ? 'primary' : 'default'" @click="run('bold')">
|
||||
<t-icon name="textformat-bold" />
|
||||
</t-button>
|
||||
<t-button v-popup="'斜体'" variant="outline" :theme="isActive('italic') ? 'primary' : 'default'" @click="run('italic')">
|
||||
<t-icon name="textformat-italic" />
|
||||
</t-button>
|
||||
<t-button v-popup="'下划线'" variant="outline" :theme="isActive('underline') ? 'primary' : 'default'" @click="run('underline')">
|
||||
<t-icon name="textformat-underline" />
|
||||
</t-button>
|
||||
<t-button v-popup="'删除线'" variant="outline" :theme="isActive('strike') ? 'primary' : 'default'" @click="run('strike')">
|
||||
<t-icon name="textformat-strikethrough" />
|
||||
</t-button>
|
||||
<t-button v-popup="'前景色'" variant="outline" @click="pickColor('text')">
|
||||
<t-icon name="textformat-color" />
|
||||
</t-button>
|
||||
<t-button v-popup="'背景色'" variant="outline" @click="pickColor('bg')">
|
||||
<t-icon name="fill-color-1" />
|
||||
</t-button>
|
||||
<t-button v-popup="'有序列表'" variant="outline" :theme="isActive('orderedList') ? 'primary' : 'default'" @click="run('orderedList')">
|
||||
<t-icon name="order-list" />
|
||||
</t-button>
|
||||
<tiptap-table-button :editor="editor" :active="isActive('table')" />
|
||||
<t-button v-popup="'无序列表'" variant="outline" :theme="isActive('bulletList') ? 'primary' : 'default'" @click="run('bulletList')">
|
||||
<t-icon name="bulletpoint" />
|
||||
</t-button>
|
||||
<t-button v-popup="'行内代码'" variant="outline" :theme="isActive('code') ? 'primary' : 'default'" @click="run('code')">
|
||||
<t-icon name="highlighted-block" />
|
||||
</t-button>
|
||||
<tiptap-code-block-button :editor="editor" :active="isActive('codeBlock')" />
|
||||
<t-button v-popup="'下标'" variant="outline" :theme="isActive('subscript') ? 'primary' : 'default'" @click="run('subscript')">
|
||||
<t-icon name="subscript" />
|
||||
</t-button>
|
||||
<t-button v-popup="'上标'" variant="outline" :theme="isActive('superscript') ? 'primary' : 'default'" @click="run('superscript')">
|
||||
<t-icon name="superscript" />
|
||||
</t-button>
|
||||
<t-button v-popup="'左对齐'" variant="outline" :theme="isAlignActive('left') ? 'primary' : 'default'" @click="setAlign('left')">
|
||||
<t-icon name="format-vertical-align-left" />
|
||||
</t-button>
|
||||
<t-button v-popup="'居中对齐'" variant="outline" :theme="isAlignActive('center') ? 'primary' : 'default'" @click="setAlign('center')">
|
||||
<t-icon name="format-vertical-align-center" />
|
||||
</t-button>
|
||||
<t-button v-popup="'右对齐'" variant="outline" :theme="isAlignActive('right') ? 'primary' : 'default'" @click="setAlign('right')">
|
||||
<t-icon name="format-vertical-align-right" />
|
||||
</t-button>
|
||||
<t-button v-popup="'两端对齐'" variant="outline" :theme="isAlignActive('justify') ? 'primary' : 'default'" @click="setAlign('justify')">
|
||||
<t-icon name="expand-horizontal" />
|
||||
</t-button>
|
||||
<t-button v-popup="'分割线'" variant="outline" @click="run('horizontalRule')">
|
||||
<t-icon name="divider-1" />
|
||||
</t-button>
|
||||
<tiptap-link-button :editor="editor" :active="isActive('link')" />
|
||||
<tiptap-media-button :editor="editor" :active="isActive('image')" type="image" :upload="uploadImage" />
|
||||
<tiptap-media-button :editor="editor" :active="isActive('video')" type="video" :upload="uploadVideo" />
|
||||
<tiptap-iframe-button :editor="editor" :active="isActive('iframe')" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<div v-if="showPaddingTool" class="right__group" :class="{ 'toolbar-disabled': sourceCodeMode }">
|
||||
<tiptap-padding-button
|
||||
:padding-t-b="paddingTB"
|
||||
:padding-l-r="paddingLR"
|
||||
@update:padding-t-b="emit('update:paddingTB', $event)"
|
||||
@update:padding-l-r="emit('update:paddingLR', $event)"
|
||||
/>
|
||||
</div>
|
||||
<t-button
|
||||
v-popup="sourceCodeMode ? '富文本' : '源码'"
|
||||
class="source-btn"
|
||||
variant="outline"
|
||||
:theme="sourceCodeMode ? 'primary' : 'default'"
|
||||
@click="emit('toggle-source-code')"
|
||||
>
|
||||
<t-icon v-if="sourceCodeMode" name="edit-1" />
|
||||
<t-icon v-else name="braces" />
|
||||
</t-button>
|
||||
</div>
|
||||
<input ref="textColorRef" class="hidden" type="color" :value="textColor" @input="onTextColorChange" />
|
||||
<input ref="bgColorRef" class="hidden" type="color" :value="bgColor" @input="onBgColorChange" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { AlignType, UploadResult } from "./shared";
|
||||
import { buildHeadingOptions } from "./shared";
|
||||
import type { TiptapEditorInstance } from "./useTiptapEditor";
|
||||
import TiptapCodeBlockButton from "./TiptapCodeBlockButton.vue";
|
||||
import TiptapIframeButton from "./TiptapIframeButton.vue";
|
||||
import TiptapLinkButton from "./TiptapLinkButton.vue";
|
||||
import TiptapMediaButton from "./TiptapMediaButton.vue";
|
||||
import TiptapPaddingButton from "./TiptapPaddingButton.vue";
|
||||
import TiptapTableButton from "./TiptapTableButton.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "TiptapToolbar"
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
editor?: TiptapEditorInstance;
|
||||
uploadImage?: (file: File) => Promise<UploadResult>;
|
||||
uploadVideo?: (file: File) => Promise<UploadResult>;
|
||||
paddingTB: number;
|
||||
paddingLR: number;
|
||||
showPaddingTool?: boolean;
|
||||
sourceCodeMode?: boolean;
|
||||
}>(), {
|
||||
showPaddingTool: true,
|
||||
sourceCodeMode: false
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:paddingTB": [value: number];
|
||||
"update:paddingLR": [value: number];
|
||||
"toggle-source-code": [];
|
||||
}>();
|
||||
|
||||
type RunType =
|
||||
| "undo"
|
||||
| "redo"
|
||||
| "bold"
|
||||
| "italic"
|
||||
| "underline"
|
||||
| "strike"
|
||||
| "orderedList"
|
||||
| "bulletList"
|
||||
| "code"
|
||||
| "subscript"
|
||||
| "superscript"
|
||||
| "horizontalRule"
|
||||
| "clear";
|
||||
|
||||
const textColorRef = ref<HTMLInputElement>();
|
||||
const bgColorRef = ref<HTMLInputElement>();
|
||||
const textColor = ref("#000000");
|
||||
const bgColor = ref("#ffff00");
|
||||
|
||||
const currentHeadingValue = computed(() => {
|
||||
if (!props.editor) {
|
||||
return "p";
|
||||
}
|
||||
for (let level = 1; level < 7; level += 1) {
|
||||
if (props.editor.isActive("heading", { level })) {
|
||||
return `h${level}`;
|
||||
}
|
||||
}
|
||||
return "p";
|
||||
});
|
||||
|
||||
const headingOptions = computed(() => {
|
||||
return buildHeadingOptions(currentHeadingValue.value);
|
||||
});
|
||||
|
||||
const isHeadingActive = computed(() => {
|
||||
return currentHeadingValue.value !== "p";
|
||||
});
|
||||
|
||||
function canExec(type: "undo" | "redo") {
|
||||
if (!props.editor) {
|
||||
return false;
|
||||
}
|
||||
switch (type) {
|
||||
case "undo":
|
||||
return props.editor.can().undo();
|
||||
case "redo":
|
||||
return props.editor.can().redo();
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isActive(type: string) {
|
||||
if (!props.editor) {
|
||||
return false;
|
||||
}
|
||||
return props.editor.isActive(type);
|
||||
}
|
||||
|
||||
function isAlignActive(align: AlignType) {
|
||||
if (!props.editor) {
|
||||
return false;
|
||||
}
|
||||
return props.editor.isActive({ textAlign: align });
|
||||
}
|
||||
|
||||
function run(type: RunType) {
|
||||
if (!props.editor) {
|
||||
return;
|
||||
}
|
||||
const chain = props.editor.chain().focus();
|
||||
switch (type) {
|
||||
case "undo":
|
||||
chain.undo().run();
|
||||
return;
|
||||
case "redo":
|
||||
chain.redo().run();
|
||||
return;
|
||||
case "bold":
|
||||
chain.toggleBold().run();
|
||||
return;
|
||||
case "italic":
|
||||
chain.toggleItalic().run();
|
||||
return;
|
||||
case "underline":
|
||||
chain.toggleUnderline().run();
|
||||
return;
|
||||
case "strike":
|
||||
chain.toggleStrike().run();
|
||||
return;
|
||||
case "orderedList":
|
||||
chain.toggleOrderedList().run();
|
||||
return;
|
||||
case "bulletList":
|
||||
chain.toggleBulletList().run();
|
||||
return;
|
||||
case "code":
|
||||
chain.toggleCode().run();
|
||||
return;
|
||||
case "subscript":
|
||||
chain.toggleSubscript().run();
|
||||
return;
|
||||
case "superscript":
|
||||
chain.toggleSuperscript().run();
|
||||
return;
|
||||
case "horizontalRule":
|
||||
chain.setHorizontalRule().run();
|
||||
return;
|
||||
case "clear":
|
||||
chain.clearNodes().unsetAllMarks().run();
|
||||
return;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
function setAlign(align: AlignType) {
|
||||
props.editor?.chain().focus().setTextAlign(align).run();
|
||||
}
|
||||
|
||||
function onHeadingSelect(option: { value?: string | number | Record<string, unknown> }) {
|
||||
if (!props.editor) {
|
||||
return;
|
||||
}
|
||||
const value = `${option?.value || "p"}`;
|
||||
const chain = props.editor.chain().focus();
|
||||
if (value === "p") {
|
||||
chain.setParagraph().run();
|
||||
return;
|
||||
}
|
||||
const level = Number(value.replace("h", ""));
|
||||
if (0 < level && level < 7) {
|
||||
chain.setHeading({ level: level as 1 | 2 | 3 | 4 | 5 | 6 }).run();
|
||||
}
|
||||
}
|
||||
|
||||
function pickColor(type: "text" | "bg") {
|
||||
if (type === "text") {
|
||||
textColorRef.value?.click();
|
||||
return;
|
||||
}
|
||||
bgColorRef.value?.click();
|
||||
}
|
||||
|
||||
function onTextColorChange(event: Event) {
|
||||
if (!props.editor) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as HTMLInputElement;
|
||||
textColor.value = target.value;
|
||||
props.editor.chain().focus().setColor(target.value).run();
|
||||
}
|
||||
|
||||
function onBgColorChange(event: Event) {
|
||||
if (!props.editor) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as HTMLInputElement;
|
||||
bgColor.value = target.value;
|
||||
props.editor.chain().focus().setMark("textStyle", { backgroundColor: target.value }).run();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
position: relative;
|
||||
margin-top: -1px;
|
||||
justify-content: space-between;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
bottom: 0;
|
||||
position: absolute;
|
||||
background: var(--td-component-border);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.left,
|
||||
.right {
|
||||
gap: 0;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
:deep(.t-button) {
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
margin-left: -1px;
|
||||
border-radius: 0;
|
||||
--td-comp-paddingLR-l: 8px;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible,
|
||||
&.t-is-active,
|
||||
&.t-button--theme-primary {
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.fixed,
|
||||
.scroll {
|
||||
gap: 0;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.fixed {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
> * {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
:deep(.t-button:last-child) {
|
||||
margin-right: 3rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
flex: none;
|
||||
margin-right: -1px;
|
||||
|
||||
.right__group {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-disabled {
|
||||
opacity: .55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.source-btn {
|
||||
margin-left: -1px;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 48rem) {
|
||||
.toolbar {
|
||||
justify-content: flex-start;
|
||||
|
||||
.left {
|
||||
.scroll {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.tp-heading-option) {
|
||||
display: block;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.heading-size(@name, @size, @weight) {
|
||||
:global(.tp-heading-option.@{name}) {
|
||||
font-size: @size;
|
||||
font-weight: @weight;
|
||||
}
|
||||
}
|
||||
|
||||
.heading-size(p, 1rem, 400);
|
||||
.heading-size(h1, 2rem, 700);
|
||||
.heading-size(h2, 1.5rem, 700);
|
||||
.heading-size(h3, 1.17rem, 700);
|
||||
.heading-size(h4, 1rem, 700);
|
||||
.heading-size(h5, .83rem, 700);
|
||||
.heading-size(h6, .67rem, 700);
|
||||
</style>
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import view from "./index.vue";
|
||||
import Toolkit from "../../utils/Toolkit";
|
||||
import type { TiptapEditorInstance } from "./useTiptapEditor";
|
||||
|
||||
export * from "./shared";
|
||||
export type { TiptapEditorInstance } from "./useTiptapEditor";
|
||||
export type TiptapEditorExpose = {
|
||||
submit: () => Promise<string>;
|
||||
getPendingMediaCount: () => number;
|
||||
editor?: TiptapEditorInstance;
|
||||
};
|
||||
|
||||
export const TiptapEditor = Toolkit.withInstall(view);
|
||||
export default TiptapEditor;
|
||||
|
||||
@@ -1,617 +0,0 @@
|
||||
<template>
|
||||
<div class="tp-editor">
|
||||
<tiptap-toolbar
|
||||
:editor="editor || undefined"
|
||||
:padding-t-b="articlePadding.paddingTB"
|
||||
:padding-l-r="articlePadding.paddingLR"
|
||||
:show-padding-tool="showPaddingTool"
|
||||
:source-code-mode="sourceCodeMode"
|
||||
:upload-image="handleInsertImage"
|
||||
:upload-video="handleInsertVideo"
|
||||
@update:padding-t-b="articlePadding.paddingTB = $event"
|
||||
@update:padding-l-r="articlePadding.paddingLR = $event"
|
||||
@toggle-source-code="toggleSourceCodeMode"
|
||||
/>
|
||||
<div class="content">
|
||||
<code-editor
|
||||
v-if="sourceCodeMode"
|
||||
ref="sourceEditorRef"
|
||||
v-model="sourceCode"
|
||||
class="source-editor"
|
||||
language="html"
|
||||
placeholder="请输入 HTML 源码"
|
||||
/>
|
||||
<div v-else-if="editor" class="value-wrap">
|
||||
<editor-search-bar
|
||||
ref="searchBarRef"
|
||||
:visible="searchVisible"
|
||||
:expanded="replaceExpanded"
|
||||
:keyword="searchKeyword"
|
||||
:replace-value="replaceKeyword"
|
||||
:status-error="searchError"
|
||||
:match-index="searchMatchIndex"
|
||||
:match-count="searchMatchList.length"
|
||||
:case-sensitive="searchOptions.caseSensitive"
|
||||
:use-regex="searchOptions.useRegex"
|
||||
@update:expanded="replaceExpanded = $event"
|
||||
@update:keyword="searchKeyword = $event"
|
||||
@update:replace-value="replaceKeyword = $event"
|
||||
@update:case-sensitive="searchOptions.caseSensitive = $event"
|
||||
@update:use-regex="searchOptions.useRegex = $event"
|
||||
@previous="findPrevious"
|
||||
@next="onSearchNext"
|
||||
@replace="replaceCurrent"
|
||||
@replace-all="replaceAll"
|
||||
@close="closeSearch"
|
||||
/>
|
||||
<editor-content
|
||||
class="value"
|
||||
:style="editorBodyStyle"
|
||||
:editor="editor"
|
||||
:spellcheck="false"
|
||||
@keydown.capture="onRichEditorKeydown"
|
||||
/>
|
||||
</div>
|
||||
<t-empty v-else description="编辑器初始化中" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import CodeEditor from "../code-editor/index.vue";
|
||||
import EditorSearchBar from "../editor-search/index.vue";
|
||||
import {
|
||||
buildSearchRegex,
|
||||
replaceMatchText,
|
||||
type TextSearchOptions
|
||||
} from "../editor-search/search";
|
||||
import Toolkit from "../../utils/Toolkit";
|
||||
import type { PendingMediaItem, UploadMediaType, UploadResult } from "./shared";
|
||||
import TiptapToolbar from "./TiptapToolbar.vue";
|
||||
import { useTiptapEditor } from "./useTiptapEditor";
|
||||
import {
|
||||
ATTACHMENT_ID_ATTR,
|
||||
LOCAL_MEDIA_ID_PREFIX,
|
||||
TEMP_FILE_ID_ATTR,
|
||||
isLocalMediaId,
|
||||
normalizeContent
|
||||
} from "./shared";
|
||||
|
||||
defineOptions({
|
||||
name: "TiptapEditor"
|
||||
});
|
||||
|
||||
type EditorSearchBarExpose = {
|
||||
focusKeyword: (selectAll?: boolean) => void;
|
||||
focusReplace: (selectAll?: boolean) => void;
|
||||
};
|
||||
|
||||
type CodeEditorExpose = {
|
||||
openSearch: (showReplace: boolean) => void;
|
||||
};
|
||||
|
||||
type RichTextMatch = {
|
||||
from: number;
|
||||
to: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: string;
|
||||
placeholder?: string;
|
||||
paddingTB?: number;
|
||||
paddingLR?: number;
|
||||
showPaddingTool?: boolean;
|
||||
uploadImage?: (file: File) => Promise<UploadResult>;
|
||||
uploadVideo?: (file: File) => Promise<UploadResult>;
|
||||
}>(), {
|
||||
modelValue: "",
|
||||
placeholder: "请输入内容",
|
||||
paddingTB: 1,
|
||||
paddingLR: 1.25,
|
||||
showPaddingTool: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string];
|
||||
"change": [value: string];
|
||||
"update:paddingTB": [value: number];
|
||||
"update:paddingLR": [value: number];
|
||||
}>();
|
||||
|
||||
const { EditorContent, articlePadding, editor, editorBodyStyle } = useTiptapEditor(props, emit);
|
||||
const pendingMediaMap = reactive(new Map<string, PendingMediaItem>());
|
||||
const sourceCodeMode = ref(false);
|
||||
const sourceCode = ref("");
|
||||
const sourceEditorRef = ref<CodeEditorExpose>();
|
||||
const searchBarRef = ref<EditorSearchBarExpose>();
|
||||
const searchVisible = ref(false);
|
||||
const replaceExpanded = ref(false);
|
||||
const searchKeyword = ref("");
|
||||
const replaceKeyword = ref("");
|
||||
const searchError = ref("");
|
||||
const searchMatchIndex = ref(-1);
|
||||
const searchMatchList = ref<RichTextMatch[]>([]);
|
||||
const searchOptions = reactive<TextSearchOptions>({
|
||||
caseSensitive: false,
|
||||
useRegex: false
|
||||
});
|
||||
|
||||
|
||||
function buildLocalMediaId() {
|
||||
return `${LOCAL_MEDIA_ID_PREFIX}${Toolkit.uuid()}`;
|
||||
}
|
||||
|
||||
function revokeMediaUrl(url?: string) {
|
||||
if (url?.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
function removePendingMedia(id: string) {
|
||||
const item = pendingMediaMap.get(id);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
revokeMediaUrl(item.url);
|
||||
pendingMediaMap.delete(id);
|
||||
}
|
||||
|
||||
function clearPendingMedia() {
|
||||
Array.from(pendingMediaMap.keys()).forEach(removePendingMedia);
|
||||
}
|
||||
|
||||
function collectMediaIds(content?: string) {
|
||||
const rawContent = normalizeContent(content);
|
||||
if (!rawContent) {
|
||||
return new Set<string>();
|
||||
}
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(rawContent, "text/html");
|
||||
const mediaList = Array.from(doc.querySelectorAll("img, video"));
|
||||
return new Set(
|
||||
mediaList
|
||||
.map(item => item.getAttribute(ATTACHMENT_ID_ATTR) || item.getAttribute(TEMP_FILE_ID_ATTR) || "")
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
function syncPendingMedia(content?: string) {
|
||||
const mediaIdSet = collectMediaIds(content);
|
||||
Array.from(pendingMediaMap.keys()).forEach((id) => {
|
||||
if (!mediaIdSet.has(id)) {
|
||||
removePendingMedia(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleInsertMedia(file: File, type: UploadMediaType): Promise<UploadResult> {
|
||||
const id = buildLocalMediaId();
|
||||
const url = URL.createObjectURL(file);
|
||||
pendingMediaMap.set(id, {
|
||||
id,
|
||||
type,
|
||||
name: file.name,
|
||||
file,
|
||||
url
|
||||
});
|
||||
return {
|
||||
id,
|
||||
url,
|
||||
name: file.name
|
||||
};
|
||||
}
|
||||
|
||||
async function handleInsertImage(file: File) {
|
||||
return handleInsertMedia(file, "image");
|
||||
}
|
||||
|
||||
async function handleInsertVideo(file: File) {
|
||||
return handleInsertMedia(file, "video");
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const instance = editor.value;
|
||||
if (!instance) {
|
||||
return normalizeContent(props.modelValue);
|
||||
}
|
||||
const rawContent = normalizeContent(instance.getHTML());
|
||||
if (!rawContent) {
|
||||
clearPendingMedia();
|
||||
return "";
|
||||
}
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(rawContent, "text/html");
|
||||
const mediaList = Array.from(doc.querySelectorAll("img, video"));
|
||||
const uploadedMediaIdList: string[] = [];
|
||||
|
||||
for (const media of mediaList) {
|
||||
const attachmentId = media.getAttribute(ATTACHMENT_ID_ATTR) || media.getAttribute(TEMP_FILE_ID_ATTR) || "";
|
||||
if (!isLocalMediaId(attachmentId)) {
|
||||
continue;
|
||||
}
|
||||
const pendingMedia = pendingMediaMap.get(attachmentId);
|
||||
if (!pendingMedia) {
|
||||
continue;
|
||||
}
|
||||
const uploadFn = pendingMedia.type === "image" ? props.uploadImage : props.uploadVideo;
|
||||
if (!uploadFn) {
|
||||
throw new Error(`未配置${pendingMedia.type === "image" ? "图片" : "视频"}上传方法`);
|
||||
}
|
||||
const result = await uploadFn(pendingMedia.file);
|
||||
media.setAttribute("src", result.url);
|
||||
media.setAttribute(ATTACHMENT_ID_ATTR, result.id);
|
||||
media.setAttribute(TEMP_FILE_ID_ATTR, result.id);
|
||||
if (media.tagName.toLowerCase() === "img") {
|
||||
media.setAttribute("alt", result.name || pendingMedia.name);
|
||||
media.setAttribute("title", result.name || pendingMedia.name);
|
||||
} else {
|
||||
media.setAttribute("title", result.name || pendingMedia.name);
|
||||
}
|
||||
uploadedMediaIdList.push(attachmentId);
|
||||
}
|
||||
|
||||
const nextContent = normalizeContent(doc.body.innerHTML);
|
||||
if (nextContent !== rawContent) {
|
||||
instance.commands.setContent(nextContent || "<p></p>");
|
||||
}
|
||||
uploadedMediaIdList.forEach(removePendingMedia);
|
||||
syncPendingMedia(nextContent);
|
||||
return nextContent;
|
||||
}
|
||||
|
||||
function syncSourceCode(value?: string) {
|
||||
sourceCode.value = value || "";
|
||||
}
|
||||
|
||||
function onSourceCodeChange(value: string) {
|
||||
sourceCode.value = value;
|
||||
emit("change", value);
|
||||
emit("update:modelValue", value);
|
||||
}
|
||||
|
||||
function getSelectedEditorText() {
|
||||
const instance = editor.value;
|
||||
if (!instance) {
|
||||
return "";
|
||||
}
|
||||
const { from, to } = instance.state.selection;
|
||||
if (from === to) {
|
||||
return "";
|
||||
}
|
||||
return instance.state.doc.textBetween(from, to, "\n", "\n");
|
||||
}
|
||||
|
||||
function refreshSearchMatchState() {
|
||||
const instance = editor.value;
|
||||
if (!instance || sourceCodeMode.value) {
|
||||
searchError.value = "";
|
||||
searchMatchList.value = [];
|
||||
searchMatchIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
const { regex, error } = buildSearchRegex(searchKeyword.value, searchOptions, true);
|
||||
searchError.value = error;
|
||||
if (!regex || error || !searchKeyword.value) {
|
||||
searchMatchList.value = [];
|
||||
searchMatchIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
const matchList: RichTextMatch[] = [];
|
||||
instance.state.doc.descendants((node, pos) => {
|
||||
if (!node.isText || !node.text) {
|
||||
return;
|
||||
}
|
||||
regex.lastIndex = 0;
|
||||
let match = regex.exec(node.text);
|
||||
while (match) {
|
||||
if (!match[0]) {
|
||||
searchError.value = "正则不能匹配空文本";
|
||||
searchMatchList.value = [];
|
||||
searchMatchIndex.value = -1;
|
||||
return false;
|
||||
}
|
||||
matchList.push({
|
||||
from: pos + match.index,
|
||||
to: pos + match.index + match[0].length,
|
||||
text: match[0]
|
||||
});
|
||||
match = regex.exec(node.text);
|
||||
}
|
||||
});
|
||||
searchMatchList.value = matchList;
|
||||
const { from, to } = instance.state.selection;
|
||||
searchMatchIndex.value = matchList.findIndex((match) => match.from === from && match.to === to);
|
||||
if (0 > searchMatchIndex.value && 0 < matchList.length) {
|
||||
searchMatchIndex.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function openSearch(showReplace: boolean) {
|
||||
if (sourceCodeMode.value) {
|
||||
sourceEditorRef.value?.openSearch(showReplace);
|
||||
return;
|
||||
}
|
||||
searchVisible.value = true;
|
||||
replaceExpanded.value = showReplace;
|
||||
const selectedText = getSelectedEditorText();
|
||||
if (selectedText) {
|
||||
searchKeyword.value = selectedText;
|
||||
}
|
||||
refreshSearchMatchState();
|
||||
nextTick(() => {
|
||||
searchBarRef.value?.focusKeyword(true);
|
||||
});
|
||||
}
|
||||
|
||||
function closeSearch() {
|
||||
searchVisible.value = false;
|
||||
replaceExpanded.value = false;
|
||||
editor.value?.commands.focus();
|
||||
}
|
||||
|
||||
function selectMatch(match: RichTextMatch, matchIndex: number) {
|
||||
const instance = editor.value;
|
||||
if (!instance) {
|
||||
return false;
|
||||
}
|
||||
instance.chain().focus().setTextSelection({ from: match.from, to: match.to }).scrollIntoView().run();
|
||||
searchMatchIndex.value = matchIndex;
|
||||
return true;
|
||||
}
|
||||
|
||||
function findNext() {
|
||||
const instance = editor.value;
|
||||
if (!instance || searchError.value || 1 > searchMatchList.value.length) {
|
||||
return false;
|
||||
}
|
||||
const currentTo = instance.state.selection.to;
|
||||
const nextIndex = searchMatchList.value.findIndex((match) => match.from >= currentTo);
|
||||
const targetIndex = 0 <= nextIndex ? nextIndex : 0;
|
||||
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
|
||||
}
|
||||
|
||||
function findPrevious() {
|
||||
const instance = editor.value;
|
||||
if (!instance || searchError.value || 1 > searchMatchList.value.length) {
|
||||
return false;
|
||||
}
|
||||
const currentFrom = instance.state.selection.from;
|
||||
let targetIndex = searchMatchList.value.length - 1;
|
||||
for (let index = searchMatchList.value.length - 1; 0 <= index; index -= 1) {
|
||||
if (searchMatchList.value[index].from < currentFrom) {
|
||||
targetIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return selectMatch(searchMatchList.value[targetIndex], targetIndex);
|
||||
}
|
||||
|
||||
function replaceCurrent() {
|
||||
const instance = editor.value;
|
||||
if (!instance || !searchKeyword.value) {
|
||||
return false;
|
||||
}
|
||||
let currentMatchIndex = searchMatchList.value.findIndex((match) => (
|
||||
match.from === instance.state.selection.from && match.to === instance.state.selection.to
|
||||
));
|
||||
if (0 > currentMatchIndex) {
|
||||
if (!findNext()) {
|
||||
return false;
|
||||
}
|
||||
currentMatchIndex = searchMatchIndex.value;
|
||||
}
|
||||
const currentMatch = searchMatchList.value[currentMatchIndex];
|
||||
if (!currentMatch) {
|
||||
return false;
|
||||
}
|
||||
const nextText = replaceMatchText(currentMatch.text, searchKeyword.value, replaceKeyword.value, searchOptions);
|
||||
const transaction = instance.state.tr.insertText(nextText, currentMatch.from, currentMatch.to);
|
||||
instance.view.dispatch(transaction);
|
||||
refreshSearchMatchState();
|
||||
nextTick(() => {
|
||||
const nextMatch = searchMatchList.value[currentMatchIndex] || searchMatchList.value[currentMatchIndex - 1];
|
||||
if (!nextMatch) {
|
||||
instance.commands.focus();
|
||||
return;
|
||||
}
|
||||
selectMatch(nextMatch, Math.min(currentMatchIndex, searchMatchList.value.length - 1));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function replaceAll() {
|
||||
const instance = editor.value;
|
||||
if (!instance || !searchKeyword.value) {
|
||||
return 0;
|
||||
}
|
||||
if (searchError.value || 1 > searchMatchList.value.length) {
|
||||
refreshSearchMatchState();
|
||||
return 0;
|
||||
}
|
||||
const replaceCount = searchMatchList.value.length;
|
||||
const transaction = instance.state.tr;
|
||||
for (let index = searchMatchList.value.length - 1; 0 <= index; index -= 1) {
|
||||
const match = searchMatchList.value[index];
|
||||
const nextText = replaceMatchText(match.text, searchKeyword.value, replaceKeyword.value, searchOptions);
|
||||
transaction.insertText(nextText, match.from, match.to);
|
||||
}
|
||||
instance.view.dispatch(transaction);
|
||||
refreshSearchMatchState();
|
||||
nextTick(() => {
|
||||
if (searchMatchList.value[0]) {
|
||||
selectMatch(searchMatchList.value[0], 0);
|
||||
return;
|
||||
}
|
||||
instance.commands.focus();
|
||||
});
|
||||
return replaceCount;
|
||||
}
|
||||
|
||||
function onSearchNext(shiftKey: boolean) {
|
||||
if (shiftKey) {
|
||||
findPrevious();
|
||||
return;
|
||||
}
|
||||
findNext();
|
||||
}
|
||||
|
||||
function onRichEditorKeydown(event: KeyboardEvent) {
|
||||
if (sourceCodeMode.value || event.altKey || !(event.ctrlKey || event.metaKey)) {
|
||||
return;
|
||||
}
|
||||
const key = event.key.toLowerCase();
|
||||
if ("f" === key) {
|
||||
event.preventDefault();
|
||||
openSearch(false);
|
||||
return;
|
||||
}
|
||||
if ("h" === key) {
|
||||
event.preventDefault();
|
||||
openSearch(true);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSourceCodeMode() {
|
||||
const nextMode = !sourceCodeMode.value;
|
||||
closeSearch();
|
||||
if (nextMode) {
|
||||
syncSourceCode(normalizeContent(editor.value?.getHTML()) || props.modelValue);
|
||||
sourceCodeMode.value = true;
|
||||
return;
|
||||
}
|
||||
sourceCodeMode.value = false;
|
||||
const nextContent = normalizeContent(sourceCode.value);
|
||||
if (editor.value) {
|
||||
editor.value.commands.setContent(nextContent || "<p></p>");
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (value) => {
|
||||
syncPendingMedia(value);
|
||||
syncSourceCode(value);
|
||||
}, { immediate: true });
|
||||
|
||||
watch(sourceCode, (value) => {
|
||||
if (!sourceCodeMode.value) {
|
||||
return;
|
||||
}
|
||||
onSourceCodeChange(value);
|
||||
});
|
||||
|
||||
watch([searchKeyword, () => searchOptions.caseSensitive, () => searchOptions.useRegex, sourceCodeMode], () => {
|
||||
refreshSearchMatchState();
|
||||
});
|
||||
|
||||
watch(() => editor.value?.state.selection.from, () => {
|
||||
if (!searchVisible.value || sourceCodeMode.value) {
|
||||
return;
|
||||
}
|
||||
refreshSearchMatchState();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPendingMedia();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
editor,
|
||||
submit,
|
||||
getPendingMediaCount: () => pendingMediaMap.size,
|
||||
openSearch,
|
||||
closeSearch,
|
||||
findNext,
|
||||
findPrevious,
|
||||
replaceCurrent,
|
||||
replaceAll
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.tp-editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--td-component-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: text;
|
||||
padding: 2rem 10rem;
|
||||
display: flex;
|
||||
overflow: auto;
|
||||
box-sizing: border-box;
|
||||
background: var(--td-bg-color-page);
|
||||
|
||||
.value-wrap {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.value {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
overflow: auto;
|
||||
background: var(--td-bg-color-container);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
border-radius: var(--td-radius-default);
|
||||
|
||||
:deep(.editor-body) {
|
||||
outline: none;
|
||||
padding: var(--tp-editor-padding-tb) var(--tp-editor-padding-lr);
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
pre[class*='language-'] > code {
|
||||
padding: .25rem .5rem;
|
||||
}
|
||||
|
||||
p.is-editor-empty:first-child:before {
|
||||
height: 0;
|
||||
float: left;
|
||||
color: var(--td-text-color-placeholder);
|
||||
content: attr(data-placeholder);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
iframe.tp-iframe {
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.source-editor {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
background: var(--td-bg-color-container);
|
||||
border-radius: var(--td-radius-default);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 48rem) {
|
||||
.tp-editor {
|
||||
.content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import view from "./index.vue";
|
||||
import Toolkit from "../../utils/Toolkit";
|
||||
|
||||
export const TiptapView = Toolkit.withInstall(view);
|
||||
export default TiptapView;
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
<template>
|
||||
<div class="tp-view selectable">
|
||||
<div v-if="renderContent && editor" ref="contentRef" class="content-wrap" :data-max-height="maxHeight">
|
||||
<editor-content class="content" :editor="editor" />
|
||||
</div>
|
||||
<div v-else class="empty">
|
||||
<span v-text="emptyText" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {EditorContent, useEditor} from "@tiptap/vue-3";
|
||||
import {buildTiptapExtensions} from "../tiptap-editor/extensions";
|
||||
import {normalizeContent} from "../tiptap-editor/shared";
|
||||
import {enhancePrismCodeBlock} from "../../utils/PrismCodeBlock";
|
||||
|
||||
defineOptions({
|
||||
name: "TiptapView"
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
content?: string;
|
||||
emptyText?: string;
|
||||
maxHeight?: string;
|
||||
}>(), {
|
||||
content: "",
|
||||
emptyText: "暂无内容",
|
||||
maxHeight: "400px"
|
||||
});
|
||||
|
||||
const renderContent = computed(() => {
|
||||
return normalizeContent(props.content);
|
||||
});
|
||||
const contentRef = ref<HTMLElement>();
|
||||
|
||||
async function syncCodeBlock() {
|
||||
await nextTick();
|
||||
if (contentRef.value) {
|
||||
enhancePrismCodeBlock(contentRef.value);
|
||||
}
|
||||
}
|
||||
|
||||
const editor = useEditor({
|
||||
editable: false,
|
||||
extensions: buildTiptapExtensions(),
|
||||
content: renderContent.value || "<p></p>",
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "editor-body tp-content-body"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => [renderContent.value, props.maxHeight] as const, ([value]) => {
|
||||
if (!editor.value) {
|
||||
return;
|
||||
}
|
||||
editor.value.commands.setContent(value || "<p></p>", {
|
||||
emitUpdate: false
|
||||
});
|
||||
void syncCodeBlock();
|
||||
}, { immediate: true });
|
||||
|
||||
watch(editor, () => {
|
||||
void syncCodeBlock();
|
||||
}, { flush: "post" });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor.value?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.tp-view {
|
||||
width: 100%;
|
||||
.empty {
|
||||
padding: 1rem;
|
||||
border-radius: .375rem;
|
||||
color: var(--td-text-color-placeholder);
|
||||
background: var(--td-bg-color-container);
|
||||
border: 1px solid var(--td-component-border);
|
||||
}
|
||||
|
||||
.content {
|
||||
border: 1px solid var(--td-component-border);
|
||||
padding: 1rem;
|
||||
min-height: 12rem;
|
||||
background: var(--td-bg-color-container);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 745 B |
@@ -1,5 +0,0 @@
|
||||
import view from "./index.vue";
|
||||
import Toolkit from "../../utils/Toolkit";
|
||||
|
||||
export const UserLevel = Toolkit.withInstall(view);
|
||||
export default UserLevel;
|
||||
@@ -1,25 +0,0 @@
|
||||
<template>
|
||||
<div class="tui-user-level" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineOptions({
|
||||
name: "UserLevel"
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
value: number;
|
||||
}>(), {});
|
||||
const { value } = toRefs(props);
|
||||
|
||||
const offset = computed(() => -9 * value.value + "px");
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.tui-user-level {
|
||||
width: 19px;
|
||||
height: 9px;
|
||||
background: url("./icon.png");
|
||||
background-position-y: v-bind(offset);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
import view from "./index.vue";
|
||||
import WebToolkit from "../../utils/WebToolkit";
|
||||
|
||||
export const HoverPopup = WebToolkit.withInstall(view);
|
||||
|
||||
// 兼容旧导出,后续应统一改为 HoverPopup
|
||||
export const VPopupComponent = HoverPopup;
|
||||
export default HoverPopup;
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<component
|
||||
:is="tag"
|
||||
class="v-popup-root"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
<teleport v-if="isShowing && hasPopupSlot" to="#tui-popup-content">
|
||||
<div class="v-popup-content">
|
||||
<slot name="popup" />
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, ref, useSlots } from "vue";
|
||||
import { appendPopupValue, clearPopup, getPopup, getPopupContent, hidePopup, movePopup, PopupType, showPopup } from "../../utils/Popup";
|
||||
|
||||
type PopupValue = string | HTMLElement;
|
||||
|
||||
defineOptions({
|
||||
name: "HoverPopup"
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
tag?: string;
|
||||
type?: PopupType;
|
||||
value?: PopupValue;
|
||||
canShow?: () => boolean;
|
||||
beforeShow?: (type: PopupType, value?: PopupValue) => void | Promise<void>;
|
||||
afterHidden?: (type: PopupType, value?: PopupValue) => void | Promise<void>;
|
||||
}>(), {
|
||||
tag: "span",
|
||||
type: PopupType.TEXT,
|
||||
canShow: () => true
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
const hasPopupSlot = computed(() => !!slots.popup);
|
||||
const isShowing = ref(false);
|
||||
|
||||
async function onMouseEnter(e: MouseEvent) {
|
||||
const popup = getPopup();
|
||||
const popupContent = getPopupContent();
|
||||
if (!popup || !popupContent) {
|
||||
return;
|
||||
}
|
||||
if (!hasPopupSlot.value && !props.value) {
|
||||
return;
|
||||
}
|
||||
if (props.beforeShow) {
|
||||
await props.beforeShow(props.type, props.value);
|
||||
}
|
||||
if (!props.canShow()) {
|
||||
return;
|
||||
}
|
||||
clearPopup(popupContent);
|
||||
if (hasPopupSlot.value) {
|
||||
isShowing.value = true;
|
||||
await nextTick();
|
||||
} else if (props.value) {
|
||||
appendPopupValue(popupContent, props.type, props.value);
|
||||
}
|
||||
movePopup(popup, e.x, e.y);
|
||||
showPopup(popup);
|
||||
isShowing.value = true;
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
const popup = getPopup();
|
||||
if (!popup || !isShowing.value || !props.canShow()) {
|
||||
return;
|
||||
}
|
||||
movePopup(popup, e.x, e.y);
|
||||
}
|
||||
|
||||
async function onMouseLeave() {
|
||||
const popup = getPopup();
|
||||
const popupContent = getPopupContent();
|
||||
isShowing.value = false;
|
||||
await nextTick();
|
||||
if (popup) {
|
||||
hidePopup(popup);
|
||||
}
|
||||
if (popupContent) {
|
||||
clearPopup(popupContent);
|
||||
}
|
||||
if (props.afterHidden) {
|
||||
await props.afterHidden(props.type, props.value);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.v-popup-content {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,11 @@
|
||||
type EditorSelection = {
|
||||
/** 代码编辑器的公共能力,包括键盘输入处理和 Prism 高亮。 */
|
||||
import Prism from "prismjs";
|
||||
import { resolvePrismLanguage } from "../utils/Prism";
|
||||
|
||||
export { resolvePrismLanguage } from "../utils/Prism";
|
||||
|
||||
/** 编辑器文本选区,使用字符串下标表示半开区间。 */
|
||||
export type EditorSelection = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
@@ -20,6 +27,7 @@ const WRAP_CHAR_MAP: Record<string, string> = {
|
||||
};
|
||||
const CLOSE_CHAR_LIST = new Set(Object.values(WRAP_CHAR_MAP));
|
||||
|
||||
/** 根据键盘事件计算代码编辑器应执行的文本变更。 */
|
||||
export const resolveCodeEditorKeydown = (
|
||||
value: string,
|
||||
selection: EditorSelection,
|
||||
@@ -40,7 +48,16 @@ export const resolveCodeEditorKeydown = (
|
||||
return resolvePairInput(value, selection, event.key);
|
||||
};
|
||||
|
||||
// 处理 Tab / Shift+Tab 缩进
|
||||
/** 使用 Prism 高亮代码;未识别语言时返回已转义的纯文本。 */
|
||||
export function highlightCode(value: string, language?: string | null) {
|
||||
const code = value || " ";
|
||||
const resolvedLanguage = resolvePrismLanguage(language);
|
||||
if (!resolvedLanguage) {
|
||||
return escapeHTML(code);
|
||||
}
|
||||
return Prism.highlight(code, Prism.languages[resolvedLanguage], resolvedLanguage);
|
||||
}
|
||||
|
||||
const resolveTabInput = (
|
||||
value: string,
|
||||
selection: EditorSelection,
|
||||
@@ -89,7 +106,6 @@ const resolveTabInput = (
|
||||
};
|
||||
};
|
||||
|
||||
// 回车时继承当前行缩进,位于块级起始括号后时额外增加一层缩进
|
||||
const resolveEnterInput = (
|
||||
value: string,
|
||||
selection: EditorSelection
|
||||
@@ -129,7 +145,6 @@ const resolveEnterInput = (
|
||||
};
|
||||
};
|
||||
|
||||
// 处理成对符号输入,选中文本时直接包裹
|
||||
const resolvePairInput = (
|
||||
value: string,
|
||||
selection: EditorSelection,
|
||||
@@ -151,7 +166,6 @@ const resolvePairInput = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// 光标位于成对符号中间时,退格一次同时删除左右字符
|
||||
const resolvePairBackspace = (
|
||||
value: string,
|
||||
selection: EditorSelection
|
||||
@@ -174,7 +188,6 @@ const resolvePairBackspace = (
|
||||
};
|
||||
};
|
||||
|
||||
// 将当前选区替换为一对包裹字符,并把光标移动到合理位置
|
||||
const wrapSelection = (
|
||||
value: string,
|
||||
selection: EditorSelection,
|
||||
@@ -206,3 +219,11 @@ const getLineIndent = (value: string) => {
|
||||
return match?.[0] ?? "";
|
||||
};
|
||||
|
||||
function escapeHTML(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("\"", """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** 编辑器公共能力出口。平台组件只依赖这里的无界面逻辑和类型。 */
|
||||
export * from "./code";
|
||||
export * from "./search";
|
||||
export * from "./tiptap/shared";
|
||||
export * from "./tiptap/extensions";
|
||||
export * from "./tiptap/useTiptapEditor";
|
||||
export * from "./tiptap/useTiptapView";
|
||||
export { default as PrismCodeBlock } from "./tiptap/PrismCodeBlock";
|
||||
export { enhancePrismCodeBlock } from "./tiptap/PrismCodeBlockView";
|
||||
@@ -1,19 +1,23 @@
|
||||
/** 编辑器文本搜索、匹配和替换的无界面实现。 */
|
||||
export type TextSearchOptions = {
|
||||
caseSensitive: boolean;
|
||||
useRegex: boolean;
|
||||
};
|
||||
|
||||
/** 单个匹配项在原文本中的半开区间。 */
|
||||
export type TextSearchMatch = {
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
/** 搜索结果;error 为空表示搜索过程正常。 */
|
||||
export type TextSearchResult = {
|
||||
matches: TextSearchMatch[];
|
||||
error: string;
|
||||
};
|
||||
|
||||
/** 根据搜索选项构造正则标志。 */
|
||||
const buildFlags = (options: TextSearchOptions, global = true) => {
|
||||
let flags = global ? "g" : "";
|
||||
if (!options.caseSensitive) {
|
||||
@@ -22,10 +26,12 @@ const buildFlags = (options: TextSearchOptions, global = true) => {
|
||||
return flags;
|
||||
};
|
||||
|
||||
/** 转义普通文本搜索中的正则特殊字符。 */
|
||||
export const escapeSearchKeyword = (value: string) => (
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
);
|
||||
|
||||
/** 构造搜索正则,并拒绝无效或可匹配空文本的表达式。 */
|
||||
export const buildSearchRegex = (
|
||||
keyword: string,
|
||||
options: TextSearchOptions,
|
||||
@@ -53,6 +59,7 @@ export const buildSearchRegex = (
|
||||
}
|
||||
};
|
||||
|
||||
/** 查找全部匹配项并返回文本位置。 */
|
||||
export const findTextMatches = (
|
||||
value: string,
|
||||
keyword: string,
|
||||
@@ -87,6 +94,7 @@ export const findTextMatches = (
|
||||
};
|
||||
};
|
||||
|
||||
/** 替换单个匹配项;正则模式下使用捕获组替换规则。 */
|
||||
export const replaceMatchText = (
|
||||
matchText: string,
|
||||
keyword: string,
|
||||
@@ -103,6 +111,7 @@ export const replaceMatchText = (
|
||||
return matchText.replace(regex, replaceText);
|
||||
};
|
||||
|
||||
/** 替换全部匹配项,并返回实际替换数量。 */
|
||||
export const replaceAllText = (
|
||||
value: string,
|
||||
keyword: string,
|
||||
@@ -131,4 +140,3 @@ export const replaceAllText = (
|
||||
error: ""
|
||||
};
|
||||
};
|
||||
|
||||
+13
-1
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 带 Prism 装饰的 Tiptap 代码块。
|
||||
* ProseMirror 文档只保存纯文本,语法高亮通过 Decoration 叠加,避免污染编辑内容。
|
||||
*/
|
||||
import CodeBlock from "@tiptap/extension-code-block";
|
||||
import { findChildren, mergeAttributes } from "@tiptap/vue-3";
|
||||
import type { Node as ProsemirrorNode } from "@tiptap/pm/model";
|
||||
@@ -7,22 +11,26 @@ import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import Prism from "prismjs";
|
||||
import { resolvePrismLanguage } from "../../utils/Prism";
|
||||
|
||||
/** Prism 分词后用于生成 Decoration 的扁平节点。 */
|
||||
type HighlightNode = {
|
||||
text: string;
|
||||
classes: string[];
|
||||
};
|
||||
|
||||
/** Prism Token 的递归结构。 */
|
||||
type PrismToken = {
|
||||
type: string;
|
||||
alias?: string | string[];
|
||||
content: string | PrismToken | Array<string | PrismToken>;
|
||||
};
|
||||
|
||||
/** 用于判断事务影响范围的最小结构。 */
|
||||
type StepRange = {
|
||||
from?: number;
|
||||
to?: number;
|
||||
};
|
||||
|
||||
/** 将语言名称转换为代码块 class 名。 */
|
||||
function buildLanguageClassName(languageClassPrefix: string | null | undefined, language?: string | null) {
|
||||
if (!languageClassPrefix || !language) {
|
||||
return null;
|
||||
@@ -30,11 +38,13 @@ function buildLanguageClassName(languageClassPrefix: string | null | undefined,
|
||||
return `${languageClassPrefix}${language}`;
|
||||
}
|
||||
|
||||
/** 生成 Prism token 对应的 class 列表。 */
|
||||
function buildTokenClassList(type: string, alias?: string | string[]) {
|
||||
const aliasList = Array.isArray(alias) ? alias : alias ? [alias] : [];
|
||||
return ["token", type, ...aliasList];
|
||||
}
|
||||
|
||||
/** 将嵌套 Prism token 展平成文本片段,便于生成行内装饰。 */
|
||||
function parsePrismTokens(tokens: Array<string | PrismToken>, className: string[] = []): HighlightNode[] {
|
||||
return tokens.flatMap((token) => {
|
||||
if (typeof token === "string") {
|
||||
@@ -58,6 +68,7 @@ function parsePrismTokens(tokens: Array<string | PrismToken>, className: string[
|
||||
});
|
||||
}
|
||||
|
||||
/** 为文档中的全部代码块生成语法高亮装饰。 */
|
||||
function getDecorations({
|
||||
doc,
|
||||
name,
|
||||
@@ -89,6 +100,7 @@ function getDecorations({
|
||||
return DecorationSet.create(doc, decorations);
|
||||
}
|
||||
|
||||
/** 创建只在代码块内容或结构受影响时重新计算高亮的插件。 */
|
||||
function createPrismPlugin({
|
||||
name,
|
||||
defaultLanguage
|
||||
@@ -143,6 +155,7 @@ function createPrismPlugin({
|
||||
return prismPlugin;
|
||||
}
|
||||
|
||||
/** 自定义代码块渲染和 Prism 装饰插件。 */
|
||||
export default CodeBlock.extend({
|
||||
renderHTML({ node, HTMLAttributes }: { node: ProsemirrorNode; HTMLAttributes: Record<string, unknown> }) {
|
||||
const languageClassName = buildLanguageClassName(this.options.languageClassPrefix, node.attrs.language);
|
||||
@@ -171,4 +184,3 @@ export default CodeBlock.extend({
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 为只读代码块补充行号、折叠和展开交互。 */
|
||||
export function enhancePrismCodeBlock(root: ParentNode) {
|
||||
const codeList = Array.from(root.querySelectorAll("pre > code[class*='language-'], pre[class*='language-'] > code"));
|
||||
|
||||
@@ -35,6 +36,7 @@ export function enhancePrismCodeBlock(root: ParentNode) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 将代码节点上的语言 class 同步到外层 pre。 */
|
||||
function syncPreLanguageClass(pre: HTMLElement, code: HTMLElement) {
|
||||
const classList = Array.from(code.classList).filter((item) => item.startsWith("language-"));
|
||||
if (!classList.length) {
|
||||
@@ -47,6 +49,7 @@ function syncPreLanguageClass(pre: HTMLElement, code: HTMLElement) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除上一次增强生成的行号节点,允许重复增强。 */
|
||||
function resetCodeBlock(code: HTMLElement) {
|
||||
code.parentElement?.classList.remove("has-line-numbers");
|
||||
const codes = code.querySelector(":scope > .codes");
|
||||
@@ -55,6 +58,7 @@ function resetCodeBlock(code: HTMLElement) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 去除 Tiptap 代码块首尾的格式化换行。 */
|
||||
function normalizeCodeContent(code: HTMLElement) {
|
||||
const firstNode = code.firstChild;
|
||||
if (firstNode instanceof Text && firstNode.data.startsWith("\n")) {
|
||||
@@ -73,6 +77,7 @@ function normalizeCodeContent(code: HTMLElement) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 从最近的 data-max-height 容器读取代码块最大高度。 */
|
||||
function resolveCodeBlockMaxHeight(pre: HTMLElement) {
|
||||
const container = pre.closest("[data-max-height]");
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
@@ -81,6 +86,7 @@ function resolveCodeBlockMaxHeight(pre: HTMLElement) {
|
||||
return container.dataset.maxHeight || "";
|
||||
}
|
||||
|
||||
/** 计算代码块行数,空代码块至少显示一行。 */
|
||||
function buildLineCount(text: string) {
|
||||
if (!text) {
|
||||
return 1;
|
||||
@@ -88,6 +94,7 @@ function buildLineCount(text: string) {
|
||||
return Math.max(text.split("\n").length, 1);
|
||||
}
|
||||
|
||||
/** 创建与代码行数对应的行号 DOM。 */
|
||||
function buildLineNumberRows(lineCount: number) {
|
||||
const rows = document.createElement("span");
|
||||
rows.className = "line-numbers-rows diselect";
|
||||
@@ -99,6 +106,7 @@ function buildLineNumberRows(lineCount: number) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 为超过阈值的代码块绑定双击展开和恢复行为。 */
|
||||
function bindCodeBlockToggle(pre: HTMLElement, code: HTMLElement, lineCount: number, maxHeight: string) {
|
||||
pre.classList.remove("expand");
|
||||
pre.dataset.lineCount = `${lineCount}`;
|
||||
@@ -120,4 +128,3 @@ function bindCodeBlockToggle(pre: HTMLElement, code: HTMLElement, lineCount: num
|
||||
window.getSelection()?.removeAllRanges();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* Tiptap 扩展集合。
|
||||
* 图片、视频和 iframe 在这里统一定义尺寸及附件属性,保证编辑和只读视图使用同一套文档模型。
|
||||
*/
|
||||
import { mergeAttributes, Node } from "@tiptap/vue-3";
|
||||
import Color from "@tiptap/extension-color";
|
||||
import Image from "@tiptap/extension-image";
|
||||
@@ -17,10 +21,12 @@ import StarterKit from "@tiptap/starter-kit";
|
||||
import PrismCodeBlock from "./PrismCodeBlock";
|
||||
import { ATTACHMENT_ID_ATTR, TEMP_FILE_ID_ATTR } from "./shared";
|
||||
|
||||
/** 拼接非空样式声明。 */
|
||||
function buildStyleString(styleList: Array<string | null | undefined>) {
|
||||
return styleList.filter(Boolean).join("; ");
|
||||
}
|
||||
|
||||
/** 创建图片、视频和 iframe 共用的尺寸属性。 */
|
||||
function buildNodeSizeAttributes(defaultHeight = "auto") {
|
||||
return {
|
||||
width: {
|
||||
@@ -34,6 +40,7 @@ function buildNodeSizeAttributes(defaultHeight = "auto") {
|
||||
};
|
||||
}
|
||||
|
||||
/** 将节点原有样式与尺寸属性合并为 style 字符串。 */
|
||||
function buildNodeSizeStyle(attributes: Record<string, string>) {
|
||||
return buildStyleString([
|
||||
attributes.style,
|
||||
@@ -42,6 +49,7 @@ function buildNodeSizeStyle(attributes: Record<string, string>) {
|
||||
]);
|
||||
}
|
||||
|
||||
/** 扩展文字样式,补充背景色属性。 */
|
||||
const ExtTextStyle = TextStyle.extend({
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
@@ -67,6 +75,7 @@ const ExtTextStyle = TextStyle.extend({
|
||||
}
|
||||
});
|
||||
|
||||
/** 扩展图片节点,支持 base64、尺寸和附件属性。 */
|
||||
const ExtImage = Image.configure({
|
||||
allowBase64: true
|
||||
}).extend({
|
||||
@@ -111,6 +120,7 @@ const ExtImage = Image.configure({
|
||||
}
|
||||
});
|
||||
|
||||
/** 扩展视频节点,作为可拖拽的原子块保存。 */
|
||||
const ExtVideo = Node.create({
|
||||
name: "video",
|
||||
group: "block",
|
||||
@@ -176,6 +186,7 @@ const ExtVideo = Node.create({
|
||||
}
|
||||
});
|
||||
|
||||
/** 扩展 iframe 节点,限制为块级原子节点并保存尺寸。 */
|
||||
const ExtIframe = Node.create({
|
||||
name: "iframe",
|
||||
group: "block",
|
||||
@@ -221,10 +232,13 @@ const ExtIframe = Node.create({
|
||||
}
|
||||
});
|
||||
|
||||
/** 创建编辑器和只读视图共用的 Tiptap 扩展。 */
|
||||
export function buildTiptapExtensions(placeholder?: string): Extensions {
|
||||
return [
|
||||
StarterKit.configure({
|
||||
codeBlock: false
|
||||
codeBlock: false,
|
||||
link: false,
|
||||
underline: false
|
||||
}),
|
||||
PrismCodeBlock.configure({
|
||||
languageClassPrefix: "language-"
|
||||
@@ -256,4 +270,3 @@ export function buildTiptapExtensions(placeholder?: string): Extensions {
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/** Tiptap 编辑器共享类型、常量和数据归一化方法。 */
|
||||
import { h } from "vue";
|
||||
|
||||
/** 上传接口返回的媒体信息。 */
|
||||
export type UploadResult = {
|
||||
id: string;
|
||||
url: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
/** 编辑器待上传媒体的本地状态。 */
|
||||
export type UploadMediaType = "image" | "video";
|
||||
export type PendingMediaItem = {
|
||||
id: string;
|
||||
@@ -15,13 +18,16 @@ export type PendingMediaItem = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
/** 文本对齐方式。 */
|
||||
export type AlignType = "left" | "center" | "right" | "justify";
|
||||
|
||||
/** 富文本媒体节点使用的附件属性名。 */
|
||||
export const ATTACHMENT_ID_ATTR = "data-attachment-id";
|
||||
export const TEMP_FILE_ID_ATTR = "data-temp-file-id";
|
||||
export const TABLE_PICKER_SIZE = 7;
|
||||
export const LOCAL_MEDIA_ID_PREFIX = "local-media:";
|
||||
|
||||
/** 工具栏支持的标题选项。 */
|
||||
export const headingMeta = [
|
||||
{ label: "正文", value: "p", className: "p" },
|
||||
{ label: "标题 1", value: "h1", className: "h1" },
|
||||
@@ -32,6 +38,7 @@ export const headingMeta = [
|
||||
{ label: "标题 6", value: "h6", className: "h6" }
|
||||
] as const;
|
||||
|
||||
/** 将标题元数据转换为 TDesign 无关的菜单选项结构。 */
|
||||
export function buildHeadingOptions(currentHeadingValue: string) {
|
||||
return headingMeta.map((item) => {
|
||||
const active = currentHeadingValue === item.value;
|
||||
@@ -47,6 +54,7 @@ export function buildHeadingOptions(currentHeadingValue: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 清理空段落并统一 HTML 外层空白。 */
|
||||
export function normalizeContent(value?: string) {
|
||||
const text = (value || "").trim();
|
||||
if (!text || text === "<p></p>") {
|
||||
@@ -58,6 +66,7 @@ export function normalizeContent(value?: string) {
|
||||
return normalized === "<p></p>" ? "" : normalized;
|
||||
}
|
||||
|
||||
/** 将内边距转换为 0 到 16 rem 范围内的数值。 */
|
||||
export function normalizePaddingValue(value: unknown, fallback: number) {
|
||||
const nextValue = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(nextValue)) {
|
||||
@@ -66,16 +75,18 @@ export function normalizePaddingValue(value: unknown, fallback: number) {
|
||||
return Math.min(Math.max(nextValue, 0), 16);
|
||||
}
|
||||
|
||||
/** 兼容滑块组件返回单值或数组值。 */
|
||||
export function normalizeSliderValue(value: number | number[], fallback: number) {
|
||||
const nextValue = Array.isArray(value) ? value[0] : value;
|
||||
return normalizePaddingValue(nextValue, fallback);
|
||||
}
|
||||
|
||||
/** 将内边距数值格式化为 CSS rem 值。 */
|
||||
export function formatPaddingValue(value: number) {
|
||||
return `${Number(value.toFixed(2))}rem`;
|
||||
}
|
||||
|
||||
/** 判断媒体 ID 是否为尚未提交到服务器的本地 ID。 */
|
||||
export function isLocalMediaId(id?: string | null) {
|
||||
return !!id && id.startsWith(LOCAL_MEDIA_ID_PREFIX);
|
||||
}
|
||||
|
||||
+110
-15
@@ -1,8 +1,45 @@
|
||||
import type { Editor } from "@tiptap/vue-3";
|
||||
/** Tiptap 编辑器状态管理,封装内容同步、内边距同步和生命周期清理。 */
|
||||
import { EditorContent, useEditor } from "@tiptap/vue-3";
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
import { TextSelection } from "@tiptap/pm/state";
|
||||
import type { Component, ShallowRef } from "vue";
|
||||
import { resolveCodeEditorKeydown } from "../code";
|
||||
import { buildTiptapExtensions } from "./extensions";
|
||||
import { normalizeContent, normalizePaddingValue } from "./shared";
|
||||
|
||||
/** Tiptap 编辑器实例对外暴露的最小结构,避免平台声明文件依赖 ProseMirror 内部类型。 */
|
||||
type TiptapEditorTextNode = {
|
||||
isText: boolean;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
type TiptapEditorState = {
|
||||
selection: {
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
doc: {
|
||||
textBetween: (...args: any[]) => string;
|
||||
descendants: (callback: (node: TiptapEditorTextNode, pos: number) => boolean | void) => void;
|
||||
};
|
||||
tr: {
|
||||
insertText: (...args: any[]) => any;
|
||||
};
|
||||
};
|
||||
|
||||
export type TiptapEditorInstance = {
|
||||
readonly isFocused: boolean;
|
||||
readonly state: TiptapEditorState;
|
||||
readonly view: any;
|
||||
readonly commands: any;
|
||||
destroy: () => void;
|
||||
chain: (...args: any[]) => any;
|
||||
can: (...args: any[]) => any;
|
||||
isActive: (...args: any[]) => boolean;
|
||||
getAttributes: (...args: any[]) => Record<string, unknown>;
|
||||
getHTML: (...args: any[]) => string;
|
||||
};
|
||||
|
||||
type TiptapEditorProps = {
|
||||
modelValue: string;
|
||||
paddingTB?: number;
|
||||
@@ -10,14 +47,74 @@ type TiptapEditorProps = {
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
type UpdateModelValue = {
|
||||
type TiptapContentEmit = {
|
||||
(event: "update:modelValue", value: string): void;
|
||||
(event: "change", value: string): void;
|
||||
(event: "update:paddingTB", value: number): void;
|
||||
(event: "update:paddingLR", value: number): void;
|
||||
};
|
||||
|
||||
export function useTiptapEditor(props: TiptapEditorProps, emit: UpdateModelValue) {
|
||||
/** 处理代码块中的代码输入行为。 */
|
||||
function handleCodeBlockKeyDown(view: EditorView, event: KeyboardEvent) {
|
||||
const { $from, $to } = view.state.selection;
|
||||
if (!$from.sameParent($to) || $from.parent.type.name !== "codeBlock") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const code = $from.parent.textContent;
|
||||
const blockStart = $from.start($from.depth);
|
||||
const result = resolveCodeEditorKeydown(code, {
|
||||
start: view.state.selection.from - blockStart,
|
||||
end: view.state.selection.to - blockStart
|
||||
}, event);
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const transaction = view.state.tr.insertText(
|
||||
result.value,
|
||||
blockStart,
|
||||
blockStart + code.length
|
||||
);
|
||||
transaction.setSelection(TextSelection.create(
|
||||
transaction.doc,
|
||||
blockStart + result.start,
|
||||
blockStart + result.end
|
||||
));
|
||||
view.dispatch(transaction);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 处理 Tiptap 编辑器中的 Tab,避免焦点离开并插入制表符。 */
|
||||
function handleTiptapKeyDown(view: EditorView, event: KeyboardEvent) {
|
||||
if (handleCodeBlockKeyDown(view, event)) {
|
||||
return true;
|
||||
}
|
||||
if (event.key !== "Tab" || event.isComposing || event.ctrlKey || event.altKey || event.metaKey) {
|
||||
return false;
|
||||
}
|
||||
if (!view.state.selection.$from.parent.isTextblock) {
|
||||
return true;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
view.dispatch(view.state.tr.insertText("\t"));
|
||||
return true;
|
||||
}
|
||||
|
||||
type TiptapPaddingUpdate = (
|
||||
event: "update:paddingTB" | "update:paddingLR",
|
||||
value: number
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* 创建可编辑的 Tiptap 实例。
|
||||
* 外部内容只在编辑器未聚焦时回灌,代码块输入期间延迟同步,避免光标跳动。
|
||||
*/
|
||||
export function useTiptapEditor(
|
||||
props: TiptapEditorProps,
|
||||
emit: TiptapContentEmit,
|
||||
onPaddingUpdate?: TiptapPaddingUpdate
|
||||
) {
|
||||
const articlePadding = reactive({
|
||||
paddingTB: normalizePaddingValue(props.paddingTB, 1),
|
||||
paddingLR: normalizePaddingValue(props.paddingLR, 1.25)
|
||||
@@ -25,7 +122,7 @@ export function useTiptapEditor(props: TiptapEditorProps, emit: UpdateModelValue
|
||||
const lastEmittedContent = ref("");
|
||||
const pendingEmitContent = ref<string | null>(null);
|
||||
|
||||
// 代码块输入期间只在编辑器内部累积,离开代码块或失焦后再统一同步
|
||||
// 代码块输入期间只在编辑器内部累积,离开代码块或失焦后再统一同步。
|
||||
function flushPendingContent() {
|
||||
if (pendingEmitContent.value === null) {
|
||||
return;
|
||||
@@ -59,7 +156,8 @@ export function useTiptapEditor(props: TiptapEditorProps, emit: UpdateModelValue
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "editor-body tp-content-body tp-editor-body"
|
||||
}
|
||||
},
|
||||
handleKeyDown: handleTiptapKeyDown
|
||||
},
|
||||
onBlur: () => {
|
||||
flushPendingContent();
|
||||
@@ -81,7 +179,7 @@ export function useTiptapEditor(props: TiptapEditorProps, emit: UpdateModelValue
|
||||
emit("change", nextContent);
|
||||
emit("update:modelValue", nextContent);
|
||||
}
|
||||
});
|
||||
}) as unknown as ShallowRef<TiptapEditorInstance | undefined>;
|
||||
|
||||
watch(() => props.modelValue, (value) => {
|
||||
if (!editor.value) {
|
||||
@@ -96,7 +194,7 @@ export function useTiptapEditor(props: TiptapEditorProps, emit: UpdateModelValue
|
||||
if (nextContent === lastEmittedContent.value) {
|
||||
return;
|
||||
}
|
||||
// 聚焦编辑时禁止外部回灌,避免重建选区后把光标拉回开头
|
||||
// 聚焦编辑时禁止外部回灌,避免重建选区后把光标拉回开头。
|
||||
if (editor.value.isFocused) {
|
||||
return;
|
||||
}
|
||||
@@ -112,11 +210,11 @@ export function useTiptapEditor(props: TiptapEditorProps, emit: UpdateModelValue
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => articlePadding.paddingTB, (value) => {
|
||||
emit("update:paddingTB", value);
|
||||
onPaddingUpdate?.("update:paddingTB", value);
|
||||
});
|
||||
|
||||
watch(() => articlePadding.paddingLR, (value) => {
|
||||
emit("update:paddingLR", value);
|
||||
onPaddingUpdate?.("update:paddingLR", value);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -124,12 +222,9 @@ export function useTiptapEditor(props: TiptapEditorProps, emit: UpdateModelValue
|
||||
});
|
||||
|
||||
return {
|
||||
EditorContent,
|
||||
EditorContent: EditorContent as unknown as Component,
|
||||
articlePadding,
|
||||
editor,
|
||||
editorBodyStyle
|
||||
};
|
||||
}
|
||||
|
||||
export type TiptapEditorInstance = Editor;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/** 创建只读 Tiptap 视图,并在内容变化后重新增强代码块。 */
|
||||
import { EditorContent, useEditor } from "@tiptap/vue-3";
|
||||
import type { Component, ShallowRef } from "vue";
|
||||
import { buildTiptapExtensions } from "./extensions";
|
||||
import { normalizeContent } from "./shared";
|
||||
import { enhancePrismCodeBlock } from "./PrismCodeBlockView";
|
||||
import type { TiptapEditorInstance } from "./useTiptapEditor";
|
||||
|
||||
type StringRef = {
|
||||
readonly value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化只读编辑器。
|
||||
* 编辑器负责解析文档,DOM 增强负责行号和折叠,两者分开以便重复同步。
|
||||
*/
|
||||
export function useTiptapView(content: StringRef, maxHeight: StringRef) {
|
||||
const contentRef = ref<HTMLElement>();
|
||||
const editor = useEditor({
|
||||
editable: false,
|
||||
extensions: buildTiptapExtensions(),
|
||||
content: content.value || "<p></p>",
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "editor-body tp-content-body"
|
||||
}
|
||||
}
|
||||
}) as unknown as ShallowRef<TiptapEditorInstance | undefined>;
|
||||
|
||||
async function syncCodeBlock() {
|
||||
await nextTick();
|
||||
if (contentRef.value) {
|
||||
enhancePrismCodeBlock(contentRef.value);
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => [content.value, maxHeight.value] as const, ([value]) => {
|
||||
if (!editor.value) {
|
||||
return;
|
||||
}
|
||||
editor.value.commands.setContent(value || "<p></p>", {
|
||||
emitUpdate: false
|
||||
});
|
||||
void syncCodeBlock();
|
||||
}, { immediate: true });
|
||||
|
||||
watch(editor, () => {
|
||||
void syncCodeBlock();
|
||||
}, { flush: "post" });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor.value?.destroy();
|
||||
});
|
||||
|
||||
return {
|
||||
EditorContent: EditorContent as unknown as Component,
|
||||
contentRef,
|
||||
editor,
|
||||
normalizeContent
|
||||
};
|
||||
}
|
||||
+1
-5
@@ -6,18 +6,14 @@ import "./assets/style/variable.less";
|
||||
import "./assets/style/timi-web.less";
|
||||
import "prismjs/themes/prism.css";
|
||||
import "./components/prism-code-block.less";
|
||||
import "./components/tiptap-content.less";
|
||||
import { Network } from "./utils";
|
||||
import VPopup from "./utils/directives/Popup";
|
||||
|
||||
export * from "./api";
|
||||
export * from "./components";
|
||||
export * from "./editor";
|
||||
export * from "./store";
|
||||
export * from "./types";
|
||||
export * from "./utils";
|
||||
|
||||
export const axios = Network.axios;
|
||||
|
||||
const install = function (app: App) {
|
||||
components.forEach(component => {
|
||||
app.use(component as unknown as { install: () => any });
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
export * from "./deviceStore";
|
||||
export * from "./userStore";
|
||||
export * from "./windowStore";
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
import {defineStore} from "pinia";
|
||||
import {computed, reactive, readonly, ref} from "vue";
|
||||
import {CaptchaData, LoginRequest, LoginResponse, LoginUser} from "../types";
|
||||
import Cooker from "../utils/Cooker";
|
||||
import Storage from "../utils/Storage";
|
||||
import {UserAPI} from "../api";
|
||||
|
||||
/** 用户 Store 配置项 */
|
||||
interface UserStoreOptions {
|
||||
/** localStorage 储存 key,默认 "loginUser" */
|
||||
storageKey?: string;
|
||||
}
|
||||
|
||||
/** 访问控制匹配模式 */
|
||||
export type AccessMatchMode = "and" | "or";
|
||||
|
||||
let _storageKey = "loginUser";
|
||||
|
||||
type AccessItem = string | {
|
||||
moduleCode?: string;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
function normalizeAccessItem(item?: AccessItem): string {
|
||||
if (!item) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof item === "string") {
|
||||
return item.trim();
|
||||
}
|
||||
|
||||
const code = item.code?.trim();
|
||||
if (!code) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const moduleCode = item.moduleCode?.trim();
|
||||
return moduleCode && !code.includes(":") ? `${moduleCode}:${code}` : code;
|
||||
}
|
||||
|
||||
function normalizeAccessList(list?: AccessItem[]): string[] {
|
||||
if (!list?.length) {
|
||||
return [];
|
||||
}
|
||||
return list
|
||||
.map(normalizeAccessItem)
|
||||
.filter((item): item is string => !!item);
|
||||
}
|
||||
|
||||
function matchAccess(sourceList?: string[], targetList?: string | string[], mode: AccessMatchMode = "or"): boolean {
|
||||
const normalizedTargetList = normalizeAccessList(Array.isArray(targetList) ? targetList : [targetList || ""]);
|
||||
if (!normalizedTargetList.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const sourceSet = new Set(normalizeAccessList(sourceList));
|
||||
if (!sourceSet.size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mode === "and") {
|
||||
return normalizedTargetList.every(item => sourceSet.has(item));
|
||||
}
|
||||
return normalizedTargetList.some(item => sourceSet.has(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置用户 Store,应在使用 useUserStore 之前调用
|
||||
* @param options 配置项
|
||||
*/
|
||||
function configureUserStore(options: UserStoreOptions): void {
|
||||
if (options.storageKey) {
|
||||
_storageKey = options.storageKey;
|
||||
}
|
||||
}
|
||||
|
||||
const useUserStore = defineStore("user", () => {
|
||||
type LoginUserState = LoginUser & Record<string, unknown>;
|
||||
|
||||
const loginUser = reactive<LoginUserState>({});
|
||||
const isRestored = ref(false);
|
||||
|
||||
const token = computed(() => loginUser.token ?? "");
|
||||
const isLoggedIn = computed(() => isValidLoginUser(loginUser));
|
||||
|
||||
function resetLoginUser(nextUser?: LoginUserState): void {
|
||||
Object.keys(loginUser).forEach((key) => {
|
||||
delete loginUser[key];
|
||||
});
|
||||
if (nextUser) {
|
||||
Object.assign(loginUser, nextUser);
|
||||
}
|
||||
}
|
||||
|
||||
function isValidLoginUser(user?: LoginUserState | null): user is LoginResponse {
|
||||
return !!user?.token && !!user.expireAt && new Date().getTime() < user.expireAt;
|
||||
}
|
||||
|
||||
function isLogged(): boolean {
|
||||
return isLoggedIn.value;
|
||||
}
|
||||
|
||||
function getLoginUser<T extends LoginUser = LoginUser>(): T | null {
|
||||
if (!isLogged()) {
|
||||
return null;
|
||||
}
|
||||
return loginUser as T;
|
||||
}
|
||||
|
||||
function getRoleList(): string[] {
|
||||
return normalizeAccessList(loginUser.user?.roleList);
|
||||
}
|
||||
|
||||
function getPermissionList(): string[] {
|
||||
return normalizeAccessList(loginUser.user?.permissionList);
|
||||
}
|
||||
|
||||
function hasRoles(roleList: string | string[], mode: AccessMatchMode = "or"): boolean {
|
||||
return matchAccess(getRoleList(), roleList, mode);
|
||||
}
|
||||
|
||||
function hasPermissions(permissionList: string | string[], mode: AccessMatchMode = "or"): boolean {
|
||||
return matchAccess(getPermissionList(), permissionList, mode);
|
||||
}
|
||||
|
||||
async function login<T extends LoginUser = LoginUser>(req: CaptchaData<LoginRequest>): Promise<T> {
|
||||
const result = await UserAPI.login(req);
|
||||
const nextUser = result as unknown as T;
|
||||
await updateLoginUser(nextUser);
|
||||
return nextUser;
|
||||
}
|
||||
|
||||
async function loginByToken<T extends LoginUser = LoginUser>(token: string): Promise<T | null> {
|
||||
loginUser.token = token;
|
||||
// 未过期
|
||||
try {
|
||||
const result = await UserAPI.loginByToken();
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
const nextUser = result as unknown as T;
|
||||
await updateLoginUser(nextUser);
|
||||
return nextUser;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
clearLoginUser();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loginByStorage<T extends LoginUser = LoginUser>(): Promise<T | null> {
|
||||
if (loginUser.token) {
|
||||
isRestored.value = true;
|
||||
return getLoginUser<T>();
|
||||
}
|
||||
|
||||
if (!Storage.has(_storageKey)) {
|
||||
isRestored.value = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
// 未登录,储存有令牌。
|
||||
const storageUser = Storage.getJSON(_storageKey) as T;
|
||||
if (isValidLoginUser(storageUser)) {
|
||||
const result = await loginByToken<T>(storageUser.token);
|
||||
isRestored.value = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
clearLoginUser();
|
||||
isRestored.value = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function updateLoginUser<T extends LoginUser = LoginUser>(user: T): Promise<void> {
|
||||
resetLoginUser(user as LoginUserState);
|
||||
Storage.setJSON(_storageKey, user);
|
||||
}
|
||||
|
||||
function clearLoginUser(): void {
|
||||
resetLoginUser();
|
||||
Cooker.remove("Token");
|
||||
Storage.remove(_storageKey);
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await UserAPI.logout();
|
||||
} finally {
|
||||
clearLoginUser();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
isLoggedIn,
|
||||
isRestored,
|
||||
getRoleList,
|
||||
getPermissionList,
|
||||
hasRoles,
|
||||
hasPermissions,
|
||||
isLogged,
|
||||
loginUser: readonly(loginUser),
|
||||
getLoginUser,
|
||||
login,
|
||||
loginByToken,
|
||||
loginByStorage,
|
||||
updateLoginUser,
|
||||
clearLoginUser,
|
||||
logout
|
||||
};
|
||||
});
|
||||
|
||||
export {
|
||||
useUserStore,
|
||||
configureUserStore
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
/** 窗口级通用状态操作。 */
|
||||
function setTitle(title?: string) {
|
||||
window.document.title = title || "";
|
||||
}
|
||||
|
||||
const windowStore = {
|
||||
setTitle
|
||||
};
|
||||
|
||||
export {
|
||||
windowStore
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { Attachment } from "./Attachment";
|
||||
import type { Comment } from "./Comment";
|
||||
import type { Model } from "./Model";
|
||||
import type { Setting } from "./Setting";
|
||||
|
||||
/** 文章业务类型 */
|
||||
export type ArticleBizType = "BLOG" | "FEEDBACK";
|
||||
|
||||
/** 文章正文类型 */
|
||||
export type ArticleContentType = "TEXT" | "MARKDOWN" | "TIP_TAP";
|
||||
|
||||
/** 文章状态 */
|
||||
export type ArticleStatus = "DART" | "NORMAL" | "HIDDEN";
|
||||
|
||||
/** 文章实体 */
|
||||
export type Article = {
|
||||
/** 业务类型 */
|
||||
bizType?: ArticleBizType;
|
||||
|
||||
/** 业务 ID */
|
||||
bizId?: string;
|
||||
|
||||
/** 标题 */
|
||||
title?: string;
|
||||
|
||||
/** 描述 */
|
||||
description?: string;
|
||||
|
||||
/** 正文类型 */
|
||||
contentType?: ArticleContentType;
|
||||
|
||||
/** 正文内容 */
|
||||
content?: string;
|
||||
|
||||
/** 阅读数量 */
|
||||
reads?: number;
|
||||
|
||||
/** 喜欢数量 */
|
||||
likes?: number;
|
||||
|
||||
/** 文章状态 */
|
||||
status?: ArticleStatus;
|
||||
|
||||
/** 设置列表 */
|
||||
settingList?: Setting[];
|
||||
|
||||
/** 评论列表 */
|
||||
commentList?: Comment[];
|
||||
|
||||
/** 附件列表 */
|
||||
attachmentList?: Attachment[];
|
||||
} & Model;
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Model } from "./Model";
|
||||
|
||||
export type Attachment = {
|
||||
bizType?: AttachmentBizType;
|
||||
bizId?: string;
|
||||
attachType?: string;
|
||||
mongoId?: string;
|
||||
title?: string;
|
||||
name?: string;
|
||||
mimeType?: string;
|
||||
metadata?: Record<string, any>;
|
||||
accessKey?: AttachmentAccessKey;
|
||||
size?: number;
|
||||
md5?: string;
|
||||
uploaderIp?: string;
|
||||
isDestroyed?: boolean;
|
||||
destroyAt?: number;
|
||||
tempFileId?: string;
|
||||
} & Model;
|
||||
|
||||
export type TempFile = {
|
||||
id: string;
|
||||
expireAt?: number;
|
||||
};
|
||||
|
||||
export type BizIdUpdate = {
|
||||
bizType: AttachmentBizType;
|
||||
bizId: string;
|
||||
items: Partial<Attachment>[];
|
||||
};
|
||||
|
||||
export type AttachmentBizType =
|
||||
| "ARTICLE"
|
||||
| "USER"
|
||||
| "GAO_CUSTOMER"
|
||||
| "GAO_EVENT_RECORD"
|
||||
| "GAO_REGISTER_RECORD"
|
||||
| "TEMP_FILE"
|
||||
| "MIRROR";
|
||||
|
||||
/** 附件访问密钥 */
|
||||
export type AttachmentAccessKey = {
|
||||
value: string;
|
||||
attachmentId: string;
|
||||
isDisposable: boolean;
|
||||
expiredAt: number;
|
||||
createdAt: number;
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Model, Page } from "./Model";
|
||||
import { User } from "./User";
|
||||
|
||||
/** 评论 */
|
||||
export type Comment = {
|
||||
bizType?: CommentBizType;
|
||||
bizId?: number;
|
||||
userId?: number;
|
||||
nick?: string;
|
||||
content?: string;
|
||||
|
||||
/** 所属用户 */
|
||||
user?: User;
|
||||
|
||||
/** 回复列表 */
|
||||
replies?: Comment[];
|
||||
|
||||
/** 回复分页 */
|
||||
repliesPage?: Page<Comment>;
|
||||
|
||||
/** 用于绑定组件当前页下标 */
|
||||
repliesCurrent?: number;
|
||||
|
||||
/** 回复数量 */
|
||||
repliesLength?: number;
|
||||
|
||||
/** 关联文章 */
|
||||
article?: object;
|
||||
|
||||
/** 关联 Git 仓库 */
|
||||
repository?: object;
|
||||
} & Model
|
||||
|
||||
export type CommentBizType = "ARTICLE" | "GIT_ISSUE" | "GIT_MERGE";
|
||||
@@ -1,6 +0,0 @@
|
||||
/** 开发者配置 */
|
||||
export type Developer = {
|
||||
developerId?: number;
|
||||
name?: string;
|
||||
rsa?: string;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// 基本实体模型
|
||||
export type Model = {
|
||||
id?: string;
|
||||
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
deletedAt?: number;
|
||||
}
|
||||
|
||||
export type ApiResponse<T> = {
|
||||
code: number;
|
||||
message: string;
|
||||
messageCode: string;
|
||||
success: boolean;
|
||||
fail: boolean;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export type Page<T> = {
|
||||
orderMap?: { [K in keyof T]?: OrderType };
|
||||
equalsExample?: Partial<T>;
|
||||
likesExample?: Partial<T>;
|
||||
} & BasePage;
|
||||
|
||||
export type BasePage = {
|
||||
index: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type OrderType = "ASC" | "DESC";
|
||||
|
||||
export type PageResult<T> = {
|
||||
total: number;
|
||||
list: T[];
|
||||
}
|
||||
|
||||
// 携带验证码的请求体
|
||||
export type CaptchaData<T> = {
|
||||
captchaId: string;
|
||||
captcha: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// 图形验证码
|
||||
export type CaptchaResult = {
|
||||
id: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export type ImageType = "IR-AUTO" | "IR-SMOOTH" | "IR-PIXELATED";
|
||||
|
||||
export type KeyValue<V, K = string> = {
|
||||
key: K;
|
||||
value?: V;
|
||||
}
|
||||
|
||||
export type LabelValue<L, V = string> = {
|
||||
label: L;
|
||||
value?: V;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { Model } from "./Model";
|
||||
import type { User } from "./User";
|
||||
|
||||
/** 通知消息类型 */
|
||||
export type NotifyMsgType = "INTERNAL" | "SMS" | "MAIL" | "HTTP" | "WECHAT";
|
||||
|
||||
/** 通知投递状态 */
|
||||
export type NotifyStatus = "WAITING" | "REMINDED" | "TIMEOUT" | "CANCEL" | "FAIL";
|
||||
|
||||
/** 通知 */
|
||||
export type Notify = {
|
||||
/** 业务类型 */
|
||||
bizType?: string;
|
||||
/** 提醒业务类型 */
|
||||
bizRemindedType?: string;
|
||||
/** 提醒业务 ID */
|
||||
bizRemindedId?: string;
|
||||
/** 参数 */
|
||||
args?: string;
|
||||
/** 创建者 */
|
||||
createdBy?: string;
|
||||
/** 通知详情列表 */
|
||||
detailList?: NotifyDetail[];
|
||||
} & Model;
|
||||
|
||||
/** 通知详情 */
|
||||
export type NotifyDetail = {
|
||||
/** 所属通知 ID */
|
||||
notifyId?: string;
|
||||
/** 消息类型 */
|
||||
msgType?: NotifyMsgType;
|
||||
/** 模板 ID */
|
||||
templateId?: string;
|
||||
/** 标题 */
|
||||
subject?: string;
|
||||
/** 内容 */
|
||||
data?: string;
|
||||
/** 参数 */
|
||||
args?: string;
|
||||
/** 发送来源 */
|
||||
sendFrom?: string;
|
||||
/** 发送去向 */
|
||||
sendTo?: string;
|
||||
/** 发送时间 */
|
||||
sendAt?: number;
|
||||
/** 重试计数 */
|
||||
retry?: number;
|
||||
/** 超时时间 */
|
||||
timeoutAt?: number;
|
||||
/** 结果描述 */
|
||||
resultDesc?: string;
|
||||
/** 投递状态 */
|
||||
status?: NotifyStatus;
|
||||
/** 已读时间,站内通知未读时为空 */
|
||||
readAt?: number;
|
||||
/** 所属通知 */
|
||||
notify?: Notify;
|
||||
/** 发送用户 */
|
||||
fromUser?: User;
|
||||
/** 接收用户 */
|
||||
toUser?: User;
|
||||
} & Model;
|
||||
@@ -1,24 +0,0 @@
|
||||
export type ModuleCode = "CORE" | "GAO";
|
||||
|
||||
export interface Permission {
|
||||
id: string;
|
||||
moduleCode: ModuleCode;
|
||||
code: string;
|
||||
nameLangId?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
builtin?: boolean;
|
||||
protectedPermission?: boolean;
|
||||
ownerType?: AuthOwnerType;
|
||||
ownerId?: string;
|
||||
createdBy?: string;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
deletedAt?: number;
|
||||
}
|
||||
|
||||
export type AuthOwnerType = "SYSTEM" | "MODULE" | "TENANT" | "STORE" | "USER";
|
||||
|
||||
export type PermissionPayload = Pick<Permission, "moduleCode" | "code" | "nameLangId" | "name" | "description"> & {
|
||||
id?: string;
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { AuthOwnerType, ModuleCode, Permission } from "./Permission";
|
||||
import { Model } from "./Model";
|
||||
|
||||
export type Role = {
|
||||
moduleCode: ModuleCode;
|
||||
code: string;
|
||||
nameLangId?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
builtin?: boolean;
|
||||
protectedRole?: boolean;
|
||||
parentRoleId?: string;
|
||||
ownerType?: AuthOwnerType;
|
||||
ownerId?: string;
|
||||
createdBy?: string;
|
||||
|
||||
permissionList?: Permission[];
|
||||
allPermissionList?: Permission[];
|
||||
childRoleList?: Role[];
|
||||
parentRole?: Role;
|
||||
delegationPermissionList?: Permission[];
|
||||
permissionIdList?: string[];
|
||||
childRoleIdList?: string[];
|
||||
delegationPermissionIdList?: string[];
|
||||
} & Model;
|
||||
|
||||
export type RolePayload = Partial<Pick<Role, "moduleCode" | "code" | "nameLangId" | "name" | "description">> & {
|
||||
id?: string;
|
||||
parentRoleId?: string;
|
||||
ownerType?: AuthOwnerType;
|
||||
ownerId?: string;
|
||||
permissionIdList?: string[];
|
||||
childRoleIdList?: string[];
|
||||
delegationPermissionIdList?: string[];
|
||||
};
|
||||
|
||||
export interface AuthorizeUserRole {
|
||||
userId: string;
|
||||
moduleCode?: ModuleCode;
|
||||
roleIdList?: string[];
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import {Model} from "./Model";
|
||||
|
||||
export type SettingValueType =
|
||||
| "STRING"
|
||||
| "SELECT_RADIO"
|
||||
| "SELECT_CHECKBOX"
|
||||
| "INTEGER"
|
||||
| "COLOR"
|
||||
| "FLOAT"
|
||||
| "DATE"
|
||||
| "DATETIME"
|
||||
| "TIME"
|
||||
| "DURATION"
|
||||
| "BOOLEAN"
|
||||
| "JSON_LIST"
|
||||
| "JSON_OBJECT"
|
||||
;
|
||||
|
||||
export type Setting = {
|
||||
module: string;
|
||||
key: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
value?: string;
|
||||
valueType: SettingValueType;
|
||||
valueArgs?: any;
|
||||
} & Model;
|
||||
@@ -1,83 +0,0 @@
|
||||
import { ImageType, Model } from "./Model";
|
||||
import { Attachment } from "./Attachment";
|
||||
import type { Setting } from "./Setting";
|
||||
import { Role } from "./Role";
|
||||
|
||||
export type Gender = "MALE" | "FEMALE";
|
||||
|
||||
export type User = {
|
||||
name: string;
|
||||
nick?: string;
|
||||
password?: string;
|
||||
email?: string;
|
||||
emailVerifyAt?: number;
|
||||
phoneNo?: string;
|
||||
phoneNoVerifyAt?: number;
|
||||
wrapperType?: ImageType;
|
||||
avatarType?: ImageType;
|
||||
exp?: number;
|
||||
gender?: Gender;
|
||||
birthdate?: number;
|
||||
qq?: string;
|
||||
description?: string;
|
||||
lastLoginIP?: string;
|
||||
lastLoginAt?: number;
|
||||
unmuteAt?: number;
|
||||
unbanAt?: number;
|
||||
|
||||
roleList?: string[];
|
||||
roleEntityList?: Role[];
|
||||
permissionList?: string[];
|
||||
attachmentList?: Attachment[];
|
||||
settingList?: Setting[];
|
||||
tempFileIdList?: string[];
|
||||
muting?: boolean;
|
||||
banning?: boolean;
|
||||
emailVerified?: boolean;
|
||||
phoneNoVerified?: boolean;
|
||||
} & Model;
|
||||
|
||||
export type UserAttachType = "AVATAR" | "WRAPPER";
|
||||
|
||||
export type RegisterRequest = {
|
||||
name: string;
|
||||
password: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export type LoginRequest = {
|
||||
user: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type SmsLoginRequest = {
|
||||
detailId: string;
|
||||
captcha: string;
|
||||
};
|
||||
|
||||
export type UpdatePasswordRequest = {
|
||||
oldValue: string;
|
||||
newValue: string;
|
||||
};
|
||||
|
||||
// 登录返回
|
||||
export type LoginResponse = {
|
||||
id: string;
|
||||
token: string;
|
||||
expireAt: number;
|
||||
user?: User;
|
||||
};
|
||||
|
||||
export type LoginUser = {
|
||||
id?: string;
|
||||
token?: string;
|
||||
expireAt?: number;
|
||||
user?: User;
|
||||
};
|
||||
|
||||
export type UserLevelType = {
|
||||
exp: number; // 经验数值,和 UserData.exp 一样
|
||||
value: number; // 经验对应等级,[0, 8]
|
||||
percent: number; // 该经验在该等级的百分比 [0, 1]
|
||||
nextLevelUp: number; // 下一级经验值
|
||||
}
|
||||
@@ -1,17 +1,5 @@
|
||||
import type { Plugin } from "vue";
|
||||
|
||||
export * from "./Model";
|
||||
export * from "./Article";
|
||||
export * from "./Setting";
|
||||
export * from "./Attachment";
|
||||
export * from "./Model";
|
||||
export * from "./Permission";
|
||||
export * from "./Role";
|
||||
export * from "./User";
|
||||
export * from "./Comment";
|
||||
export * from "./Developer";
|
||||
export * from "./Notify";
|
||||
|
||||
/**
|
||||
* 安装方法
|
||||
*/
|
||||
@@ -29,27 +17,3 @@ 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>>>>
|
||||
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
import type { Directive } from "vue";
|
||||
import type { PopupConfig } from "../utils/directives/Popup";
|
||||
|
||||
declare module "vue" {
|
||||
export interface GlobalDirectives {
|
||||
popup: Directive<HTMLElement, string | PopupConfig>;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* ### 全局事件管理
|
||||
*
|
||||
* ```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;
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import Text from "./Text";
|
||||
|
||||
export const UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB"] as const;
|
||||
export type Unit = (typeof UNITS)[number];
|
||||
|
||||
/** 储存单位 */
|
||||
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;
|
||||
|
||||
/**
|
||||
* <p>格式化一个储存容量,保留两位小数
|
||||
* <pre>
|
||||
* // 返回 100.01 KB
|
||||
* format(102411, 2);
|
||||
* </pre>
|
||||
*
|
||||
* @param size 字节大小
|
||||
* @param fixed 保留小数
|
||||
* @param stopUnit 停止单位
|
||||
* @return
|
||||
*/
|
||||
public static format(size?: number | null, fixed = 2, stopUnit?: Unit): string {
|
||||
if (size === undefined || size === null) {
|
||||
return "0 B";
|
||||
}
|
||||
const units = Object.values(UNITS);
|
||||
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";
|
||||
}
|
||||
|
||||
/**
|
||||
* 将格式化的储存量字符串解析为字节量
|
||||
* <p>支持格式:10GB, 10 GB, 1TB, 1.24 KB 等(单位不区分大小写)</p>
|
||||
* <pre>
|
||||
* // 返回 102400
|
||||
* IOSize.parse("100 KB");
|
||||
* // 返回 1073741824
|
||||
* IOSize.parse("1GB");
|
||||
* </pre>
|
||||
*
|
||||
* @param sizeStr 格式化后的储存量字符串
|
||||
* @return 字节量
|
||||
* @throws Error 格式无效
|
||||
*/
|
||||
public static parse(sizeStr?: string | null): number {
|
||||
if (sizeStr === undefined || sizeStr === null || sizeStr.trim() === "") {
|
||||
return 0;
|
||||
}
|
||||
// 正则匹配:可选符号 + 数字(含小数点)+ 可选空格 + 单位
|
||||
const pattern = /([-+]?\d+(?:\.\d+)?)\s*([a-zA-Z]+)/;
|
||||
const matcher = pattern.exec(sizeStr.trim());
|
||||
if (matcher) {
|
||||
let value: number;
|
||||
try {
|
||||
value = parseFloat(matcher[1]);
|
||||
} catch (e) {
|
||||
throw new Error("invalid number format: " + matcher[1]);
|
||||
}
|
||||
if (value < 0) {
|
||||
throw new Error("size cannot be negative: " + value);
|
||||
}
|
||||
const unitStr = matcher[2].toUpperCase();
|
||||
let unit: Unit;
|
||||
|
||||
// 先尝试精确匹配枚举
|
||||
if (Object.values(UNITS).includes(unitStr as Unit)) {
|
||||
unit = unitStr as Unit;
|
||||
} else {
|
||||
// 处理单字母单位缩写(K/M/G/T/P/E)
|
||||
switch (unitStr.charAt(0)) {
|
||||
case "K":
|
||||
unit = "KB";
|
||||
break;
|
||||
case "M":
|
||||
unit = "MB";
|
||||
break;
|
||||
case "G":
|
||||
unit = "GB";
|
||||
break;
|
||||
case "T":
|
||||
unit = "TB";
|
||||
break;
|
||||
case "P":
|
||||
unit = "PB";
|
||||
break;
|
||||
case "E":
|
||||
unit = "EB";
|
||||
break;
|
||||
case "B":
|
||||
unit = "B";
|
||||
break;
|
||||
default:
|
||||
throw new Error("Unknown unit: " + unitStr);
|
||||
}
|
||||
}
|
||||
return IOSize.toBytes(value, unit);
|
||||
}
|
||||
// 尝试解析纯数字(无单位,默认字节)
|
||||
try {
|
||||
const bytes = parseInt(sizeStr.trim());
|
||||
if (bytes < 0) {
|
||||
throw new Error("size cannot be negative: " + bytes);
|
||||
}
|
||||
return bytes;
|
||||
} catch (e) {
|
||||
throw new Error("invalid size format: " + sizeStr);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换值到字节量
|
||||
*
|
||||
* @param val 值
|
||||
* @param unit 单位
|
||||
* @return 字节量
|
||||
*/
|
||||
private static toBytes(val: number | undefined | null, unit: Unit): number {
|
||||
if (val === undefined || val === null) {
|
||||
return 0;
|
||||
}
|
||||
const units = Object.values(UNITS);
|
||||
const ordinal = units.indexOf(unit);
|
||||
return Math.round(val * Math.pow(1024, ordinal));
|
||||
}
|
||||
|
||||
public static speed(val?: number | null) {
|
||||
if (val === undefined || val === null) {
|
||||
return "";
|
||||
}
|
||||
return Text.unit(IOSize.format(val, 2), " / s");
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import axios, {type AxiosInstance, type AxiosResponse, type InternalAxiosRequestConfig} from "axios";
|
||||
import {ApiResponse} from "../types";
|
||||
|
||||
export type ErrorCallback = (response: ApiResponse<any>) => void;
|
||||
export type RequestInterceptor = (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig;
|
||||
export interface ResponseObserverPayload {
|
||||
config?: InternalAxiosRequestConfig;
|
||||
status?: number;
|
||||
response?: AxiosResponse;
|
||||
error?: any;
|
||||
serverResponse?: ApiResponse<any>;
|
||||
}
|
||||
export type ResponseObserver = (payload: ResponseObserverPayload) => void;
|
||||
|
||||
let globalErrorCallback: ErrorCallback | null = null;
|
||||
let globalRequestInterceptor: RequestInterceptor | null = null;
|
||||
let globalResponseObserver: ResponseObserver | null = null;
|
||||
|
||||
export const setGlobalErrorCallback = (callback: ErrorCallback) => {
|
||||
globalErrorCallback = callback;
|
||||
};
|
||||
|
||||
export const setGlobalRequestInterceptor = (callback: RequestInterceptor | null) => {
|
||||
globalRequestInterceptor = callback;
|
||||
};
|
||||
|
||||
export const setGlobalResponseObserver = (callback: ResponseObserver | null) => {
|
||||
globalResponseObserver = callback;
|
||||
};
|
||||
|
||||
const notifyResponseObserver = (payload: ResponseObserverPayload) => {
|
||||
if (!globalResponseObserver) {
|
||||
return;
|
||||
}
|
||||
globalResponseObserver(payload);
|
||||
};
|
||||
|
||||
const applyDefaultInterceptors = (instance: AxiosInstance) => {
|
||||
instance.defaults.withCredentials = true;
|
||||
instance.interceptors.request.use((config) => {
|
||||
if (globalRequestInterceptor) {
|
||||
return globalRequestInterceptor(config);
|
||||
}
|
||||
return config;
|
||||
});
|
||||
instance.interceptors.response.use((axiosResp: any) => {
|
||||
if (!axiosResp.config.responseType) {
|
||||
// 服务端返回
|
||||
const serverResp = axiosResp.data as ApiResponse<any>;
|
||||
if (serverResp.code < 40000) {
|
||||
notifyResponseObserver({
|
||||
config: axiosResp?.config,
|
||||
status: axiosResp?.status,
|
||||
response: axiosResp,
|
||||
serverResponse: serverResp
|
||||
});
|
||||
// 200 或 300 HTTP 状态段视为成功
|
||||
return serverResp.data;
|
||||
} else {
|
||||
notifyResponseObserver({
|
||||
config: axiosResp?.config,
|
||||
status: axiosResp?.status,
|
||||
response: axiosResp,
|
||||
serverResponse: serverResp,
|
||||
error: serverResp.message
|
||||
});
|
||||
if (globalErrorCallback) {
|
||||
globalErrorCallback(serverResp);
|
||||
}
|
||||
// 由调用方处理
|
||||
return Promise.reject(serverResp.message);
|
||||
}
|
||||
}
|
||||
notifyResponseObserver({
|
||||
config: axiosResp?.config,
|
||||
status: axiosResp?.status,
|
||||
response: axiosResp
|
||||
});
|
||||
return axiosResp.data;
|
||||
}, (error: any) => {
|
||||
notifyResponseObserver({
|
||||
config: error?.config,
|
||||
status: error?.response?.status,
|
||||
response: error?.response,
|
||||
error
|
||||
});
|
||||
// 请求错误
|
||||
if (error) {
|
||||
if (error.response && error.response.status) {
|
||||
throw error;
|
||||
}
|
||||
if (error.request) {
|
||||
if (error.message && error.message.startsWith("timeout")) {
|
||||
throw new Error("time out");
|
||||
} else {
|
||||
throw new Error(error.message || "request error");
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
const axiosInstance = axios.create();
|
||||
applyDefaultInterceptors(axiosInstance);
|
||||
|
||||
export const createServiceAxios = (baseURL?: string) => {
|
||||
const instance = axios.create({ baseURL });
|
||||
applyDefaultInterceptors(instance);
|
||||
return instance;
|
||||
};
|
||||
|
||||
export default {
|
||||
axios: axiosInstance,
|
||||
setGlobalErrorCallback,
|
||||
setGlobalRequestInterceptor,
|
||||
setGlobalResponseObserver,
|
||||
createServiceAxios
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import WebToolkit from "./WebToolkit";
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
export const POPUP_ID = "tui-popup";
|
||||
export const POPUP_CONTENT_ID = "tui-popup-content";
|
||||
|
||||
export function getPopup() {
|
||||
return document.getElementById(POPUP_ID);
|
||||
}
|
||||
|
||||
export function getPopupContent() {
|
||||
return document.getElementById(POPUP_CONTENT_ID);
|
||||
}
|
||||
|
||||
export function clearPopup(popup: HTMLElement) {
|
||||
popup.replaceChildren();
|
||||
}
|
||||
|
||||
export function movePopup(popup: HTMLElement, x: number, y: number) {
|
||||
const offsetX = 20;
|
||||
const offsetY = 32;
|
||||
popup.style.left = `${x + offsetX}px`;
|
||||
popup.style.top = `${y + offsetY}px`;
|
||||
|
||||
const popupRect = popup.getBoundingClientRect();
|
||||
if (window.innerWidth < popupRect.right + offsetX) {
|
||||
popup.style.left = `${Math.max(0, window.innerWidth - popupRect.width - offsetX)}px`;
|
||||
}
|
||||
if (window.innerHeight < popupRect.bottom) {
|
||||
popup.style.top = `${Math.max(0, y - popupRect.height - 6)}px`;
|
||||
}
|
||||
}
|
||||
|
||||
export function showPopup(popup: HTMLElement) {
|
||||
popup.style.visibility = "visible";
|
||||
}
|
||||
|
||||
export function hidePopup(popup: HTMLElement) {
|
||||
popup.style.visibility = "hidden";
|
||||
popup.style.left = "0px";
|
||||
popup.style.top = "0px";
|
||||
}
|
||||
|
||||
export function appendPopupValue(popup: HTMLElement, type: PopupType, value: string | HTMLElement) {
|
||||
let popupChild: HTMLElement;
|
||||
switch (type) {
|
||||
case PopupType.TEXT:
|
||||
popupChild = document.createElement("div");
|
||||
popupChild.className = "text";
|
||||
popupChild.textContent = value as string;
|
||||
popup.appendChild(popupChild);
|
||||
break;
|
||||
case PopupType.IMG:
|
||||
popupChild = document.createElement("img");
|
||||
(popupChild as HTMLImageElement).src = value as string;
|
||||
popup.appendChild(popupChild);
|
||||
break;
|
||||
case PopupType.HTML:
|
||||
popup.appendChild(WebToolkit.toDOM(value as string));
|
||||
break;
|
||||
case PopupType.EL:
|
||||
if (value instanceof HTMLElement) {
|
||||
value.style.display = "block";
|
||||
popup.appendChild(value);
|
||||
break;
|
||||
}
|
||||
throw new Error("Vue 指令错误:v-popup:el 的值不是 HTML 元素");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user