├── .env ├── .env.production ├── src ├── store │ ├── index.ts │ └── module │ │ ├── index.ts │ │ ├── global.ts │ │ ├── code.ts │ │ └── graph.ts ├── constants │ ├── index.ts │ ├── colors.ts │ └── layouts.ts ├── assets │ └── iconfont │ │ ├── iconfont.ttf │ │ ├── iconfont.woff │ │ ├── iconfont.woff2 │ │ └── iconfont.css ├── hooks │ ├── index.ts │ ├── useDialogWidth.ts │ ├── useMobile.ts │ └── useDarkAnimate.ts ├── env.d.ts ├── utils │ ├── index.ts │ ├── format-layout.ts │ ├── get-width.ts │ ├── random-id.ts │ ├── import-export-json.ts │ ├── save-image.ts │ ├── register-node.ts │ └── json-to-tree.ts ├── locales │ ├── i18n.ts │ ├── zh-CN.ts │ └── en-US.ts ├── components │ ├── sync │ │ ├── JsonCanvas.vue │ │ ├── SearchInput.vue │ │ ├── VueCodeMirror.vue │ │ ├── EditorToolBar.vue │ │ └── CanvasToolBar.vue │ └── async │ │ ├── NodeDialog.vue │ │ ├── FieldsCustom.vue │ │ ├── LayoutOptions.vue │ │ ├── ExportImage.vue │ │ └── ConfigDrawer.vue ├── example.json ├── main.scss ├── main.ts ├── App.vue └── types │ ├── auto-components.d.ts │ └── auto-imports.d.ts ├── .env.prod_remote ├── .github ├── renovate.json5 └── workflows │ └── main.yml ├── public ├── logo_120.png ├── logo_144.png ├── logo_192.png ├── logo_512.png ├── logo_72.png └── favicon.svg ├── eslint.config.js ├── CHANGELOG.md ├── deploy.sh ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── tsconfig.json ├── index.html ├── LICENSE ├── uno.config.ts ├── package.json ├── README.md ├── README_EN.md └── vite.config.js /.env: -------------------------------------------------------------------------------- 1 | VITE_BASE_URL=/ 2 | VITE_OUTDIR=dist 3 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | VITE_BASE_URL=/json-viewer/ 2 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | export * from './module' 2 | -------------------------------------------------------------------------------- /.env.prod_remote: -------------------------------------------------------------------------------- 1 | VITE_OUTDIR=/var/www/json.fxzer.top 2 | VITE_BASE_URL=/ 3 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>sxzz/renovate-config"] 3 | } 4 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './colors' 2 | export * from './layouts' 3 | -------------------------------------------------------------------------------- /public/logo_120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fxzer/json-viewer/HEAD/public/logo_120.png -------------------------------------------------------------------------------- /public/logo_144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fxzer/json-viewer/HEAD/public/logo_144.png -------------------------------------------------------------------------------- /public/logo_192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fxzer/json-viewer/HEAD/public/logo_192.png -------------------------------------------------------------------------------- /public/logo_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fxzer/json-viewer/HEAD/public/logo_512.png -------------------------------------------------------------------------------- /public/logo_72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fxzer/json-viewer/HEAD/public/logo_72.png -------------------------------------------------------------------------------- /src/store/module/index.ts: -------------------------------------------------------------------------------- 1 | export * from './code' 2 | export * from './global' 3 | export * from './graph' 4 | -------------------------------------------------------------------------------- /src/assets/iconfont/iconfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fxzer/json-viewer/HEAD/src/assets/iconfont/iconfont.ttf -------------------------------------------------------------------------------- /src/assets/iconfont/iconfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fxzer/json-viewer/HEAD/src/assets/iconfont/iconfont.woff -------------------------------------------------------------------------------- /src/assets/iconfont/iconfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fxzer/json-viewer/HEAD/src/assets/iconfont/iconfont.woff2 -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useDarkAnimate' 2 | export * from './useDialogWidth' 3 | export * from './useMobile' 4 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu({ 4 | formatters: true, 5 | unocss: true, 6 | vue: true, 7 | typescript: true, 8 | }) 9 | -------------------------------------------------------------------------------- /src/constants/colors.ts: -------------------------------------------------------------------------------- 1 | export const COLORS_MAP = { 2 | array: '#818cf8', 3 | string: '#a3e635', 4 | boolean: '#fb7185', 5 | object: '#fb923c', 6 | other: '#c084fc', 7 | } 8 | -------------------------------------------------------------------------------- /src/hooks/useDialogWidth.ts: -------------------------------------------------------------------------------- 1 | export function useDialogWidth(initialWidth = 400) { 2 | const { width } = useWindowSize() 3 | const w = computed(() => Math.min(width.value, initialWidth)) 4 | return w 5 | } 6 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | declare module '*.vue' { 3 | import type { DefineComponent } from 'vue' 4 | 5 | const component: DefineComponent<{}, {}, any> 6 | export default component 7 | 8 | } 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [2.2.6](https://github.com/fxzer/json-viewer/compare/v2.2.5...v2.2.6) (2024-04-24) 6 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | # 确保脚本抛出遇到的错误 2 | set -e 3 | 4 | # 生成静态文件 5 | pnpm build 6 | 7 | # 进入生成的文件夹 8 | cd dist/ 9 | # 如果是发布到自定义域名 10 | 11 | git init 12 | git add -A 13 | git commit -m '🚀Deploy Page' 14 | 15 | # 打包后的文件推送到gitee的gh-pages分支 16 | 17 | git push -f git@gitee.com:fxzer/json-viewer.git main:gh-pages 18 | 19 | cd .. 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | .CodeCounter 15 | # Editor directories and files 16 | .idea 17 | .DS_Store 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | report.html 24 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './format-layout' 2 | export * from './get-width' 3 | export * from './import-export-json' 4 | export * from './json-to-tree' 5 | export * from './register-node' 6 | export * from './save-image' 7 | export function setHtmlProperty(key: string, value: string) { 8 | document.documentElement.style.setProperty(key, value) 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/format-layout.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 格式化布局配置 3 | */ 4 | export function formatLayoutConfig(layoutConfig: any) { 5 | const config = { ...layoutConfig } 6 | 7 | if (['mindmap', 'compact-box'].includes(config.type)) { 8 | const { getVGap, getHGap } = config 9 | config.getVGap = () => getVGap 10 | config.getHGap = () => getHGap 11 | } 12 | 13 | config.animation = false 14 | return config 15 | } 16 | -------------------------------------------------------------------------------- /src/hooks/useMobile.ts: -------------------------------------------------------------------------------- 1 | import { breakpointsTailwind } from '@vueuse/core' 2 | 3 | const breakpoints = useBreakpoints(breakpointsTailwind) 4 | export function useMobile() { 5 | const isMobile = ref(breakpoints.isSmaller('md')) 6 | // 防抖,只有在停止改变窗口大小后,才会执行 7 | const debounceResize = useDebounceFn(() => { 8 | isMobile.value = breakpoints.isSmaller('md') 9 | }, 300) 10 | useResizeObserver(document.body, debounceResize) 11 | return isMobile 12 | } 13 | -------------------------------------------------------------------------------- /src/locales/i18n.ts: -------------------------------------------------------------------------------- 1 | import type { App } from 'vue' 2 | 3 | import { createI18n } from 'vue-i18n' 4 | 5 | import en from '@/locales/en-US' 6 | import zh from '@/locales/zh-CN' 7 | 8 | const locale = JSON.parse(localStorage.getItem('global'))?.language || 'zh-CN' 9 | const i18n = createI18n({ 10 | allowComposition: true, 11 | legacy: false, 12 | locale, 13 | messages: { 14 | zh, 15 | en, 16 | }, 17 | }) 18 | 19 | export function setupI18n(app: App) { 20 | app.use(i18n) 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/get-width.ts: -------------------------------------------------------------------------------- 1 | export const getWidth = (() => { 2 | const canvas = document.createElement('canvas') 3 | const context = canvas.getContext('2d') 4 | const cache = new Map() 5 | 6 | return (text: string, font: string = 'normal 16px monospace'): number => { 7 | const cacheKey = `${font}:${text}` 8 | if (cache.has(cacheKey)) 9 | return cache.get(cacheKey)! 10 | 11 | context.font = font 12 | const width = Math.ceil(context.measureText(text).width) 13 | cache.set(cacheKey, width) 14 | return width 15 | } 16 | })() 17 | -------------------------------------------------------------------------------- /src/components/sync/JsonCanvas.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 17 | -------------------------------------------------------------------------------- /src/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": "success", 3 | "error": false, 4 | "total": 100, 5 | "createdDate": "Jan 10, 2023 1:13:21 PM", 6 | "finishedDate": "Jan 10, 2023 1:13:21 PM", 7 | "friends": [ 8 | "李四", 9 | "王五", 10 | "赵六" 11 | ], 12 | "person": { 13 | "name": "张三", 14 | "age": 18, 15 | "height": 180, 16 | "address": "江苏苏州姑苏区", 17 | "contact": { 18 | "phone": ["19988886666", "010-88889999"], 19 | "eamil": ["300100999@qq.com", "g123456@gamil.com"] 20 | } 21 | }, 22 | "result": "{\"code\": 200, \"message\": \"success\"}" 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "chrome", 9 | "request": "launch", 10 | "name": "Launch Chrome against localhost", 11 | "url": "http://localhost:5173", 12 | "webRoot": "${workspaceFolder}", 13 | "skipFiles": [ 14 | "${workspaceFolder}/node_modules/**" 15 | ] 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/random-id.ts: -------------------------------------------------------------------------------- 1 | // 缓存相关变量 2 | const ID_CACHE_SIZE = 50 3 | export const cacheIdList: string[] = [] 4 | 5 | // 生成随机id (使用缓存减少计算) 6 | export function randomId(): string { 7 | if (cacheIdList.length > 0) { 8 | return cacheIdList.pop() 9 | } 10 | function makeId(count = 8): string { 11 | return Math.random().toString(36).substring(2, 2 + count) 12 | } 13 | function fillIdList(idList: string[]) { 14 | for (let i = 0; i < ID_CACHE_SIZE; i++) { 15 | idList.push(makeId()) 16 | } 17 | } 18 | // 批量生成ID并缓存,提高性能 19 | if (cacheIdList.length === 0) { 20 | fillIdList(cacheIdList) 21 | } 22 | 23 | return makeId() 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "jsx": "preserve", 5 | "lib": ["esnext", "dom"], 6 | "useDefineForClassFields": true, 7 | "baseUrl": "./", 8 | "module": "esnext", 9 | "moduleResolution": "node", 10 | "paths": { 11 | "@": ["src"], 12 | "@/*": ["src/*"] 13 | }, 14 | "resolveJsonModule": true, // 默认导入 15 | "types": ["vite/client", "element-plus/global"], 16 | "allowJs": false, 17 | "strict": false, 18 | "sourceMap": true, 19 | "allowSyntheticDefaultImports": true, 20 | "esModuleInterop": true, 21 | "skipLibCheck": true 22 | }, 23 | "include": ["src/**/*.ts", "build/*.ts"], 24 | "exclude": ["node_modules"] 25 | } 26 | -------------------------------------------------------------------------------- /src/components/async/NodeDialog.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/components/sync/SearchInput.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 23 | -------------------------------------------------------------------------------- /src/main.scss: -------------------------------------------------------------------------------- 1 | @import url('./assets/iconfont/iconfont.css'); 2 | 3 | * { 4 | margin: 0; 5 | padding: 0; 6 | font-size: 14px; 7 | box-sizing: border-box; 8 | } 9 | 10 | /* 暗黑模式切换动画 */ 11 | ::view-transition-old(root), 12 | ::view-transition-new(root) { 13 | animation: none; 14 | mix-blend-mode: normal; 15 | } 16 | ::view-transition-old(root), 17 | .dark::view-transition-new(root) { 18 | z-index: 1; 19 | } 20 | ::view-transition-new(root), 21 | .dark::view-transition-old(root) { 22 | z-index: 9999; 23 | } 24 | 25 | /* 自定义滚动条 */ 26 | ::-webkit-scrollbar { 27 | width: 8px; 28 | height: 8px; 29 | } 30 | ::-webkit-scrollbar-thumb { 31 | border-radius: 8px; 32 | background-color: #9ca3af28; 33 | } 34 | 35 | .iconfont { 36 | font-size: 18px; 37 | cursor: pointer; 38 | transition: all 0.2s; 39 | color: #9ca3af; 40 | 41 | &:hover { 42 | color: #222; 43 | } 44 | } 45 | 46 | .dark .iconfont:hover { 47 | color: #fff; 48 | } 49 | -------------------------------------------------------------------------------- /src/components/sync/VueCodeMirror.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 25 | 26 | 38 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | JSON Viewer 8 | 12 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import chroma from 'chroma-js' 2 | import { createPinia } from 'pinia' 3 | import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' 4 | import { createApp } from 'vue' 5 | import { setupI18n } from '@/locales/i18n' 6 | import App from './App.vue' 7 | import { setHtmlProperty } from './utils' 8 | import './main.scss' 9 | // Tailwind 紧凑型样式重置 10 | import 'virtual:uno.css' 11 | import '@unocss/reset/tailwind-compat.css' 12 | import 'splitpanes/dist/splitpanes.css' 13 | // 引入pinia 14 | import 'element-plus/theme-chalk/dark/css-vars.css' 15 | 16 | // 创建pinia实例 17 | const pinia = createPinia() 18 | pinia.use(piniaPluginPersistedstate) 19 | const app = createApp(App) 20 | app.use(pinia) 21 | setupI18n(app) 22 | app.mount('#app') 23 | 24 | function setThemeColor(color: string) { 25 | setHtmlProperty('--el-color-primary', color) 26 | setHtmlProperty('--el-color-primary-light-9', chroma(color).alpha(0.1).hex()) 27 | setHtmlProperty('--el-color-primary-light-7', chroma(color).alpha(0.3).hex()) 28 | } 29 | 30 | setThemeColor('#00bd5c') 31 | -------------------------------------------------------------------------------- /src/store/module/global.ts: -------------------------------------------------------------------------------- 1 | import { useI18n } from 'vue-i18n' 2 | import { useMobile } from '@/hooks' 3 | 4 | const LANGUAGES = { 5 | EN: 'en-US', 6 | CN: 'zh-CN', 7 | } 8 | 9 | export const useGlobalStore = defineStore('global', () => { 10 | const i18n = useI18n() 11 | const currentLanguage = ref(LANGUAGES.CN) 12 | const isDark = useDark() 13 | const isMobile = useMobile() 14 | 15 | function toggleLanguage() { 16 | const newLanguage = currentLanguage.value === LANGUAGES.CN ? LANGUAGES.EN : LANGUAGES.CN 17 | currentLanguage.value = i18n.locale.value = newLanguage 18 | } 19 | 20 | const [isExpandEditor, toggleEditor] = useToggle(true) 21 | 22 | // 根据设备类型和编辑器展开状态动态计算面板尺寸 23 | const paneSize = computed(() => { 24 | if (!isExpandEditor.value) 25 | return [0, 100] 26 | 27 | return isMobile.value ? [50, 50] : [30, 70] 28 | }) 29 | return { 30 | isDark, 31 | currentLanguage, 32 | isExpandEditor, 33 | toggleEditor, 34 | paneSize, 35 | toggleLanguage, 36 | } 37 | }, { persist: true }) 38 | -------------------------------------------------------------------------------- /src/utils/import-export-json.ts: -------------------------------------------------------------------------------- 1 | function isObject(mybeObj: any) { 2 | return Object.prototype.toString.call(mybeObj) === '[object Object]' 3 | } 4 | 5 | export function exportJSON(json: string | object, name = 'json-viewer.json') { 6 | const jsonStr = isObject(json) 7 | ? JSON.stringify(json, undefined, 2) 8 | : json 9 | const blob = new Blob([jsonStr as any], { type: 'text/plain' }) 10 | const link = document.createElement('a') 11 | link.setAttribute('style', 'display: none') 12 | link.href = URL.createObjectURL(blob) 13 | link.download = name 14 | link.click() 15 | } 16 | 17 | export function importJSON(callback: (json: any) => void) { 18 | const input = document.createElement('input') 19 | input.setAttribute('type', 'file') 20 | input.setAttribute('accept', '.json') 21 | input.click() 22 | input.onchange = () => { 23 | const file = input.files?.[0] 24 | if (!file) 25 | return 26 | const reader = new FileReader() 27 | reader.readAsText(file) 28 | reader.onload = () => { 29 | callback(reader.result) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 fxzer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/utils/save-image.ts: -------------------------------------------------------------------------------- 1 | import type { Graph } from '@antv/g6' 2 | 3 | // 保存为图片 4 | export async function saveImage(graph: Graph, name = 'json-viewer', type = 'image/png') { 5 | if (!graph) 6 | return 7 | 8 | try { 9 | const dataURL = await graph.toDataURL({ 10 | mode: 'overall', 11 | type: type as 'image/png' | 'image/jpeg' | 'image/webp' | 'image/bmp', 12 | encoderOptions: 1, 13 | }) 14 | 15 | // 将dataURL转换为Blob并下载 16 | const [head, content] = dataURL.split(',') 17 | const contentType = head.match(/:(.*?);/)![1] 18 | 19 | const bstr = atob(content) 20 | let length = bstr.length 21 | const u8arr = new Uint8Array(length) 22 | 23 | while (length--) { 24 | u8arr[length] = bstr.charCodeAt(length) 25 | } 26 | 27 | const blob = new Blob([u8arr], { type: contentType }) 28 | 29 | const url = URL.createObjectURL(blob) 30 | const a = document.createElement('a') 31 | a.href = url 32 | a.download = `${name}.${type.split('/')[1]}` 33 | a.click() 34 | 35 | // 释放URL对象 36 | URL.revokeObjectURL(url) 37 | } 38 | catch (error) { 39 | console.error('导出图片失败:', error) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/constants/layouts.ts: -------------------------------------------------------------------------------- 1 | export const LAYOUTS = { 2 | 'mindmap': { 3 | type: 'mindmap', 4 | direction: 'LR', 5 | directions: ['H', 'LR', 'RL', 'TB', 'BT'], 6 | getWidth: (d) => { 7 | return d.data?.width 8 | }, 9 | getHeight: (d) => { 10 | return d.data?.height 11 | }, 12 | getVGap: 40, 13 | getHGap: 120, 14 | }, 15 | 'compact-box': { 16 | type: 'compact-box', 17 | direction: 'LR', 18 | directions: ['LR', 'RL', 'H', 'V'], 19 | getWidth: (d) => { 20 | return d.data?.width 21 | }, 22 | getHeight: (d) => { 23 | return d.data?.height 24 | }, 25 | 26 | getVGap: 40, 27 | getHGap: 120, 28 | }, 29 | 'indented': { 30 | type: 'indented', 31 | direction: 'LR', 32 | directions: ['LR', 'RL', 'H'], 33 | indent: 240, 34 | dropCap: false, 35 | getWidth: (d) => { 36 | return d.data?.width 37 | }, 38 | getHeight: (d) => { 39 | return d.data?.height 40 | }, 41 | }, 42 | 'dendrogram': { 43 | type: 'dendrogram', 44 | direction: 'LR', 45 | directions: ['LR', 'RL', 'H'], 46 | nodeSep: 100, 47 | rankSep: 160, 48 | radial: false, 49 | }, 50 | 51 | } 52 | -------------------------------------------------------------------------------- /src/hooks/useDarkAnimate.ts: -------------------------------------------------------------------------------- 1 | const isDark = useDark() 2 | const toggleDark = useToggle(isDark) 3 | const isAppearanceTransition = document.startViewTransition 4 | && !window.matchMedia('(prefers-reduced-motion: reduce)').matches 5 | 6 | export function toggleDarkAnimate(event?: MouseEvent) { 7 | if (!isAppearanceTransition || !event) { 8 | toggleDark() 9 | return 10 | } 11 | const x = event.clientX 12 | const y = event.clientY 13 | const endRadius = Math.hypot( 14 | Math.max(x, innerWidth - x), 15 | Math.max(y, innerHeight - y), 16 | ) 17 | const transition = document.startViewTransition(async () => { 18 | toggleDark() 19 | await nextTick() 20 | }) 21 | transition.ready.then(() => { 22 | const clipPath = [ 23 | `circle(0px at ${x}px ${y}px)`, 24 | `circle(${endRadius}px at ${x}px ${y}px)`, 25 | ] 26 | document.documentElement.animate( 27 | { 28 | clipPath: isDark.value 29 | ? [...clipPath].reverse() 30 | : clipPath, 31 | }, 32 | { 33 | duration: 400, 34 | easing: 'ease-in', 35 | pseudoElement: isDark.value 36 | ? '::view-transition-old(root)' 37 | : '::view-transition-new(root)', 38 | }, 39 | ) 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 32 | 33 | 47 | -------------------------------------------------------------------------------- /src/store/module/code.ts: -------------------------------------------------------------------------------- 1 | import { decompressFromEncodedURIComponent as decode } from 'lz-string' 2 | import exampleJson from '@/example.json' 3 | 4 | const params = new URLSearchParams(window.location.search || '') 5 | 6 | export const queryKey = 'code' 7 | export const useCodeStore = defineStore('code', () => { 8 | const defaultCode = JSON.stringify(exampleJson, null, 2) 9 | const rawCode = ref(decode(params.get(queryKey) || '') || defaultCode) 10 | const parsedJson = ref(JSON.parse(rawCode.value)) 11 | 12 | const isJsonValid = ref(true) 13 | watchDebounced(rawCode, (codeString) => { 14 | if (!codeString) { 15 | parsedJson.value = {} 16 | return 17 | } 18 | try { 19 | const parsedObject = JSON.parse(codeString) 20 | isJsonValid.value = true 21 | parsedJson.value = parsedObject 22 | } 23 | catch (err) { 24 | isJsonValid.value = false 25 | ElNotification({ 26 | type: 'error', 27 | title: 'JSON语法错误', 28 | dangerouslyUseHTMLString: true, 29 | message: `
${err.message}
`, 30 | duration: 2000, 31 | }) 32 | } 33 | }, { debounce: 300 }) 34 | 35 | return { 36 | rawCode, 37 | parsedJson, 38 | isJsonValid, 39 | } 40 | }) 41 | -------------------------------------------------------------------------------- /src/locales/zh-CN.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 'language': 'English', 3 | 'zoomIn': '放大', 4 | 'zoomOut': '缩小', 5 | 'exit': '退出', 6 | 'enter': '进入', 7 | 'fullscreen': '全屏', 8 | 'expand': '展开', 9 | 'collapse': '收起', 10 | 'editor': '编辑器', 11 | 'saveImage': '存为图片', 12 | 'center': '居中', 13 | 'nodes': '节点', 14 | 'autoRender': '自动渲染', 15 | 'manualRender': '手动渲染', 16 | 'execute': '渲染', 17 | 'import': '导入', 18 | 'export': '导出', 19 | 'clear': '清空JSON', 20 | 'parseField': '解析字段', 21 | 'config': '配置', 22 | 'layoutConfig': '布局配置', 23 | 'inputPlaceholder': '请输入内容', 24 | 'editorPlaceholder': '请输入JSON', 25 | 26 | 'imageName': '图片名称', 27 | 'saveFormat': '导出格式', 28 | 'saveImagePlaceholder': '请输入导出图片名', 29 | 30 | 'nodeDetail': '节点详情', 31 | 32 | 'indented': '缩进树', 33 | 'compact-box': '紧凑树', 34 | 'mindmap': '脑图树', 35 | 'dendrogram': '生态树', 36 | 37 | 'direction': '布局方向', 38 | 'nodeWrap': '节点换行', 39 | 'indentDistance': '缩进距离', 40 | 'nodeWrapInfo': '开启后子节点从父节点下一行开始依次渲染', 41 | 'levelSpacing': '层级间距', 42 | 'nodeSpacing': '节点间距', 43 | 44 | 'getHGap': '水平间距', 45 | 'getVGap': '垂直间距', 46 | 47 | 'assignField': '指定额外解析字段', 48 | 'fieldPlaceholder': '请输入字段名,可按回车确认', 49 | 'infoName': '额外解析字段:', 50 | 'info': '处理指定字段是,若可将其值解析为JSON,则继续递归处理,并转化为子树,否则不处理。', 51 | 52 | } 53 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Enable the ESlint flat config support 3 | "eslint.experimental.useFlatConfig": true, 4 | 5 | // Disable the default formatter, use eslint instead 6 | "prettier.enable": false, 7 | "editor.formatOnSave": false, 8 | 9 | // Auto fix 10 | "editor.codeActionsOnSave": { 11 | "source.fixAll.eslint": "explicit", 12 | "source.organizeImports": "never" 13 | }, 14 | 15 | // Silent the stylistic rules in you IDE, but still auto fix them 16 | "eslint.rules.customizations": [ 17 | { "rule": "style/*", "severity": "off" }, 18 | { "rule": "format/*", "severity": "off" }, 19 | { "rule": "*-indent", "severity": "off" }, 20 | { "rule": "*-spacing", "severity": "off" }, 21 | { "rule": "*-spaces", "severity": "off" }, 22 | { "rule": "*-order", "severity": "off" }, 23 | { "rule": "*-dangle", "severity": "off" }, 24 | { "rule": "*-newline", "severity": "off" }, 25 | { "rule": "*quotes", "severity": "off" }, 26 | { "rule": "*semi", "severity": "off" } 27 | ], 28 | 29 | // Enable eslint for all supported languages 30 | "eslint.validate": [ 31 | "javascript", 32 | "javascriptreact", 33 | "typescript", 34 | "typescriptreact", 35 | "vue", 36 | "html", 37 | "markdown", 38 | "json", 39 | "jsonc", 40 | "yaml", 41 | "toml", 42 | "astro" 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /src/components/async/FieldsCustom.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 46 | -------------------------------------------------------------------------------- /src/components/async/LayoutOptions.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 40 | -------------------------------------------------------------------------------- /src/types/auto-components.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | // biome-ignore lint: disable 4 | // oxlint-disable 5 | // ------ 6 | // Generated by unplugin-vue-components 7 | // Read more: https://github.com/vuejs/core/pull/3399 8 | 9 | export {} 10 | 11 | /* prettier-ignore */ 12 | declare module 'vue' { 13 | export interface GlobalComponents { 14 | CanvasToolBar: typeof import('./../components/sync/CanvasToolBar.vue')['default'] 15 | EditorToolBar: typeof import('./../components/sync/EditorToolBar.vue')['default'] 16 | ElButton: typeof import('element-plus/es')['ElButton'] 17 | ElDialog: typeof import('element-plus/es')['ElDialog'] 18 | ElDivider: typeof import('element-plus/es')['ElDivider'] 19 | ElDrawer: typeof import('element-plus/es')['ElDrawer'] 20 | ElForm: typeof import('element-plus/es')['ElForm'] 21 | ElFormItem: typeof import('element-plus/es')['ElFormItem'] 22 | ElInput: typeof import('element-plus/es')['ElInput'] 23 | ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] 24 | ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] 25 | ElSwitch: typeof import('element-plus/es')['ElSwitch'] 26 | ElTag: typeof import('element-plus/es')['ElTag'] 27 | ElTooltip: typeof import('element-plus/es')['ElTooltip'] 28 | JsonCanvas: typeof import('./../components/sync/JsonCanvas.vue')['default'] 29 | SearchInput: typeof import('./../components/sync/SearchInput.vue')['default'] 30 | VueCodeMirror: typeof import('./../components/sync/VueCodeMirror.vue')['default'] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/locales/en-US.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 'language': '简体中文', 3 | 'zoomIn': 'ZoomIn', 4 | 'zoomOut': 'ZoomOut', 5 | 'exit': 'Exit ', 6 | 'enter': 'Enter ', 7 | 'fullscreen': 'Fullscreen', 8 | 'expand': 'Expand ', 9 | 'collapse': 'Collapse ', 10 | 'editor': 'Editor', 11 | 'saveImage': 'Save as Image', 12 | 'center': 'Center', 13 | 'nodes': 'Nodes', 14 | 'autoRender': 'Auto Render', 15 | 'manualRender': 'Manual Render', 16 | 'execute': 'Render', 17 | 'import': 'Import', 18 | 'export': 'Export', 19 | 'clear': 'Clear JSON', 20 | 'parseField': 'Parse field', 21 | 'config': 'Config', 22 | 'layoutConfig': 'Layout Config', 23 | 'inputPlaceholder': 'Please enter content', 24 | 'editorPlaceholder': 'Please enter JSON', 25 | 'imageName': 'Name', 26 | 'saveImagePlaceholder': 'Please enter the exported image name', 27 | 'saveFormat': 'Format', 28 | 29 | 'nodeDetail': 'Node Detail', 30 | 31 | 'indented': 'Indented', 32 | 'compact-box': 'CompactBox', 33 | 'mindmap': 'MindMap', 34 | 'dendrogram': 'Dendrogram', 35 | 36 | 'direction': 'Direction', 37 | 'nodeWrap': 'NodeWrap', 38 | 'indentDistance': 'Indent', 39 | 'nodeWrapInfo': 'After opening, the child nodes start rendering from the next line of the parent node in turn', 40 | 'levelSpacing': 'LevelGap', 41 | 'nodeSpacing': 'NodeGap', 42 | 43 | 'getHGap': 'HGap', 44 | 'getVGap': 'VGap', 45 | 46 | 'assignField': 'Assign Field', 47 | 'fieldPlaceholder': 'Please enter the field name and press Enter to confirm', 48 | 'infoName': 'Extra parse field:', 49 | 'info': 'The specified field is processed. If the value can be parsed as JSON, recursive processing is continued and converted into a subtree. Otherwise, no processing is performed.', 50 | 51 | } 52 | -------------------------------------------------------------------------------- /src/components/async/ExportImage.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 60 | 61 | 64 | -------------------------------------------------------------------------------- /uno.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineConfig, 3 | presetAttributify, 4 | presetIcons, 5 | presetUno, 6 | transformerDirectives, 7 | transformerVariantGroup, 8 | } from 'unocss' 9 | 10 | export default defineConfig({ 11 | shortcuts: [ 12 | ['flex-col', 'flex flex-col'], 13 | ['flex-x-center', 'flex justify-center'], 14 | ['flex-y-center', 'flex items-center'], 15 | ['flex-center', 'flex justify-center items-center'], 16 | ['flex-start-center', 'flex justify-start items-center'], 17 | ['flex-between-center', 'flex justify-between items-center'], 18 | ['flex-center-stretch', 'flex justify-center items-stretch'], 19 | ['flex-between-stretch', 'flex justify-between items-stretch'], 20 | ['flex-around-center', 'flex justify-around items-center'], 21 | ['flex-start-end', 'flex justify-start items-end'], 22 | ['flex-end-center', 'flex justify-end items-center'], 23 | // 宽高相同 24 | [/^wh-(.+)$/, ([, c]) => `w-${c} h-${c}`], 25 | ], 26 | rules: [ 27 | [ 28 | /^clamp-(\d+)$/, 29 | ([, d]) => ({ 30 | 'display': '-webkit-box', 31 | '-webkit-box-orient': ' vertical', 32 | '-webkit-line-clamp': d, 33 | 'overflow': 'hidden', 34 | }), 35 | ], 36 | [ 37 | /^sd-(\d+)-(\d+)$/, 38 | ([, d, a]) => ({ 39 | 'box-shadow': `0 0 ${d}px rgba(0, 0, 0, 0.${a})`, 40 | }), 41 | ], 42 | ], 43 | theme: { 44 | colors: { 45 | primary: 'var(--el-color-primary)', 46 | }, 47 | }, 48 | presets: [ 49 | presetUno(), 50 | presetAttributify(), 51 | // 配置以类名使用的 iconify 52 | presetIcons({ 53 | }), // 以 CSS 方式使用 iconify 54 | ], 55 | safelist: [ 56 | // 生成所需的静态类名组合 57 | ], // 防止动态绑定误判为未使用,被 tree-shaking 58 | transformers: [ 59 | transformerDirectives(), // @apply, @screen, @variants 60 | transformerVariantGroup(), // 样式分组 61 | ], 62 | }) 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "json-viewer", 3 | "type": "module", 4 | "version": "3.0.0", 5 | "private": true, 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "build:remote": "vite build --mode prod_remote", 10 | "report": "rimraf dist && vite build", 11 | "preview": "vite preview", 12 | "bp": "vite build && vite preview", 13 | "lint": "eslint .", 14 | "lint:fix": "eslint . --fix", 15 | "release": "bumpp" 16 | }, 17 | "dependencies": { 18 | "@antv/g": "^6.1.28", 19 | "@antv/g6": "^5.0.50", 20 | "@codemirror/lang-json": "^6.0.2", 21 | "@codemirror/theme-one-dark": "^6.1.3", 22 | "@vueuse/core": "^14.0.0", 23 | "codemirror": "^6.0.2", 24 | "element-plus": "^2.11.7", 25 | "lz-string": "^1.5.0", 26 | "pinia": "^3.0.4", 27 | "pinia-plugin-persistedstate": "^4.7.1", 28 | "splitpanes": "^4.0.4", 29 | "vite-plugin-pwa": "^1.1.0", 30 | "vue": "^3.5.24", 31 | "vue-codemirror": "^6.1.1", 32 | "vue-i18n": "^11.1.12" 33 | }, 34 | "devDependencies": { 35 | "@antfu/eslint-config": "^6.2.0", 36 | "@types/node": "^24.10.0", 37 | "@unocss/eslint-plugin": "^66.5.5", 38 | "@unocss/reset": "^66.5.5", 39 | "@vitejs/plugin-vue": "^6.0.1", 40 | "bumpp": "^10.3.1", 41 | "chroma-js": "^3.1.2", 42 | "dayjs": "^1.11.19", 43 | "eslint": "^9.39.1", 44 | "eslint-plugin-format": "^1.0.2", 45 | "lint-staged": "^16.2.6", 46 | "picocolors": "^1.1.1", 47 | "rollup-plugin-visualizer": "^6.0.5", 48 | "sass": "^1.94.0", 49 | "simple-git-hooks": "^2.13.1", 50 | "standard-version": "^9.5.0", 51 | "unocss": "^66.5.5", 52 | "unplugin-auto-import": "^20.2.0", 53 | "unplugin-vue-components": "^30.0.0", 54 | "vite": "^7.2.2", 55 | "vite-plugin-vue-devtools": "^8.0.3" 56 | }, 57 | "simple-git-hooks": { 58 | "pre-commit": "pnpm lint-staged" 59 | }, 60 | "lint-staged": { 61 | "*": "pnpm lintf" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/assets/iconfont/iconfont.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'iconfont'; /* Project id 3847355 */ 3 | src: 4 | url('iconfont.woff2?t=1746868044564') format('woff2'), 5 | url('iconfont.woff?t=1746868044564') format('woff'), 6 | url('iconfont.ttf?t=1746868044564') format('truetype'); 7 | } 8 | 9 | .iconfont { 10 | font-family: 'iconfont' !important; 11 | font-size: 16px; 12 | font-style: normal; 13 | -webkit-font-smoothing: antialiased; 14 | -moz-osx-font-smoothing: grayscale; 15 | } 16 | 17 | .icon-clear:before { 18 | content: '\e642'; 19 | } 20 | 21 | .icon-language:before { 22 | content: '\e6c9'; 23 | } 24 | 25 | .icon-day:before { 26 | content: '\e839'; 27 | } 28 | 29 | .icon-night:before { 30 | content: '\e83e'; 31 | } 32 | 33 | .icon-execute:before { 34 | content: '\e674'; 35 | } 36 | 37 | .icon-auto:before { 38 | content: '\e988'; 39 | } 40 | 41 | .icon-center-focus:before { 42 | content: '\ebb0'; 43 | } 44 | 45 | .icon-enter-fullscreen:before { 46 | content: '\e888'; 47 | } 48 | 49 | .icon-exit-fullscreen:before { 50 | content: '\e889'; 51 | } 52 | 53 | .icon-jian:before { 54 | content: '\e612'; 55 | } 56 | 57 | .icon-jia:before { 58 | content: '\e6aa'; 59 | } 60 | 61 | .icon-config:before { 62 | content: '\e723'; 63 | } 64 | 65 | .icon-home-fill:before { 66 | content: '\e867'; 67 | } 68 | 69 | .icon-export-json:before { 70 | content: '\e64e'; 71 | } 72 | 73 | .icon-import-json:before { 74 | content: '\e675'; 75 | } 76 | 77 | .icon-node-collapse:before { 78 | content: '\e795'; 79 | } 80 | 81 | .icon-clear-json:before { 82 | content: '\e680'; 83 | } 84 | 85 | .icon-node-expand:before { 86 | content: '\e65f'; 87 | } 88 | 89 | .icon-auto-zoom:before { 90 | content: '\e6cd'; 91 | } 92 | 93 | .icon-save-image:before { 94 | content: '\e6d1'; 95 | } 96 | 97 | .icon-editor-expand:before { 98 | content: '\e624'; 99 | } 100 | 101 | .icon-search:before { 102 | content: '\e7b3'; 103 | } 104 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Deploy GitHub Pages And Sync to Gitee 2 | 3 | # 触发条件:在 push 到 main 分支后 4 | on: 5 | push: 6 | branches: 7 | - main 8 | paths-ignore: # 下列文件的变更不触发部署 9 | - LICENSE 10 | # 任务 11 | jobs: 12 | build-and-deploy: 13 | runs-on: ubuntu-latest # 部署运行环境搭建 14 | steps: 15 | - name: Checkout 🛎️ 16 | uses: actions/checkout@v4 17 | 18 | - name: Set USERNAME and REPO_NAME 19 | run: | 20 | echo "USERNAME=${{ github.actor }}" >> $GITHUB_ENV 21 | echo "REPO_NAME=${{ github.repository }}" >> $GITHUB_ENV 22 | 23 | - name: Install Node.js 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: latest 27 | 28 | - name: Install pnpm 29 | uses: pnpm/action-setup@v4 30 | with: 31 | version: latest 32 | run_install: true 33 | 34 | - name: Build 🔧 35 | run: | 36 | REPO_NAME="${{ github.event.repository.name }}" 37 | echo "Repository: ${REPO_NAME}" 38 | if [ "$REPO_NAME" = "json-viewer" ]; then 39 | pnpm run build 40 | elif [ "$REPO_NAME" = "fxzer.github.io" ]; then 41 | pnpm run build:edgeone 42 | else 43 | pnpm run build 44 | fi 45 | node -v 46 | 47 | - name: 📲 Push To GitHub Pages 48 | uses: ftnext/action-push-ghpages@v1.0.0 49 | with: 50 | build_dir: dist 51 | github_token: ${{ secrets.DEPLOY_TOKEN }} 52 | 53 | # - name: Sync to Gitee 54 | # uses: wearerequired/git-mirror-action@master 55 | # env: 56 | # # 注意在 Settings->Secrets 配置 GITEE_RSA_PRIVATE_KEY(复制本机生成的 ~/.ssh/gitee ) 57 | # SSH_PRIVATE_KEY: ${{ secrets.GITEE_RSA_PRIVATE_KEY }} 58 | # with: 59 | # # GitHub 源仓库地址 60 | # source-repo: git@github.com:${{ env.REPO_NAME }}.git 61 | # # Gitee 目标仓库地址 62 | # destination-repo: git@gitee.com:${{ env.REPO_NAME }}.git 63 | 64 | # 首次必须手动 Gitee 部署,否则会出错 65 | # - name: Build Gitee Pages 66 | # uses: yanglbme/gitee-pages-action@main 67 | # with: 68 | # # Gitee 用户名 69 | # gitee-username: ${{ env.USERNAME }} 70 | # # 在 Settings->Secrets 配置 GITEE_PSWD 71 | # gitee-password: ${{ secrets.GITEE_PSWD }} 72 | # # Gitee 仓库,仓库名严格区分大小写,请准确填写,否则会出错 73 | # gitee-repo: ${{ env.REPO_NAME }} 74 | # # 要部署的分支,默认是 master,若是其他分支,则需要指定(指定的分支必须存在) 75 | # branch: gh-pages 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

JSON-Viewer

6 | 7 |

8 | English | 中文 9 |

10 | 11 |

12 | 一个JSON可视化工具 13 |

14 | 15 | ## 技术栈:Vue 3 + Vite +TS + [Antv/G6](https://g6.antv.vision/) + Pinia +[CodeMirror](https://codemirror.net/) 16 | 17 | ## 全新版本(2.x.x) 新特性 18 | 19 | 1. 新增语言(中/英)切换,全站适配 20 | 2. 新增 15 种主题色,主题色全站适配 21 | 3. 新增亮、暗主题切换功能,编辑器主题跟随切换 22 | 4. 新增渲染方式(自动渲染、手动渲染)选择 23 | 5. 代码编辑器升级,优化编辑体验,提升性能(VueJsonEditor ---> VueCodeMirror) 24 | 6. 完全适配移动端 ,画布节点支持移动端点击、缩放事件,新增缩放比例展示 25 | 7. 支持网址链接存储编辑器代码信息,便于分享 26 | 8. 布局升级为活动分栏,让布局更灵活易用 27 | 9. 新增 PWA 功能,可安装后离线使用 28 | 29 | ## 全新升级(3.x.x) 新特性 30 | 31 | 1. 版本升级antv/g6@4 -> antv/g6@5 32 | 2. 移除主题色选择,只保留亮、暗主题 33 | 3. 不同颜色区分节点类型 34 | 4. 整合布局配置、解析字段配置项 35 | 36 | ## 优化项 37 | 38 | 1. JSON 语法报错不渲染,并提示语法错误位置 39 | 40 | 2. 使用原子化框架 UnoCSS,减小样式文件体积 41 | 42 | 3. 使用 `@antfu/esling-config`、`lint-staged`提升语法检查速度 43 | 44 | | 构建资源 | 重构前打包体积 | 重构后打包体积 | 提升 | 45 | | :------------------------------ | -------------- | -------------- | ------------------------------------- | 46 | | dist/assets/iconfont-xxxxxx.ttf | 4.68 kB | 无 | 4.68 kB | 47 | | dist/assets/index-xxxxxx.css | 107.92 kB | 81.94 kB | 25.98 kB | 48 | | dist/assets/index-xxxxxx.js | 3,325.04 kB | 2,324.82 kB | 1,000.22 kB (**共减小1,030.88 kB**) | 49 | 50 | ## 主要功能 51 | 52 | 1. JSON编辑生成对应视图 53 | 2. 布局配置调整更新视图 54 | 3. 节点收起、展开 55 | 4. 导入、导出JSON 56 | 5. 指定额外解析字段 57 | 6. 亮暗主题切换 58 | 7. 15种主题随意切换 59 | 8. 视图节点聚焦搜索 60 | 9. 视图导出为图片 61 | 10. 点击节点查看节点详情 62 | 11. 链接分享 JSON 代码 63 | 12. 渲染方式控制 64 | 13. 画布操作(放大/缩小/居中/全屏/退出全屏) 65 | 66 | ## 预览地址: 67 | 68 | ### Bilibili演示:[Bilibili演示视频地址](https://www.bilibili.com/video/BV18i42117Fa/?spm_id_from=333.999.0.0) 69 | 70 | ### Github:[https://fxzer.github.io/json-viewer/](https://fxzer.github.io/json-viewer/) 71 | 72 | ### Gitee:[https://fxzer.gitee.io/json-viewer](https://fxzer.gitee.io/json-viewer) 73 | 74 | ## 源码分享 75 | 76 | ### Github:[https://github.com/fxzer/json-viewer](https://github.com/fxzer/json-viewer) 77 | 78 | ### Gitee:[https://gitee.com/fxzer/json-viewer](https://gitee.com/fxzer/json-viewer) 79 | 80 | ## 预览截图 81 | 82 | 83 | 84 | ## 灵感来源 85 | 86 | [JSON Crack](https://github.com/AykutSarac/jsoncrack.com): 一个丝滑且大气的React+TypeScript项目JSON可视化项目。 87 | -------------------------------------------------------------------------------- /src/components/sync/EditorToolBar.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 80 | 81 | 95 | -------------------------------------------------------------------------------- /README_EN.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

JSON-Viewer

6 | 7 |

8 | 中文 | English 9 |

10 | 11 |

12 | A JSON visualization tool 13 |

14 | 15 | ## Tech Stack: Vue 3 + Vite + TS + [Antv/G6](https://g6.antv.vision/) + Pinia + [CodeMirror](https://codemirror.net/) 16 | 17 | ## New Features in Version 2.x.x 18 | 19 | 1. Added language switching (Chinese/English) with full site adaptation 20 | 2. Added 15 theme colors with full site compatibility 21 | 3. Added light/dark theme switching with editor theme following 22 | 4. Added rendering mode selection (automatic/manual rendering) 23 | 5. Upgraded code editor for better editing experience and performance (VueJsonEditor ---> VueCodeMirror) 24 | 6. Full mobile adaptation with canvas node support for mobile click and zoom events, added zoom ratio display 25 | 7. Support URL storage of editor code information for easy sharing 26 | 8. Layout upgraded to active columns for more flexible and user-friendly layout 27 | 9. Added PWA functionality for offline use after installation 28 | 29 | ## Optimizations 30 | 31 | 1. JSON syntax errors prevent rendering and indicate error location 32 | 2. Using atomic framework UnoCSS to reduce style file size 33 | 3. Using `@antfu/esling-config` and `lint-staged` to improve syntax checking speed 34 | 35 | | Build Resources | Before Refactor | After Refactor | Improvement | 36 | | :------------------------------ | --------------- | -------------- | ------------------------------------- | 37 | | dist/assets/iconfont-xxxxxx.ttf | 4.68 kB | None | 4.68 kB | 38 | | dist/assets/index-xxxxxx.css | 107.92 kB | 81.94 kB | 25.98 kB | 39 | | dist/assets/index-xxxxxx.js | 3,325.04 kB | 2,324.82 kB | 1,000.22 kB (**Total: -1,030.88 kB**) | 40 | 41 | ## Main Features 42 | 43 | 1. JSON editing generates corresponding views 44 | 2. Layout configuration adjustment updates view 45 | 3. Node collapse and expansion 46 | 4. Import and export JSON 47 | 5. Specify additional parsing fields 48 | 6. Light/dark theme switching 49 | 7. 15 themes to choose from 50 | 8. View node focus search 51 | 9. Export view as image 52 | 10. Click node to view node details 53 | 11. Share JSON code via link 54 | 12. Rendering mode control 55 | 13. Canvas operations (zoom in/out/center/fullscreen/exit fullscreen) 56 | 57 | ## Preview Links: 58 | 59 | ### Bilibili Demo: [Bilibili Demo Video](https://www.bilibili.com/video/BV18i42117Fa/?spm_id_from=333.999.0.0) 60 | 61 | ### Github: [https://fxzer.github.io/json-viewer/](https://fxzer.github.io/json-viewer/) 62 | 63 | ### Gitee: [https://fxzer.gitee.io/json-viewer](https://fxzer.gitee.io/json-viewer) 64 | 65 | ## Source Code 66 | 67 | ### Github: [https://github.com/fxzer/json-viewer](https://github.com/fxzer/json-viewer) 68 | 69 | ### Gitee: [https://gitee.com/fxzer/json-viewer](https://gitee.com/fxzer/json-viewer) 70 | 71 | ## Preview Screenshots 72 | 73 | 74 | 75 | ## Inspiration 76 | 77 | [JSON Crack](https://github.com/AykutSarac/jsoncrack.com): A smooth and elegant React + TypeScript JSON visualization project. 78 | -------------------------------------------------------------------------------- /src/components/sync/CanvasToolBar.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 116 | 117 | 120 | -------------------------------------------------------------------------------- /src/utils/register-node.ts: -------------------------------------------------------------------------------- 1 | import type { BadgeStyleProps, LabelStyleProps } from '@antv/g6' 2 | import { Text as GText } from '@antv/g' 3 | import { 4 | Badge, 5 | CommonEvent, 6 | ExtensionCategory, 7 | Rect, 8 | register, 9 | } from '@antv/g6' 10 | import { COLORS_MAP } from '@/constants/colors' 11 | 12 | class TreeNode extends Rect { 13 | get data() { 14 | return this.context.model.getNodeLikeDatum(this.id) 15 | } 16 | 17 | get childrenData() { 18 | return this.context.model.getChildrenData(this.id) 19 | } 20 | 21 | get isDark() { 22 | return this.context.graph.getTheme() === 'dark' 23 | } 24 | 25 | get textColor() { 26 | return this.isDark ? '#fff' : '#000' 27 | } 28 | 29 | get backgroundColor() { 30 | return this.isDark ? '#000' : '#fff' 31 | } 32 | 33 | getContentStyle(attributes): false | LabelStyleProps | any { 34 | const [width] = this.getSize(attributes) 35 | const content = this.data.content 36 | return { 37 | x: -width / 2 + 8, 38 | y: 0, 39 | text: String(content), 40 | fontSize: 16, 41 | fill: this.textColor, 42 | fontFamily: 'monospace', 43 | opacity: 0.9, 44 | textAlign: 'left', 45 | textBaseline: 'middle', 46 | } 47 | } 48 | 49 | drawContentShape(attributes, container) { 50 | const contentStyle = this.getContentStyle(attributes) 51 | this.upsert('content', GText, contentStyle, container) 52 | } 53 | 54 | getCollapseStyle(attributes): false | BadgeStyleProps { 55 | const count = this.childrenData.length 56 | if (count === 0) 57 | return false 58 | const { collapsed } = attributes 59 | const badge = this.data.data.badge as 'object' | 'array' 60 | const [width] = this.getSize(attributes) 61 | const text = collapsed ? (badge === 'object' ? `{${count}}` : `[${count}]`) : '-' 62 | 63 | return { 64 | backgroundFill: this.backgroundColor, 65 | backgroundWidth: 16, 66 | backgroundHeight: 16, 67 | backgroundLineWidth: 0.5, 68 | backgroundFillOpacity: 0.5, 69 | backgroundRadius: 3, 70 | backgroundStroke: this.textColor, 71 | backgroundStrokeOpacity: 0.2, 72 | cursor: 'pointer', 73 | fill: COLORS_MAP[badge], 74 | fontSize: collapsed ? 10 : 18, 75 | text, 76 | textAlign: 'center', 77 | textBaseline: 'middle', 78 | x: width / 2, 79 | y: 0, 80 | } 81 | } 82 | 83 | drawCollapseShape(attributes, container) { 84 | const collapseStyle = this.getCollapseStyle(attributes) 85 | const btn = this.upsert('collapse', Badge, collapseStyle, container) 86 | 87 | if (btn && !Reflect.has(btn, '__bind__')) { 88 | Reflect.set(btn, '__bind__', true) 89 | btn.addEventListener(CommonEvent.CLICK, (e) => { 90 | e.stopPropagation() 91 | 92 | const { collapsed } = this.attributes 93 | const graph = this.context.graph 94 | if (collapsed) { 95 | graph.expandElement(this.id, { 96 | animation: { 97 | duration: 300, // 300毫秒 98 | easing: 'ease-in-out', // 先慢后快 99 | }, 100 | }) 101 | } 102 | else { 103 | graph.collapseElement(this.id, { 104 | animation: { 105 | duration: 300, // 300毫秒 106 | easing: 'ease-in-out', // 先慢后快 107 | }, 108 | }) 109 | } 110 | }) 111 | } 112 | } 113 | 114 | // 节点 115 | getKeyStyle(attributes) { 116 | const keyStyle = super.getKeyStyle(attributes) 117 | return { 118 | ...keyStyle, 119 | } 120 | } 121 | 122 | render(attributes = this.parsedAttributes, container) { 123 | super.render(attributes, container) 124 | this.drawContentShape(attributes, container) 125 | this.drawCollapseShape(attributes, container) 126 | } 127 | } 128 | 129 | register(ExtensionCategory.NODE, 'flow-rect', TreeNode) 130 | -------------------------------------------------------------------------------- /src/components/async/ConfigDrawer.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 127 | 128 | 133 | 134 | 152 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import process from 'node:process' 3 | import vue from '@vitejs/plugin-vue' 4 | import { visualizer } from 'rollup-plugin-visualizer' 5 | import UnoCSS from 'unocss/vite' 6 | // 引入element-plus自动按需导入插件 7 | import AutoImport from 'unplugin-auto-import/vite' 8 | import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' 9 | import Components from 'unplugin-vue-components/vite' 10 | import { defineConfig, loadEnv } from 'vite' 11 | import { VitePWA } from 'vite-plugin-pwa' 12 | import vueDevTools from 'vite-plugin-vue-devtools' 13 | import { setupPrintBuildInfo } from './build/print-build-info' 14 | 15 | const lifecycle = process.env.npm_lifecycle_event 16 | // 获取npm命令 17 | export default defineConfig(({ _, mode }) => { 18 | const env = loadEnv(mode, process.cwd()) 19 | return { 20 | base: env.VITE_BASE_URL, 21 | plugins: [ 22 | vue(), 23 | setupPrintBuildInfo(), 24 | vueDevTools(), 25 | UnoCSS(), 26 | // visualizer(),//打包分析 27 | lifecycle === 'report' 28 | ? visualizer({ open: true, brotliSize: true, filename: 'report.html' }) 29 | : null, // 打包分析 30 | AutoImport({ 31 | include: [ 32 | // 导入目标文件类型 33 | /\.[tj]s(x|on)?$/, // .ts, .tsx, .js, .jsx .json 34 | /\.vue$/, 35 | /\.vue\?vue/, // .vue 36 | /\.md$/, // .md 37 | ], 38 | imports: [ 39 | // 预定义 40 | 'vue', // 自动导入 Vue 相关函数,如:ref, reactive, toRef 等 41 | 'pinia', 42 | '@vueuse/core', 43 | { 44 | '@antv/g6': [ 45 | ['default', 'G6'], // import G6 from "@antv/g6"; 46 | ], 47 | }, 48 | ], 49 | dts: 'src/types/auto-imports.d.ts', // 方案二:生成自动导入的auto-imports.d.ts声明文件, 解决 '找不到名称“Elxxx”' 报错 50 | resolvers: [ElementPlusResolver()], // 自动导入 Element Plus 相关函数,如:ElMessage, ElMessageBox... (带样式) 51 | }), 52 | Components({ 53 | resolvers: [ElementPlusResolver()], 54 | dts: 'src/types/auto-components.d.ts', 55 | dirs: ['src/components/sync'], 56 | }), 57 | VitePWA({ 58 | outDir: 'dist', 59 | manifest: { 60 | lang: 'zh-CN', 61 | name: 'json-viewer', 62 | short_name: 'json-viewer', 63 | scope: '/json-viewer/', 64 | start_url: '/json-viewer/', 65 | theme_color: '#ffffff', 66 | background_color: '#ffffff', 67 | icons: [ 68 | { 69 | src: '/json-viewer/logo_512.png', 70 | types: 'img/png', 71 | sizes: '512x512', 72 | purpose: 'any', 73 | }, 74 | { 75 | src: '/json-viewer/logo_192.png', 76 | types: 'img/png', 77 | sizes: '192x192', 78 | purpose: 'maskable', 79 | }, 80 | { 81 | src: '/json-viewer/logo_144.png', 82 | types: 'img/png', 83 | sizes: '144x144', 84 | purpose: 'any', 85 | }, 86 | { 87 | src: '/json-viewer/logo_120.png', 88 | types: 'img/png', 89 | sizes: '120x120', 90 | purpose: 'any', 91 | }, 92 | { 93 | src: '/json-viewer/logo_72.png', 94 | types: 'img/png', 95 | sizes: '72x72', 96 | purpose: 'maskable', 97 | }, 98 | ], 99 | }, 100 | }), 101 | ], 102 | // 指定@为src目录 103 | resolve: { 104 | // Vite路径别名配置 105 | alias: { 106 | '@': path.resolve(__dirname, 'src'), 107 | }, 108 | extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json'], 109 | }, 110 | eslintrc: { 111 | enabled: true, // Default `false` 112 | filepath: './.eslintrc-auto-import.json', // Default `./.eslintrc-auto-import.json` 113 | globalsPropValue: true, // Default `true`, (true | false | 'readonly' | 'readable' | 'writable' | 'writeable') 114 | }, 115 | esbuild: { 116 | pure: ['console'], 117 | }, 118 | server: { 119 | host: true, 120 | open: true, 121 | }, 122 | // 打包配置 123 | build: { 124 | outDir: env.VITE_OUTDIR, 125 | // 手动分包,把第三方库单独打包 126 | rollupOptions: { 127 | // external: ['@antv/g6'], 128 | output: { 129 | entryFileNames: 'entries/[name]-[hash].js', 130 | chunkFileNames: 'chunks/[name]-[hash].js', 131 | assetFileNames: 'assets/[ext]/[name]-[hash].[ext]', 132 | manualChunks(id) { 133 | if (id.includes('node_modules')) { 134 | if (id.includes('@antv/g6')) { 135 | return 'antv-g6' 136 | } 137 | if (id.includes('element-plus')) { 138 | return 'element-plus' 139 | } 140 | if (id.includes('codemirror')) { 141 | return 'codemirror' 142 | } 143 | // 默认所有node_modules库都放到vendor包中 144 | return 'vendor' 145 | } 146 | }, 147 | }, 148 | }, 149 | }, 150 | } 151 | }) 152 | -------------------------------------------------------------------------------- /src/utils/json-to-tree.ts: -------------------------------------------------------------------------------- 1 | import { COLORS_MAP } from '@/constants/colors' 2 | import { getWidth } from './get-width' 3 | import { cacheIdList, randomId } from './random-id' 4 | 5 | interface NodeItem { 6 | id: string 7 | content?: string 8 | name?: string 9 | children?: NodeItem[] 10 | style?: { 11 | collapsed?: boolean 12 | [key: string]: any 13 | } 14 | data: { 15 | width?: number 16 | height?: number 17 | badge?: 'object' | 'array' 18 | } 19 | obj?: Record 20 | } 21 | 22 | const PADDING = 10 23 | const LINE_HEIGHT = 20 24 | 25 | // 格式化值,保留字符串的双引号 26 | function formatValue(value: any): string { 27 | if (typeof value === 'string') { 28 | return `"${value}"` 29 | } 30 | return String(value) 31 | } 32 | 33 | // 创建基本节点结构 34 | function createBaseNode(content: string, type: string = 'object', width?: number, height: number = 40): NodeItem { 35 | const color = COLORS_MAP[type] 36 | return { 37 | id: randomId(), 38 | content, 39 | style: { 40 | collapsed: false, 41 | stroke: color, 42 | fill: color, 43 | }, 44 | data: { 45 | width: width || getWidth(content) + PADDING * 2, 46 | height, 47 | badge: type as 'object' | 'array', 48 | }, 49 | } 50 | } 51 | 52 | // 将对象属性分为基本类型和复杂类型 53 | interface CategorizeProperties { 54 | basicProps: [string, any][] 55 | complexProps: [string, any][] 56 | } 57 | function categorizeProperties(obj: Record): CategorizeProperties { 58 | const basicProps: [string, any][] = [] 59 | const complexProps: [string, any][] = [] 60 | 61 | Object.entries(obj).forEach(([key, value]) => { 62 | if (value === null || typeof value !== 'object') { 63 | basicProps.push([key, value]) 64 | } 65 | else { 66 | complexProps.push([key, value]) 67 | } 68 | }) 69 | 70 | return { basicProps, complexProps } 71 | } 72 | 73 | // 创建包含基本属性的节点 74 | function createBasicPropsNode(basicProps: [string, any][], formatFields: string[]): { basicNode: NodeItem, parsedNodes: NodeItem[] } { 75 | let maxWidth = 0 76 | const contentLines = [] 77 | const parsedNodes: NodeItem[] = [] 78 | 79 | for (const [k, v] of basicProps) { 80 | // 处理JSON字符串 81 | if (typeof v === 'string' && formatFields.includes(k)) { 82 | try { 83 | const parsedValue = JSON.parse(v) 84 | if (typeof parsedValue === 'object' && parsedValue !== null) { 85 | const parsedNode = createNode(k, parsedValue, formatFields) 86 | parsedNodes.push(parsedNode) 87 | continue // 跳过添加到contentLines 88 | } 89 | } 90 | catch { 91 | console.warn('解析失败,作为普通字符串处理', v) 92 | } 93 | } 94 | 95 | const line = `${k}: ${formatValue(v)}` 96 | contentLines.push(line) 97 | const lineWidth = getWidth(line) 98 | maxWidth = Math.max(maxWidth, lineWidth) 99 | } 100 | 101 | const basicContent = contentLines.join('\n') 102 | const width = maxWidth + PADDING * 2 103 | const height = contentLines.length * LINE_HEIGHT + PADDING * 2 104 | 105 | const basicNode = createBaseNode(basicContent, 'other', width, height) 106 | // 如果是多行内容,把原始对象放在obj属性上 107 | if (contentLines.length > 1) { 108 | // 筛选不是formatFields的属性 109 | const obj = Object.fromEntries(basicProps.filter(([k]) => !formatFields.includes(k))) 110 | basicNode.obj = obj 111 | } 112 | 113 | return { basicNode, parsedNodes } 114 | } 115 | 116 | // 处理值并创建节点 117 | function createNode(key: string, value: any, formatFields: string[] = []): NodeItem { 118 | let content = String(key) 119 | let type = 'object' 120 | 121 | // 处理基本类型 122 | if (value === null || typeof value !== 'object') { 123 | content = `${key}: ${formatValue(value)}` 124 | type = typeof value 125 | return createBaseNode(content, type) 126 | } 127 | 128 | // 处理数组 129 | if (Array.isArray(value)) { 130 | const node = createBaseNode(content, 'array') 131 | node.children = value.map((item, index) => createNode(String(index), item, formatFields)) 132 | return node 133 | } 134 | 135 | // 处理对象 136 | if (typeof value === 'object' && value !== null) { 137 | const node = createBaseNode(content, 'object') 138 | const { basicProps, complexProps } = categorizeProperties(value) 139 | 140 | // 创建子节点 141 | node.children = [] 142 | 143 | // 如果有基础类型属性,创建一个基础属性节点 144 | if (basicProps.length > 0) { 145 | const { basicNode, parsedNodes } = createBasicPropsNode(basicProps, formatFields) 146 | 147 | if (basicNode.content && basicNode.content.trim() !== '') { 148 | node.children.push(basicNode) 149 | } 150 | 151 | // 添加解析出的JSON节点 152 | if (parsedNodes.length > 0) { 153 | node.children.push(...parsedNodes) 154 | } 155 | } 156 | 157 | // 添加复杂类型节点 158 | complexProps.forEach(([k, v]) => { 159 | node.children.push(createNode(k, v, formatFields)) 160 | }) 161 | 162 | return node 163 | } 164 | 165 | // 处理JSON字符串 166 | if (typeof value === 'string' && formatFields.includes(key)) { 167 | try { 168 | const parsedValue = JSON.parse(value) 169 | if (typeof parsedValue === 'object' && parsedValue !== null) { 170 | const parsedNode = createNode(key, parsedValue, formatFields) 171 | return parsedNode 172 | } 173 | } 174 | catch { 175 | console.warn('解析失败,作为普通字符串处理', value) 176 | } 177 | } 178 | 179 | return createBaseNode(content, type) 180 | } 181 | 182 | // 处理数据结构的入口函数 183 | export function jsonToTree(data: Record, formatFields: string[] = []): NodeItem { 184 | // 重置ID缓存 185 | cacheIdList.length = 0 186 | 187 | // 创建根节点 188 | const rootNode: NodeItem = { 189 | id: 'ROOT', 190 | name: 'ROOT', 191 | content: 'ROOT', 192 | style: { 193 | collapsed: false, 194 | stroke: COLORS_MAP.object, 195 | fill: COLORS_MAP.object, 196 | }, 197 | data: { 198 | width: 56, 199 | height: 56, 200 | badge: 'object', 201 | }, 202 | } 203 | 204 | if (!data || Object.keys(data).length === 0) { 205 | return {} as NodeItem 206 | } 207 | 208 | // 分类属性 209 | const { basicProps, complexProps } = categorizeProperties(data) 210 | 211 | // 创建子节点 212 | rootNode.children = [] 213 | 214 | // 如果有基础类型属性,创建一个基础属性节点 215 | if (basicProps.length > 0) { 216 | const { basicNode, parsedNodes } = createBasicPropsNode(basicProps, formatFields) 217 | 218 | if (basicNode.content && basicNode.content.trim() !== '') { 219 | rootNode.children.push(basicNode) 220 | } 221 | 222 | // 添加解析出的JSON节点 223 | if (parsedNodes.length > 0) { 224 | rootNode.children.push(...parsedNodes) 225 | } 226 | } 227 | 228 | // 添加复杂类型节点 229 | complexProps.forEach(([k, v]) => { 230 | rootNode.children.push(createNode(k, v, formatFields)) 231 | }) 232 | 233 | return rootNode 234 | } 235 | -------------------------------------------------------------------------------- /src/store/module/graph.ts: -------------------------------------------------------------------------------- 1 | import { Graph, GraphEvent, NodeEvent, treeToGraphData } from '@antv/g6' 2 | import { compressToEncodedURIComponent as encode } from 'lz-string' 3 | import { LAYOUTS } from '@/constants' 4 | import { formatLayoutConfig, jsonToTree, saveImage } from '@/utils' 5 | import { queryKey, useCodeStore } from './code' 6 | import { useGlobalStore } from './global' 7 | 8 | const baseUrl = import.meta.env.VITE_BASE_URL as string 9 | const url = new URL(baseUrl, window.location.origin) 10 | 11 | export const useGraphStore = defineStore('graph', () => { 12 | const { parsedJson, rawCode } = toRefs(useCodeStore()) 13 | const { isDark } = toRefs(useGlobalStore()) 14 | const [isExpandNode, toggleNode] = useToggle(true) 15 | const focusCount = ref(0) 16 | const keyword = ref('') 17 | const fields = ref(['result']) 18 | const jsonCanvasRef = ref(null) 19 | const { width, height } = useElementSize(jsonCanvasRef) 20 | const ratio = ref(1) 21 | const ratioText = computed(() => `${(ratio.value * 100).toFixed(2)}%`) 22 | const isFirstLoad = ref(true) 23 | const [autoRender, toggleExecuteMode] = useToggle(true) 24 | const activeLayout = ref('mindmap') 25 | const layoutList = reactive(LAYOUTS) 26 | const activeConfig = computed(() => layoutList[activeLayout.value]) 27 | // 节点详情状态 28 | const nodeDetailVisible = ref(false) 29 | const nodeDetail = ref({}) 30 | 31 | // 图实例 32 | let graph: Graph | null = null 33 | 34 | const render = useDebounceFn(_render, 300) 35 | /** 36 | * 渲染JSON数据到图形 (内部实现) 37 | */ 38 | function _render() { 39 | if (!parsedJson.value || !jsonCanvasRef.value || !jsonCanvasRef.value.offsetParent) { 40 | return 41 | } 42 | 43 | try { 44 | // 确保图实例已初始化 45 | if (!graph) { 46 | graph = initGraph(jsonCanvasRef.value) 47 | if (!graph) 48 | return 49 | } 50 | 51 | // 数据处理与渲染 52 | graph.clear() 53 | const tree = jsonToTree(parsedJson.value, fields.value) 54 | const treeData = treeToGraphData(tree) 55 | graph.setData(treeData) 56 | 57 | // 使用async/await等待渲染完成 58 | graph.render().then(() => { 59 | // 渲染完成后,如果有关键词,执行搜索 60 | if (keyword.value) { 61 | if (graph) { 62 | focusNode(keyword.value) 63 | } 64 | } 65 | }).catch((error) => { 66 | console.error('图形渲染失败:', error) 67 | }) 68 | } 69 | catch (error) { 70 | console.error('渲染图失败:', error) 71 | } 72 | } 73 | 74 | /** 75 | * 初始化图实例 76 | */ 77 | function initGraph(container: HTMLElement) { 78 | if (graph) 79 | return graph 80 | 81 | if (!container || !container.offsetParent) { 82 | console.error('容器元素无效或未挂载到DOM') 83 | return null 84 | } 85 | 86 | try { 87 | const layout = formatLayoutConfig(activeConfig.value) 88 | 89 | graph = new Graph({ 90 | container, 91 | autoFit: 'view', 92 | theme: isDark.value ? 'dark' : 'light', 93 | padding: [30, 30, 30, 30], 94 | zoomRange: [0.1, 3], 95 | animation: { 96 | duration: 300, // 动画持续时间(毫秒) 97 | easing: 'ease-in-out', // 先慢后快的动画曲线 98 | }, 99 | layout, 100 | node: { 101 | type: 'flow-rect', 102 | style: { 103 | cursor: 'pointer', 104 | size: (d) => { 105 | const { data } = d 106 | return [data.width, data.height] as [number, number] 107 | }, 108 | ports: [{ placement: 'left' }, { placement: 'right' }], 109 | radius: 4, 110 | fillOpacity: 0.1, 111 | lineWidth: 1, 112 | }, 113 | state: { 114 | focused: { 115 | haloStroke: '#4ac666', 116 | halo: true, 117 | stroke: '#4ac666', 118 | }, 119 | }, 120 | }, 121 | edge: { 122 | type: 'cubic-horizontal', 123 | style: { 124 | strokeOpacity: 0.5, 125 | }, 126 | }, 127 | behaviors: ['zoom-canvas', 'drag-canvas'], 128 | }) 129 | 130 | bindEvents() 131 | return graph 132 | } 133 | catch (error) { 134 | console.error('初始化图实例出错:', error) 135 | return null 136 | } 137 | } 138 | 139 | /** 140 | * 绑定图事件 141 | */ 142 | function bindEvents() { 143 | if (!graph) 144 | return 145 | 146 | // 监听画布缩放事件 147 | graph.on(GraphEvent.AFTER_TRANSFORM, () => { 148 | if (!graph) 149 | return 150 | updateRatio() 151 | }) 152 | 153 | // 节点点击事件 154 | graph.on(NodeEvent.CLICK, (e: any) => { 155 | if (!graph) 156 | return 157 | 158 | const { targetType, target } = e 159 | if (targetType === 'node') { 160 | nodeDetail.value = graph.getNodeData(target.id) 161 | nodeDetailVisible.value = true 162 | } 163 | }) 164 | } 165 | 166 | /** 167 | * 更新缩放比例 168 | */ 169 | function updateRatio() { 170 | if (!graph) { 171 | ratio.value = 1 172 | return 173 | } 174 | 175 | try { 176 | ratio.value = graph.getZoom() || 1 177 | } 178 | catch { 179 | // 出现异常时设置默认值 180 | ratio.value = 1 181 | } 182 | } 183 | 184 | /** 185 | * 按比例缩放 186 | */ 187 | function zoomBy(value: number) { 188 | if (!graph) 189 | return 190 | graph.zoomBy(value) 191 | updateRatio() 192 | } 193 | 194 | /** 195 | * 适配视图 196 | */ 197 | function fitView() { 198 | if (!graph) 199 | return 200 | 201 | graph.fitView( 202 | { 203 | when: 'always', 204 | direction: 'both', 205 | }, 206 | { 207 | duration: 1000, 208 | }, 209 | ) 210 | updateRatio() 211 | } 212 | 213 | /** 214 | * 搜索并聚焦包含关键词的节点 215 | */ 216 | function focusNode(newKeyword?: string): void { 217 | console.warn('执行focusNode', { newKeyword, hasGraph: !!graph }) 218 | 219 | if (!graph) { 220 | console.warn('图形未初始化,无法执行搜索') 221 | return 222 | } 223 | 224 | // 确保图形已经渲染完成 225 | // 注意:不要依赖graph.rendered属性,它可能不可靠 226 | 227 | // 切换节点状态的工具函数 228 | function toggleNodesState(nodes: any[], state: Array = []) { 229 | if (!nodes || !nodes.length) 230 | return 231 | 232 | try { 233 | const stateMap = nodes.reduce((acc, node) => { 234 | acc[node.id] = state 235 | return acc 236 | }, {}) 237 | graph.setElementState(stateMap, true) 238 | } 239 | catch (error) { 240 | console.warn('设置节点状态失败:', error) 241 | } 242 | } 243 | 244 | // 清除已有的聚焦状态 245 | const clearFocusedNodes = () => { 246 | try { 247 | const focusedNodes = graph.getElementDataByState('node', 'focused') 248 | if (focusedNodes && focusedNodes.length) 249 | toggleNodesState(focusedNodes, []) 250 | } 251 | catch (error) { 252 | console.warn('清除节点聚焦状态失败:', error) 253 | } 254 | } 255 | 256 | clearFocusedNodes() 257 | 258 | // 没有关键词时直接重置计数并返回 259 | if (!newKeyword) { 260 | focusCount.value = 0 261 | fitView() 262 | return 263 | } 264 | 265 | try { 266 | // 获取节点数据 267 | let nodes = [] 268 | try { 269 | nodes = graph.getNodeData() || [] 270 | } 271 | catch (error) { 272 | console.warn('获取节点数据失败:', error) 273 | nodes = [] 274 | } 275 | 276 | // 如果没有节点数据,可能图形还未完全渲染 277 | if (!nodes.length) { 278 | console.warn('图形没有节点数据,可能渲染未完成') 279 | focusCount.value = 0 280 | 281 | // 尝试延迟再次执行 282 | setTimeout(() => { 283 | console.warn('延迟重试搜索') 284 | focusNode(newKeyword) 285 | }, 500) 286 | return 287 | } 288 | 289 | const lowerKeyword = newKeyword.toLowerCase() 290 | 291 | // 查找包含关键字的节点 292 | const foundNodes = nodes.filter((node) => { 293 | if (!node || !node.data) 294 | return false 295 | return String(node.content).toLowerCase().includes(lowerKeyword) 296 | }) 297 | 298 | // 更新匹配计数 299 | focusCount.value = foundNodes.length 300 | console.warn('找到匹配节点数量:', foundNodes.length) 301 | 302 | const foundCount = foundNodes.length 303 | 304 | if (foundCount > 0) { 305 | toggleNodesState(foundNodes, ['focused']) 306 | 307 | // 聚焦到节点 308 | if (foundCount === 1) { 309 | // 单个节点时,直接聚焦到该节点 310 | try { 311 | graph.focusElement(foundNodes[0].id, { 312 | duration: 300, 313 | easing: 'ease-in-out', 314 | }) 315 | } 316 | catch (error) { 317 | console.warn('聚焦到节点失败:', error) 318 | fitView() 319 | } 320 | } 321 | else { 322 | fitView() 323 | } 324 | } 325 | else { 326 | fitView() 327 | } 328 | } 329 | catch (error) { 330 | console.warn('节点聚焦失败:', error) 331 | focusCount.value = 0 332 | } 333 | } 334 | 335 | /** 336 | * 导出图像 337 | */ 338 | function exportImage(name: string, type: string) { 339 | if (!graph) 340 | return 341 | saveImage(graph, name, type) 342 | } 343 | 344 | /** 345 | * 销毁图实例 346 | */ 347 | function destroyGraph() { 348 | try { 349 | if (graph) { 350 | graph.off() 351 | graph.destroy() 352 | graph = null 353 | } 354 | } 355 | catch (error) { 356 | console.warn('销毁图实例出错:', error) 357 | graph = null 358 | } 359 | } 360 | 361 | watch(parsedJson, (json) => { 362 | if (autoRender.value) { 363 | render() 364 | } 365 | isFirstLoad.value = false 366 | 367 | if (Object.keys(json).length === 0) { 368 | url.searchParams.delete(queryKey) 369 | window.history.replaceState('', '', `${url.pathname}`) 370 | } 371 | else if (!isFirstLoad.value) { 372 | url.searchParams.set(queryKey, encode(rawCode.value)) 373 | window.history.replaceState('', '', `${url.pathname}${url.search}`) 374 | } 375 | }) 376 | 377 | watchDebounced(autoRender, () => render(), { debounce: 300 }) 378 | 379 | // 监听主题变化 380 | watch(isDark, () => render()) 381 | 382 | // 监听字段变化 383 | watch(fields, () => autoRender.value && render(), { deep: true }) 384 | 385 | // 监听搜索关键词变化 386 | watchDebounced(() => keyword.value, (newKeyword) => { 387 | // 确保图形已经渲染完成 388 | if (graph && graph.rendered) { 389 | focusNode(newKeyword) 390 | } 391 | }, { debounce: 300 }) 392 | 393 | // 监听布局配置变化 394 | watchDebounced(() => activeConfig.value, () => { 395 | if (graph) { 396 | const formattedLayout = formatLayoutConfig(activeConfig.value) 397 | graph.layout(formattedLayout) 398 | 399 | // 重新布局后居中展示 400 | graph.fitView({ 401 | when: 'always', 402 | direction: 'both', 403 | }) 404 | } 405 | }, { debounce: 400, deep: true }) 406 | 407 | // 监听暗色模式变化 408 | watch(isDark, () => { 409 | if (graph) { 410 | graph.setTheme(isDark.value ? 'dark' : 'light') 411 | } 412 | }) 413 | 414 | // 监听节点展开/收起状态 415 | watch(isExpandNode, (val) => { 416 | if (!graph) 417 | return 418 | 419 | val 420 | ? graph.expandElement('ROOT', { 421 | align: true, 422 | animation: { 423 | duration: 300, // 300毫秒 424 | easing: 'ease-in-out', // 先慢后快 425 | }, 426 | }) 427 | : graph.collapseElement('ROOT', { 428 | align: true, 429 | animation: { 430 | duration: 300, // 300毫秒 431 | easing: 'ease-in-out', // 先慢后快 432 | }, 433 | }) 434 | }) 435 | 436 | // 监听容器大小变化 437 | watchDebounced([width, height], ([w, h]) => { 438 | if (graph) { 439 | graph.setSize(w, h) 440 | } 441 | }, { debounce: 300 }) 442 | 443 | return { 444 | // 状态 445 | ratio, 446 | ratioText, 447 | isExpandNode, 448 | nodeDetailVisible, 449 | nodeDetail, 450 | activeLayout, 451 | activeConfig, 452 | keyword, 453 | fields, 454 | focusCount, 455 | autoRender, 456 | jsonCanvasRef, 457 | 458 | // 方法 459 | initGraph, 460 | destroyGraph, 461 | render, 462 | zoomBy, 463 | fitView, 464 | exportImage, 465 | toggleNode, 466 | toggleExecuteMode, 467 | } 468 | }, { persist: true }) 469 | -------------------------------------------------------------------------------- /src/types/auto-imports.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* prettier-ignore */ 3 | // @ts-nocheck 4 | // noinspection JSUnusedGlobalSymbols 5 | // Generated by unplugin-auto-import 6 | // biome-ignore lint: disable 7 | export {} 8 | declare global { 9 | const EffectScope: typeof import('vue').EffectScope 10 | const ElNotification: typeof import('element-plus/es').ElNotification 11 | const G6: typeof import('@antv/g6').default 12 | const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate 13 | const asyncComputed: typeof import('@vueuse/core').asyncComputed 14 | const autoResetRef: typeof import('@vueuse/core').autoResetRef 15 | const computed: typeof import('vue').computed 16 | const computedAsync: typeof import('@vueuse/core').computedAsync 17 | const computedEager: typeof import('@vueuse/core').computedEager 18 | const computedInject: typeof import('@vueuse/core').computedInject 19 | const computedWithControl: typeof import('@vueuse/core').computedWithControl 20 | const controlledComputed: typeof import('@vueuse/core').controlledComputed 21 | const controlledRef: typeof import('@vueuse/core').controlledRef 22 | const createApp: typeof import('vue').createApp 23 | const createEventHook: typeof import('@vueuse/core').createEventHook 24 | const createGlobalState: typeof import('@vueuse/core').createGlobalState 25 | const createInjectionState: typeof import('@vueuse/core').createInjectionState 26 | const createPinia: typeof import('pinia').createPinia 27 | const createReactiveFn: typeof import('@vueuse/core').createReactiveFn 28 | const createRef: typeof import('@vueuse/core').createRef 29 | const createReusableTemplate: typeof import('@vueuse/core').createReusableTemplate 30 | const createSharedComposable: typeof import('@vueuse/core').createSharedComposable 31 | const createTemplatePromise: typeof import('@vueuse/core').createTemplatePromise 32 | const createUnrefFn: typeof import('@vueuse/core').createUnrefFn 33 | const customRef: typeof import('vue').customRef 34 | const debouncedRef: typeof import('@vueuse/core').debouncedRef 35 | const debouncedWatch: typeof import('@vueuse/core').debouncedWatch 36 | const defineAsyncComponent: typeof import('vue').defineAsyncComponent 37 | const defineComponent: typeof import('vue').defineComponent 38 | const defineStore: typeof import('pinia').defineStore 39 | const eagerComputed: typeof import('@vueuse/core').eagerComputed 40 | const effectScope: typeof import('vue').effectScope 41 | const extendRef: typeof import('@vueuse/core').extendRef 42 | const getActivePinia: typeof import('pinia').getActivePinia 43 | const getCurrentInstance: typeof import('vue').getCurrentInstance 44 | const getCurrentScope: typeof import('vue').getCurrentScope 45 | const getCurrentWatcher: typeof import('vue').getCurrentWatcher 46 | const h: typeof import('vue').h 47 | const ignorableWatch: typeof import('@vueuse/core').ignorableWatch 48 | const inject: typeof import('vue').inject 49 | const injectLocal: typeof import('@vueuse/core').injectLocal 50 | const isDefined: typeof import('@vueuse/core').isDefined 51 | const isProxy: typeof import('vue').isProxy 52 | const isReactive: typeof import('vue').isReactive 53 | const isReadonly: typeof import('vue').isReadonly 54 | const isRef: typeof import('vue').isRef 55 | const isShallow: typeof import('vue').isShallow 56 | const makeDestructurable: typeof import('@vueuse/core').makeDestructurable 57 | const manualResetRef: typeof import('@vueuse/core').manualResetRef 58 | const mapActions: typeof import('pinia').mapActions 59 | const mapGetters: typeof import('pinia').mapGetters 60 | const mapState: typeof import('pinia').mapState 61 | const mapStores: typeof import('pinia').mapStores 62 | const mapWritableState: typeof import('pinia').mapWritableState 63 | const markRaw: typeof import('vue').markRaw 64 | const nextTick: typeof import('vue').nextTick 65 | const onActivated: typeof import('vue').onActivated 66 | const onBeforeMount: typeof import('vue').onBeforeMount 67 | const onBeforeUnmount: typeof import('vue').onBeforeUnmount 68 | const onBeforeUpdate: typeof import('vue').onBeforeUpdate 69 | const onClickOutside: typeof import('@vueuse/core').onClickOutside 70 | const onDeactivated: typeof import('vue').onDeactivated 71 | const onElementRemoval: typeof import('@vueuse/core').onElementRemoval 72 | const onErrorCaptured: typeof import('vue').onErrorCaptured 73 | const onKeyStroke: typeof import('@vueuse/core').onKeyStroke 74 | const onLongPress: typeof import('@vueuse/core').onLongPress 75 | const onMounted: typeof import('vue').onMounted 76 | const onRenderTracked: typeof import('vue').onRenderTracked 77 | const onRenderTriggered: typeof import('vue').onRenderTriggered 78 | const onScopeDispose: typeof import('vue').onScopeDispose 79 | const onServerPrefetch: typeof import('vue').onServerPrefetch 80 | const onStartTyping: typeof import('@vueuse/core').onStartTyping 81 | const onUnmounted: typeof import('vue').onUnmounted 82 | const onUpdated: typeof import('vue').onUpdated 83 | const onWatcherCleanup: typeof import('vue').onWatcherCleanup 84 | const pausableWatch: typeof import('@vueuse/core').pausableWatch 85 | const provide: typeof import('vue').provide 86 | const provideLocal: typeof import('@vueuse/core').provideLocal 87 | const reactify: typeof import('@vueuse/core').reactify 88 | const reactifyObject: typeof import('@vueuse/core').reactifyObject 89 | const reactive: typeof import('vue').reactive 90 | const reactiveComputed: typeof import('@vueuse/core').reactiveComputed 91 | const reactiveOmit: typeof import('@vueuse/core').reactiveOmit 92 | const reactivePick: typeof import('@vueuse/core').reactivePick 93 | const readonly: typeof import('vue').readonly 94 | const ref: typeof import('vue').ref 95 | const refAutoReset: typeof import('@vueuse/core').refAutoReset 96 | const refDebounced: typeof import('@vueuse/core').refDebounced 97 | const refDefault: typeof import('@vueuse/core').refDefault 98 | const refManualReset: typeof import('@vueuse/core').refManualReset 99 | const refThrottled: typeof import('@vueuse/core').refThrottled 100 | const refWithControl: typeof import('@vueuse/core').refWithControl 101 | const resolveComponent: typeof import('vue').resolveComponent 102 | const resolveRef: typeof import('@vueuse/core').resolveRef 103 | const resolveUnref: typeof import('@vueuse/core')['resolveUnref'] 104 | const setActivePinia: typeof import('pinia').setActivePinia 105 | const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix 106 | const shallowReactive: typeof import('vue').shallowReactive 107 | const shallowReadonly: typeof import('vue').shallowReadonly 108 | const shallowRef: typeof import('vue').shallowRef 109 | const storeToRefs: typeof import('pinia').storeToRefs 110 | const syncRef: typeof import('@vueuse/core').syncRef 111 | const syncRefs: typeof import('@vueuse/core').syncRefs 112 | const templateRef: typeof import('@vueuse/core').templateRef 113 | const throttledRef: typeof import('@vueuse/core').throttledRef 114 | const throttledWatch: typeof import('@vueuse/core').throttledWatch 115 | const toRaw: typeof import('vue').toRaw 116 | const toReactive: typeof import('@vueuse/core').toReactive 117 | const toRef: typeof import('vue').toRef 118 | const toRefs: typeof import('vue').toRefs 119 | const toValue: typeof import('vue').toValue 120 | const triggerRef: typeof import('vue').triggerRef 121 | const tryOnBeforeMount: typeof import('@vueuse/core').tryOnBeforeMount 122 | const tryOnBeforeUnmount: typeof import('@vueuse/core').tryOnBeforeUnmount 123 | const tryOnMounted: typeof import('@vueuse/core').tryOnMounted 124 | const tryOnScopeDispose: typeof import('@vueuse/core').tryOnScopeDispose 125 | const tryOnUnmounted: typeof import('@vueuse/core').tryOnUnmounted 126 | const unref: typeof import('vue').unref 127 | const unrefElement: typeof import('@vueuse/core').unrefElement 128 | const until: typeof import('@vueuse/core').until 129 | const useActiveElement: typeof import('@vueuse/core').useActiveElement 130 | const useAnimate: typeof import('@vueuse/core').useAnimate 131 | const useArrayDifference: typeof import('@vueuse/core').useArrayDifference 132 | const useArrayEvery: typeof import('@vueuse/core').useArrayEvery 133 | const useArrayFilter: typeof import('@vueuse/core').useArrayFilter 134 | const useArrayFind: typeof import('@vueuse/core').useArrayFind 135 | const useArrayFindIndex: typeof import('@vueuse/core').useArrayFindIndex 136 | const useArrayFindLast: typeof import('@vueuse/core').useArrayFindLast 137 | const useArrayIncludes: typeof import('@vueuse/core').useArrayIncludes 138 | const useArrayJoin: typeof import('@vueuse/core').useArrayJoin 139 | const useArrayMap: typeof import('@vueuse/core').useArrayMap 140 | const useArrayReduce: typeof import('@vueuse/core').useArrayReduce 141 | const useArraySome: typeof import('@vueuse/core').useArraySome 142 | const useArrayUnique: typeof import('@vueuse/core').useArrayUnique 143 | const useAsyncQueue: typeof import('@vueuse/core').useAsyncQueue 144 | const useAsyncState: typeof import('@vueuse/core').useAsyncState 145 | const useAttrs: typeof import('vue').useAttrs 146 | const useBase64: typeof import('@vueuse/core').useBase64 147 | const useBattery: typeof import('@vueuse/core').useBattery 148 | const useBluetooth: typeof import('@vueuse/core').useBluetooth 149 | const useBreakpoints: typeof import('@vueuse/core').useBreakpoints 150 | const useBroadcastChannel: typeof import('@vueuse/core').useBroadcastChannel 151 | const useBrowserLocation: typeof import('@vueuse/core').useBrowserLocation 152 | const useCached: typeof import('@vueuse/core').useCached 153 | const useClipboard: typeof import('@vueuse/core').useClipboard 154 | const useClipboardItems: typeof import('@vueuse/core').useClipboardItems 155 | const useCloned: typeof import('@vueuse/core').useCloned 156 | const useColorMode: typeof import('@vueuse/core').useColorMode 157 | const useConfirmDialog: typeof import('@vueuse/core').useConfirmDialog 158 | const useCountdown: typeof import('@vueuse/core').useCountdown 159 | const useCounter: typeof import('@vueuse/core').useCounter 160 | const useCssModule: typeof import('vue').useCssModule 161 | const useCssVar: typeof import('@vueuse/core').useCssVar 162 | const useCssVars: typeof import('vue').useCssVars 163 | const useCurrentElement: typeof import('@vueuse/core').useCurrentElement 164 | const useCycleList: typeof import('@vueuse/core').useCycleList 165 | const useDark: typeof import('@vueuse/core').useDark 166 | const useDateFormat: typeof import('@vueuse/core').useDateFormat 167 | const useDebounce: typeof import('@vueuse/core').useDebounce 168 | const useDebounceFn: typeof import('@vueuse/core').useDebounceFn 169 | const useDebouncedRefHistory: typeof import('@vueuse/core').useDebouncedRefHistory 170 | const useDeviceMotion: typeof import('@vueuse/core').useDeviceMotion 171 | const useDeviceOrientation: typeof import('@vueuse/core').useDeviceOrientation 172 | const useDevicePixelRatio: typeof import('@vueuse/core').useDevicePixelRatio 173 | const useDevicesList: typeof import('@vueuse/core').useDevicesList 174 | const useDisplayMedia: typeof import('@vueuse/core').useDisplayMedia 175 | const useDocumentVisibility: typeof import('@vueuse/core').useDocumentVisibility 176 | const useDraggable: typeof import('@vueuse/core').useDraggable 177 | const useDropZone: typeof import('@vueuse/core').useDropZone 178 | const useElementBounding: typeof import('@vueuse/core').useElementBounding 179 | const useElementByPoint: typeof import('@vueuse/core').useElementByPoint 180 | const useElementHover: typeof import('@vueuse/core').useElementHover 181 | const useElementSize: typeof import('@vueuse/core').useElementSize 182 | const useElementVisibility: typeof import('@vueuse/core').useElementVisibility 183 | const useEventBus: typeof import('@vueuse/core').useEventBus 184 | const useEventListener: typeof import('@vueuse/core').useEventListener 185 | const useEventSource: typeof import('@vueuse/core').useEventSource 186 | const useEyeDropper: typeof import('@vueuse/core').useEyeDropper 187 | const useFavicon: typeof import('@vueuse/core').useFavicon 188 | const useFetch: typeof import('@vueuse/core').useFetch 189 | const useFileDialog: typeof import('@vueuse/core').useFileDialog 190 | const useFileSystemAccess: typeof import('@vueuse/core').useFileSystemAccess 191 | const useFocus: typeof import('@vueuse/core').useFocus 192 | const useFocusWithin: typeof import('@vueuse/core').useFocusWithin 193 | const useFps: typeof import('@vueuse/core').useFps 194 | const useFullscreen: typeof import('@vueuse/core').useFullscreen 195 | const useGamepad: typeof import('@vueuse/core').useGamepad 196 | const useGeolocation: typeof import('@vueuse/core').useGeolocation 197 | const useId: typeof import('vue').useId 198 | const useIdle: typeof import('@vueuse/core').useIdle 199 | const useImage: typeof import('@vueuse/core').useImage 200 | const useInfiniteScroll: typeof import('@vueuse/core').useInfiniteScroll 201 | const useIntersectionObserver: typeof import('@vueuse/core').useIntersectionObserver 202 | const useInterval: typeof import('@vueuse/core').useInterval 203 | const useIntervalFn: typeof import('@vueuse/core').useIntervalFn 204 | const useKeyModifier: typeof import('@vueuse/core').useKeyModifier 205 | const useLastChanged: typeof import('@vueuse/core').useLastChanged 206 | const useLocalStorage: typeof import('@vueuse/core').useLocalStorage 207 | const useMagicKeys: typeof import('@vueuse/core').useMagicKeys 208 | const useManualRefHistory: typeof import('@vueuse/core').useManualRefHistory 209 | const useMediaControls: typeof import('@vueuse/core').useMediaControls 210 | const useMediaQuery: typeof import('@vueuse/core').useMediaQuery 211 | const useMemoize: typeof import('@vueuse/core').useMemoize 212 | const useMemory: typeof import('@vueuse/core').useMemory 213 | const useModel: typeof import('vue').useModel 214 | const useMounted: typeof import('@vueuse/core').useMounted 215 | const useMouse: typeof import('@vueuse/core').useMouse 216 | const useMouseInElement: typeof import('@vueuse/core').useMouseInElement 217 | const useMousePressed: typeof import('@vueuse/core').useMousePressed 218 | const useMutationObserver: typeof import('@vueuse/core').useMutationObserver 219 | const useNavigatorLanguage: typeof import('@vueuse/core').useNavigatorLanguage 220 | const useNetwork: typeof import('@vueuse/core').useNetwork 221 | const useNow: typeof import('@vueuse/core').useNow 222 | const useObjectUrl: typeof import('@vueuse/core').useObjectUrl 223 | const useOffsetPagination: typeof import('@vueuse/core').useOffsetPagination 224 | const useOnline: typeof import('@vueuse/core').useOnline 225 | const usePageLeave: typeof import('@vueuse/core').usePageLeave 226 | const useParallax: typeof import('@vueuse/core').useParallax 227 | const useParentElement: typeof import('@vueuse/core').useParentElement 228 | const usePerformanceObserver: typeof import('@vueuse/core').usePerformanceObserver 229 | const usePermission: typeof import('@vueuse/core').usePermission 230 | const usePointer: typeof import('@vueuse/core').usePointer 231 | const usePointerLock: typeof import('@vueuse/core').usePointerLock 232 | const usePointerSwipe: typeof import('@vueuse/core').usePointerSwipe 233 | const usePreferredColorScheme: typeof import('@vueuse/core').usePreferredColorScheme 234 | const usePreferredContrast: typeof import('@vueuse/core').usePreferredContrast 235 | const usePreferredDark: typeof import('@vueuse/core').usePreferredDark 236 | const usePreferredLanguages: typeof import('@vueuse/core').usePreferredLanguages 237 | const usePreferredReducedMotion: typeof import('@vueuse/core').usePreferredReducedMotion 238 | const usePreferredReducedTransparency: typeof import('@vueuse/core').usePreferredReducedTransparency 239 | const usePrevious: typeof import('@vueuse/core').usePrevious 240 | const useRafFn: typeof import('@vueuse/core').useRafFn 241 | const useRefHistory: typeof import('@vueuse/core').useRefHistory 242 | const useResizeObserver: typeof import('@vueuse/core').useResizeObserver 243 | const useSSRWidth: typeof import('@vueuse/core').useSSRWidth 244 | const useScreenOrientation: typeof import('@vueuse/core').useScreenOrientation 245 | const useScreenSafeArea: typeof import('@vueuse/core').useScreenSafeArea 246 | const useScriptTag: typeof import('@vueuse/core').useScriptTag 247 | const useScroll: typeof import('@vueuse/core').useScroll 248 | const useScrollLock: typeof import('@vueuse/core').useScrollLock 249 | const useSessionStorage: typeof import('@vueuse/core').useSessionStorage 250 | const useShare: typeof import('@vueuse/core').useShare 251 | const useSlots: typeof import('vue').useSlots 252 | const useSorted: typeof import('@vueuse/core').useSorted 253 | const useSpeechRecognition: typeof import('@vueuse/core').useSpeechRecognition 254 | const useSpeechSynthesis: typeof import('@vueuse/core').useSpeechSynthesis 255 | const useStepper: typeof import('@vueuse/core').useStepper 256 | const useStorage: typeof import('@vueuse/core').useStorage 257 | const useStorageAsync: typeof import('@vueuse/core').useStorageAsync 258 | const useStyleTag: typeof import('@vueuse/core').useStyleTag 259 | const useSupported: typeof import('@vueuse/core').useSupported 260 | const useSwipe: typeof import('@vueuse/core').useSwipe 261 | const useTemplateRef: typeof import('vue').useTemplateRef 262 | const useTemplateRefsList: typeof import('@vueuse/core').useTemplateRefsList 263 | const useTextDirection: typeof import('@vueuse/core').useTextDirection 264 | const useTextSelection: typeof import('@vueuse/core').useTextSelection 265 | const useTextareaAutosize: typeof import('@vueuse/core').useTextareaAutosize 266 | const useThrottle: typeof import('@vueuse/core').useThrottle 267 | const useThrottleFn: typeof import('@vueuse/core').useThrottleFn 268 | const useThrottledRefHistory: typeof import('@vueuse/core').useThrottledRefHistory 269 | const useTimeAgo: typeof import('@vueuse/core').useTimeAgo 270 | const useTimeAgoIntl: typeof import('@vueuse/core').useTimeAgoIntl 271 | const useTimeout: typeof import('@vueuse/core').useTimeout 272 | const useTimeoutFn: typeof import('@vueuse/core').useTimeoutFn 273 | const useTimeoutPoll: typeof import('@vueuse/core').useTimeoutPoll 274 | const useTimestamp: typeof import('@vueuse/core').useTimestamp 275 | const useTitle: typeof import('@vueuse/core').useTitle 276 | const useToNumber: typeof import('@vueuse/core').useToNumber 277 | const useToString: typeof import('@vueuse/core').useToString 278 | const useToggle: typeof import('@vueuse/core').useToggle 279 | const useTransition: typeof import('@vueuse/core').useTransition 280 | const useUrlSearchParams: typeof import('@vueuse/core').useUrlSearchParams 281 | const useUserMedia: typeof import('@vueuse/core').useUserMedia 282 | const useVModel: typeof import('@vueuse/core').useVModel 283 | const useVModels: typeof import('@vueuse/core').useVModels 284 | const useVibrate: typeof import('@vueuse/core').useVibrate 285 | const useVirtualList: typeof import('@vueuse/core').useVirtualList 286 | const useWakeLock: typeof import('@vueuse/core').useWakeLock 287 | const useWebNotification: typeof import('@vueuse/core').useWebNotification 288 | const useWebSocket: typeof import('@vueuse/core').useWebSocket 289 | const useWebWorker: typeof import('@vueuse/core').useWebWorker 290 | const useWebWorkerFn: typeof import('@vueuse/core').useWebWorkerFn 291 | const useWindowFocus: typeof import('@vueuse/core').useWindowFocus 292 | const useWindowScroll: typeof import('@vueuse/core').useWindowScroll 293 | const useWindowSize: typeof import('@vueuse/core').useWindowSize 294 | const watch: typeof import('vue').watch 295 | const watchArray: typeof import('@vueuse/core').watchArray 296 | const watchAtMost: typeof import('@vueuse/core').watchAtMost 297 | const watchDebounced: typeof import('@vueuse/core').watchDebounced 298 | const watchDeep: typeof import('@vueuse/core').watchDeep 299 | const watchEffect: typeof import('vue').watchEffect 300 | const watchIgnorable: typeof import('@vueuse/core').watchIgnorable 301 | const watchImmediate: typeof import('@vueuse/core').watchImmediate 302 | const watchOnce: typeof import('@vueuse/core').watchOnce 303 | const watchPausable: typeof import('@vueuse/core').watchPausable 304 | const watchPostEffect: typeof import('vue').watchPostEffect 305 | const watchSyncEffect: typeof import('vue').watchSyncEffect 306 | const watchThrottled: typeof import('@vueuse/core').watchThrottled 307 | const watchTriggerable: typeof import('@vueuse/core').watchTriggerable 308 | const watchWithFilter: typeof import('@vueuse/core').watchWithFilter 309 | const whenever: typeof import('@vueuse/core').whenever 310 | } 311 | // for type re-export 312 | declare global { 313 | // @ts-ignore 314 | export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' 315 | import('vue') 316 | } 317 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | --------------------------------------------------------------------------------