| New file |
| | |
| | | { |
| | | "globals": { |
| | | "Component": true, |
| | | "ComponentPublicInstance": true, |
| | | "ComputedRef": true, |
| | | "DirectiveBinding": true, |
| | | "EffectScope": true, |
| | | "ElLoading": true, |
| | | "ElMessage": true, |
| | | "ExtractDefaultPropTypes": true, |
| | | "ExtractPropTypes": true, |
| | | "ExtractPublicPropTypes": true, |
| | | "InjectionKey": true, |
| | | "MaybeRef": true, |
| | | "MaybeRefOrGetter": true, |
| | | "PropType": true, |
| | | "Ref": true, |
| | | "ShallowRef": true, |
| | | "Slot": true, |
| | | "Slots": true, |
| | | "VNode": true, |
| | | "WritableComputedRef": true, |
| | | "acceptHMRUpdate": true, |
| | | "asyncComputed": true, |
| | | "autoResetRef": true, |
| | | "computed": true, |
| | | "computedAsync": true, |
| | | "computedEager": true, |
| | | "computedInject": true, |
| | | "computedWithControl": true, |
| | | "controlledComputed": true, |
| | | "controlledRef": true, |
| | | "createApp": true, |
| | | "createEventHook": true, |
| | | "createGlobalState": true, |
| | | "createInjectionState": true, |
| | | "createPinia": true, |
| | | "createReactiveFn": true, |
| | | "createRef": true, |
| | | "createReusableTemplate": true, |
| | | "createSharedComposable": true, |
| | | "createTemplatePromise": true, |
| | | "createUnrefFn": true, |
| | | "customRef": true, |
| | | "debouncedRef": true, |
| | | "debouncedWatch": true, |
| | | "defineAsyncComponent": true, |
| | | "defineComponent": true, |
| | | "defineStore": true, |
| | | "eagerComputed": true, |
| | | "effectScope": true, |
| | | "extendRef": true, |
| | | "getActivePinia": true, |
| | | "getCurrentInstance": true, |
| | | "getCurrentScope": true, |
| | | "getCurrentWatcher": true, |
| | | "h": true, |
| | | "ignorableWatch": true, |
| | | "inject": true, |
| | | "injectLocal": true, |
| | | "isDefined": true, |
| | | "isProxy": true, |
| | | "isReactive": true, |
| | | "isReadonly": true, |
| | | "isRef": true, |
| | | "isShallow": true, |
| | | "makeDestructurable": true, |
| | | "mapActions": true, |
| | | "mapGetters": true, |
| | | "mapState": true, |
| | | "mapStores": true, |
| | | "mapWritableState": true, |
| | | "markRaw": true, |
| | | "nextTick": true, |
| | | "onActivated": true, |
| | | "onBeforeMount": true, |
| | | "onBeforeRouteLeave": true, |
| | | "onBeforeRouteUpdate": true, |
| | | "onBeforeUnmount": true, |
| | | "onBeforeUpdate": true, |
| | | "onClickOutside": true, |
| | | "onDeactivated": true, |
| | | "onElementRemoval": true, |
| | | "onErrorCaptured": true, |
| | | "onKeyStroke": true, |
| | | "onLongPress": true, |
| | | "onMounted": true, |
| | | "onRenderTracked": true, |
| | | "onRenderTriggered": true, |
| | | "onScopeDispose": true, |
| | | "onServerPrefetch": true, |
| | | "onStartTyping": true, |
| | | "onUnmounted": true, |
| | | "onUpdated": true, |
| | | "onWatcherCleanup": true, |
| | | "pausableWatch": true, |
| | | "provide": true, |
| | | "provideLocal": true, |
| | | "reactify": true, |
| | | "reactifyObject": true, |
| | | "reactive": true, |
| | | "reactiveComputed": true, |
| | | "reactiveOmit": true, |
| | | "reactivePick": true, |
| | | "readonly": true, |
| | | "ref": true, |
| | | "refAutoReset": true, |
| | | "refDebounced": true, |
| | | "refDefault": true, |
| | | "refThrottled": true, |
| | | "refWithControl": true, |
| | | "resolveComponent": true, |
| | | "resolveRef": true, |
| | | "resolveUnref": true, |
| | | "setActivePinia": true, |
| | | "setMapStoreSuffix": true, |
| | | "shallowReactive": true, |
| | | "shallowReadonly": true, |
| | | "shallowRef": true, |
| | | "storeToRefs": true, |
| | | "syncRef": true, |
| | | "syncRefs": true, |
| | | "templateRef": true, |
| | | "throttledRef": true, |
| | | "throttledWatch": true, |
| | | "toRaw": true, |
| | | "toReactive": true, |
| | | "toRef": true, |
| | | "toRefs": true, |
| | | "toValue": true, |
| | | "triggerRef": true, |
| | | "tryOnBeforeMount": true, |
| | | "tryOnBeforeUnmount": true, |
| | | "tryOnMounted": true, |
| | | "tryOnScopeDispose": true, |
| | | "tryOnUnmounted": true, |
| | | "unref": true, |
| | | "unrefElement": true, |
| | | "until": true, |
| | | "useActiveElement": true, |
| | | "useAnimate": true, |
| | | "useArrayDifference": true, |
| | | "useArrayEvery": true, |
| | | "useArrayFilter": true, |
| | | "useArrayFind": true, |
| | | "useArrayFindIndex": true, |
| | | "useArrayFindLast": true, |
| | | "useArrayIncludes": true, |
| | | "useArrayJoin": true, |
| | | "useArrayMap": true, |
| | | "useArrayReduce": true, |
| | | "useArraySome": true, |
| | | "useArrayUnique": true, |
| | | "useAsyncQueue": true, |
| | | "useAsyncState": true, |
| | | "useAttrs": true, |
| | | "useBase64": true, |
| | | "useBattery": true, |
| | | "useBluetooth": true, |
| | | "useBreakpoints": true, |
| | | "useBroadcastChannel": true, |
| | | "useBrowserLocation": true, |
| | | "useCached": true, |
| | | "useClipboard": true, |
| | | "useClipboardItems": true, |
| | | "useCloned": true, |
| | | "useColorMode": true, |
| | | "useConfirmDialog": true, |
| | | "useCountdown": true, |
| | | "useCounter": true, |
| | | "useCssModule": true, |
| | | "useCssVar": true, |
| | | "useCssVars": true, |
| | | "useCurrentElement": true, |
| | | "useCycleList": true, |
| | | "useDark": true, |
| | | "useDateFormat": true, |
| | | "useDebounce": true, |
| | | "useDebounceFn": true, |
| | | "useDebouncedRefHistory": true, |
| | | "useDeviceMotion": true, |
| | | "useDeviceOrientation": true, |
| | | "useDevicePixelRatio": true, |
| | | "useDevicesList": true, |
| | | "useDisplayMedia": true, |
| | | "useDocumentVisibility": true, |
| | | "useDraggable": true, |
| | | "useDropZone": true, |
| | | "useElementBounding": true, |
| | | "useElementByPoint": true, |
| | | "useElementHover": true, |
| | | "useElementSize": true, |
| | | "useElementVisibility": true, |
| | | "useEventBus": true, |
| | | "useEventListener": true, |
| | | "useEventSource": true, |
| | | "useEyeDropper": true, |
| | | "useFavicon": true, |
| | | "useFetch": true, |
| | | "useFileDialog": true, |
| | | "useFileSystemAccess": true, |
| | | "useFocus": true, |
| | | "useFocusWithin": true, |
| | | "useFps": true, |
| | | "useFullscreen": true, |
| | | "useGamepad": true, |
| | | "useGeolocation": true, |
| | | "useId": true, |
| | | "useIdle": true, |
| | | "useImage": true, |
| | | "useInfiniteScroll": true, |
| | | "useIntersectionObserver": true, |
| | | "useInterval": true, |
| | | "useIntervalFn": true, |
| | | "useKeyModifier": true, |
| | | "useLastChanged": true, |
| | | "useLink": true, |
| | | "useLocalStorage": true, |
| | | "useMagicKeys": true, |
| | | "useManualRefHistory": true, |
| | | "useMediaControls": true, |
| | | "useMediaQuery": true, |
| | | "useMemoize": true, |
| | | "useMemory": true, |
| | | "useModel": true, |
| | | "useMounted": true, |
| | | "useMouse": true, |
| | | "useMouseInElement": true, |
| | | "useMousePressed": true, |
| | | "useMutationObserver": true, |
| | | "useNavigatorLanguage": true, |
| | | "useNetwork": true, |
| | | "useNow": true, |
| | | "useObjectUrl": true, |
| | | "useOffsetPagination": true, |
| | | "useOnline": true, |
| | | "usePageLeave": true, |
| | | "useParallax": true, |
| | | "useParentElement": true, |
| | | "usePerformanceObserver": true, |
| | | "usePermission": true, |
| | | "usePointer": true, |
| | | "usePointerLock": true, |
| | | "usePointerSwipe": true, |
| | | "usePreferredColorScheme": true, |
| | | "usePreferredContrast": true, |
| | | "usePreferredDark": true, |
| | | "usePreferredLanguages": true, |
| | | "usePreferredReducedMotion": true, |
| | | "usePreferredReducedTransparency": true, |
| | | "usePrevious": true, |
| | | "useRafFn": true, |
| | | "useRefHistory": true, |
| | | "useResizeObserver": true, |
| | | "useRoute": true, |
| | | "useRouter": true, |
| | | "useSSRWidth": true, |
| | | "useScreenOrientation": true, |
| | | "useScreenSafeArea": true, |
| | | "useScriptTag": true, |
| | | "useScroll": true, |
| | | "useScrollLock": true, |
| | | "useSessionStorage": true, |
| | | "useShare": true, |
| | | "useSlots": true, |
| | | "useSorted": true, |
| | | "useSpeechRecognition": true, |
| | | "useSpeechSynthesis": true, |
| | | "useStepper": true, |
| | | "useStorage": true, |
| | | "useStorageAsync": true, |
| | | "useStyleTag": true, |
| | | "useSupported": true, |
| | | "useSwipe": true, |
| | | "useTemplateRef": true, |
| | | "useTemplateRefsList": true, |
| | | "useTextDirection": true, |
| | | "useTextSelection": true, |
| | | "useTextareaAutosize": true, |
| | | "useThrottle": true, |
| | | "useThrottleFn": true, |
| | | "useThrottledRefHistory": true, |
| | | "useTimeAgo": true, |
| | | "useTimeAgoIntl": true, |
| | | "useTimeout": true, |
| | | "useTimeoutFn": true, |
| | | "useTimeoutPoll": true, |
| | | "useTimestamp": true, |
| | | "useTitle": true, |
| | | "useToNumber": true, |
| | | "useToString": true, |
| | | "useToggle": true, |
| | | "useTransition": true, |
| | | "useUrlSearchParams": true, |
| | | "useUserMedia": true, |
| | | "useVModel": true, |
| | | "useVModels": true, |
| | | "useVibrate": true, |
| | | "useVirtualList": true, |
| | | "useWakeLock": true, |
| | | "useWebNotification": true, |
| | | "useWebSocket": true, |
| | | "useWebWorker": true, |
| | | "useWebWorkerFn": true, |
| | | "useWindowFocus": true, |
| | | "useWindowScroll": true, |
| | | "useWindowSize": true, |
| | | "watch": true, |
| | | "watchArray": true, |
| | | "watchAtMost": true, |
| | | "watchDebounced": true, |
| | | "watchDeep": true, |
| | | "watchEffect": true, |
| | | "watchIgnorable": true, |
| | | "watchImmediate": true, |
| | | "watchOnce": true, |
| | | "watchPausable": true, |
| | | "watchPostEffect": true, |
| | | "watchSyncEffect": true, |
| | | "watchThrottled": true, |
| | | "watchTriggerable": true, |
| | | "watchWithFilter": true, |
| | | "whenever": true |
| | | } |
| | | } |
| New file |
| | |
| | | # 【通用】环境变量 |
| | | |
| | | # 版本号 |
| | | VITE_VERSION = 3.0.2 |
| | | |
| | | # 端口号 |
| | | VITE_PORT = 3006 |
| | | |
| | | # 应用部署基础路径(如部署在子目录 /admin,则设置为 /admin/) |
| | | VITE_BASE_URL = / |
| | | |
| | | # 权限模式【 frontend 前端模式 / backend 后端模式 】 |
| | | VITE_ACCESS_MODE = frontend |
| | | |
| | | # 跨域请求时是否携带 Cookie(开启前需确保后端支持) |
| | | VITE_WITH_CREDENTIALS = false |
| | | |
| | | # 是否打开路由信息 |
| | | VITE_OPEN_ROUTE_INFO = false |
| | | |
| | | # 锁屏加密密钥 |
| | | VITE_LOCK_ENCRYPT_KEY = s3cur3k3y4adpro |
| | | |
| | | # 登录 / WebSocket 示例默认地址 |
| | | VITE_LOGIN_WEBSOCKET = ws://localhost:8080/ws |
| New file |
| | |
| | | # 【开发】环境变量 |
| | | |
| | | # 应用部署基础路径(如部署在子目录 /admin,则设置为 /admin/) |
| | | VITE_BASE_URL = / |
| | | |
| | | # API 请求基础路径(开发环境设置为 / 使用代理,生产环境设置为完整后端地址) |
| | | VITE_API_URL = / |
| | | |
| | | # 代理目标地址(开发环境通过 Vite 代理转发请求到此地址,解决跨域问题) |
| | | VITE_API_PROXY_URL = https://m1.apifoxmock.com/m1/6400575-6097373-default |
| | | |
| | | # Delete console |
| | | VITE_DROP_CONSOLE = false |
| New file |
| | |
| | | # 【生产】环境变量 |
| | | |
| | | # 应用部署基础路径(如部署在子目录 /admin,则设置为 /admin/) |
| | | VITE_BASE_URL = / |
| | | |
| | | # API 地址前缀 |
| | | VITE_API_URL = https://m1.apifoxmock.com/m1/6400575-6097373-default |
| | | |
| | | # Delete console |
| | | VITE_DROP_CONSOLE = true |
| New file |
| | |
| | | *.html linguist-detectable=false |
| | | *.vue linguist-detectable=true |
| New file |
| | |
| | | node_modules/ |
| | | dist/ |
| | | dist-ssr/ |
| | | .vite/ |
| | | .playwright-cli/ |
| | | output/ |
| | | coverage/ |
| | | *.log |
| | | __pycache__/ |
| | | *.pyc |
| | | *.local |
| | | .DS_Store |
| | | Thumbs.db |
| | | .cursorrules |
| New file |
| | |
| | | /node_modules/* |
| | | /dist/* |
| | | /src/main.ts |
| New file |
| | |
| | | { |
| | | "printWidth": 100, |
| | | "tabWidth": 2, |
| | | "useTabs": false, |
| | | "semi": false, |
| | | "vueIndentScriptAndStyle": true, |
| | | "singleQuote": true, |
| | | "quoteProps": "as-needed", |
| | | "bracketSpacing": true, |
| | | "trailingComma": "none", |
| | | "bracketSameLine": false, |
| | | "jsxSingleQuote": false, |
| | | "arrowParens": "always", |
| | | "insertPragma": false, |
| | | "requirePragma": false, |
| | | "proseWrap": "never", |
| | | "htmlWhitespaceSensitivity": "strict", |
| | | "endOfLine": "auto", |
| | | "rangeStart": 0 |
| | | } |
| New file |
| | |
| | | dist |
| | | node_modules |
| | | public |
| | | .husky |
| | | .vscode |
| | | |
| | | src/components/Layout/MenuLeft/index.vue |
| | | src/assets |
| | | stats.html |
| New file |
| | |
| | | module.exports = { |
| | | // 继承推荐规范配置 |
| | | extends: [ |
| | | 'stylelint-config-standard', |
| | | 'stylelint-config-recommended-scss', |
| | | 'stylelint-config-recommended-vue/scss', |
| | | 'stylelint-config-html/vue', |
| | | 'stylelint-config-recess-order' |
| | | ], |
| | | // 指定不同文件对应的解析器 |
| | | overrides: [ |
| | | { |
| | | files: ['**/*.{vue,html}'], |
| | | customSyntax: 'postcss-html' |
| | | }, |
| | | { |
| | | files: ['**/*.{css,scss}'], |
| | | customSyntax: 'postcss-scss' |
| | | } |
| | | ], |
| | | // 自定义规则 |
| | | rules: { |
| | | 'import-notation': 'string', // 指定导入CSS文件的方式("string"|"url") |
| | | 'selector-class-pattern': null, // 选择器类名命名规则 |
| | | 'custom-property-pattern': null, // 自定义属性命名规则 |
| | | 'keyframes-name-pattern': null, // 动画帧节点样式命名规则 |
| | | 'no-descending-specificity': null, // 允许无降序特异性 |
| | | 'no-empty-source': null, // 允许空样式 |
| | | 'property-no-vendor-prefix': null, // 允许属性前缀 |
| | | // 允许 global 、export 、deep伪类 |
| | | 'selector-pseudo-class-no-unknown': [ |
| | | true, |
| | | { |
| | | ignorePseudoClasses: ['global', 'export', 'deep'] |
| | | } |
| | | ], |
| | | // 允许未知属性 |
| | | 'property-no-unknown': [ |
| | | true, |
| | | { |
| | | ignoreProperties: [] |
| | | } |
| | | ], |
| | | // 允许未知规则 |
| | | 'at-rule-no-unknown': [ |
| | | true, |
| | | { |
| | | ignoreAtRules: [ |
| | | 'apply', |
| | | 'use', |
| | | 'mixin', |
| | | 'include', |
| | | 'extend', |
| | | 'each', |
| | | 'if', |
| | | 'else', |
| | | 'for', |
| | | 'while', |
| | | 'reference' |
| | | ] |
| | | } |
| | | ], |
| | | 'scss/at-rule-no-unknown': [ |
| | | true, |
| | | { |
| | | ignoreAtRules: [ |
| | | 'apply', |
| | | 'use', |
| | | 'mixin', |
| | | 'include', |
| | | 'extend', |
| | | 'each', |
| | | 'if', |
| | | 'else', |
| | | 'for', |
| | | 'while', |
| | | 'reference' |
| | | ] |
| | | } |
| | | ] |
| | | } |
| | | } |
| New file |
| | |
| | | MIT License |
| | | |
| | | Copyright (c) 2025 SuperManTT |
| | | |
| | | Permission is hereby granted, free of charge, to any person obtaining a copy |
| | | of this software and associated documentation files (the "Software"), to deal |
| | | in the Software without restriction, including without limitation the rights |
| | | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
| | | copies of the Software, and to permit persons to whom the Software is |
| | | furnished to do so, subject to the following conditions: |
| | | |
| | | The above copyright notice and this permission notice shall be included in all |
| | | copies or substantial portions of the Software. |
| | | |
| | | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| | | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| | | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| | | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| | | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| | | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
| | | SOFTWARE. |
| New file |
| | |
| | | /** |
| | | * commitlint 配置文件 |
| | | * 文档 |
| | | * https://commitlint.js.org/#/reference-rules |
| | | * https://cz-git.qbb.sh/zh/guide/ |
| | | */ |
| | | |
| | | module.exports = { |
| | | // 继承的规则 |
| | | extends: ['@commitlint/config-conventional'], |
| | | // 自定义规则 |
| | | rules: { |
| | | // 提交类型枚举,git提交type必须是以下类型 |
| | | 'type-enum': [ |
| | | 2, |
| | | 'always', |
| | | [ |
| | | 'feat', // 新增功能 |
| | | 'fix', // 修复缺陷 |
| | | 'docs', // 文档变更 |
| | | 'style', // 代码格式(不影响功能,例如空格、分号等格式修正) |
| | | 'refactor', // 代码重构(不包括 bug 修复、功能新增) |
| | | 'perf', // 性能优化 |
| | | 'test', // 添加疏漏测试或已有测试改动 |
| | | 'build', // 构建流程、外部依赖变更(如升级 npm 包、修改 webpack 配置等) |
| | | 'ci', // 修改 CI 配置、脚本 |
| | | 'revert', // 回滚 commit |
| | | 'chore', // 对构建过程或辅助工具和库的更改(不影响源文件、测试用例) |
| | | 'wip' // 对构建过程或辅助工具和库的更改(不影响源文件、测试用例) |
| | | ] |
| | | ], |
| | | 'subject-case': [0] // subject大小写不做校验 |
| | | }, |
| | | |
| | | prompt: { |
| | | messages: { |
| | | type: '选择你要提交的类型 :', |
| | | scope: '选择一个提交范围(可选):', |
| | | customScope: '请输入自定义的提交范围 :', |
| | | subject: '填写简短精炼的变更描述 :\n', |
| | | body: '填写更加详细的变更描述(可选)。使用 "|" 换行 :\n', |
| | | breaking: '列举非兼容性重大的变更(可选)。使用 "|" 换行 :\n', |
| | | footerPrefixesSelect: '选择关联issue前缀(可选):', |
| | | customFooterPrefix: '输入自定义issue前缀 :', |
| | | footer: '列举关联issue (可选) 例如: #31, #I3244 :\n', |
| | | generatingByAI: '正在通过 AI 生成你的提交简短描述...', |
| | | generatedSelectByAI: '选择一个 AI 生成的简短描述:', |
| | | confirmCommit: '是否提交或修改commit ?' |
| | | }, |
| | | // prettier-ignore |
| | | types: [ |
| | | { value: "feat", name: "feat: 新增功能" }, |
| | | { value: "fix", name: "fix: 修复缺陷" }, |
| | | { value: "docs", name: "docs: 文档变更" }, |
| | | { value: "style", name: "style: 代码格式(不影响功能,例如空格、分号等格式修正)" }, |
| | | { value: "refactor", name: "refactor: 代码重构(不包括 bug 修复、功能新增)" }, |
| | | { value: "perf", name: "perf: 性能优化" }, |
| | | { value: "test", name: "test: 添加疏漏测试或已有测试改动" }, |
| | | { value: "build", name: "build: 构建流程、外部依赖变更(如升级 npm 包、修改 vite 配置等)" }, |
| | | { value: "ci", name: "ci: 修改 CI 配置、脚本" }, |
| | | { value: "revert", name: "revert: 回滚 commit" }, |
| | | { value: "chore", name: "chore: 对构建过程或辅助工具和库的更改(不影响源文件、测试用例)" }, |
| | | ], |
| | | useEmoji: true, |
| | | emojiAlign: 'center', |
| | | useAI: false, |
| | | aiNumber: 1, |
| | | themeColorCode: '', |
| | | scopes: [], |
| | | allowCustomScopes: true, |
| | | allowEmptyScopes: true, |
| | | customScopesAlign: 'bottom', |
| | | customScopesAlias: 'custom', |
| | | emptyScopesAlias: 'empty', |
| | | upperCaseSubject: false, |
| | | markBreakingChangeMode: false, |
| | | allowBreakingChanges: ['feat', 'fix'], |
| | | breaklineNumber: 100, |
| | | breaklineChar: '|', |
| | | skipQuestions: ['breaking', 'footerPrefix', 'footer'], // 跳过的步骤 |
| | | issuePrefixes: [{ value: 'closed', name: 'closed: ISSUES has been processed' }], |
| | | customIssuePrefixAlign: 'top', |
| | | emptyIssuePrefixAlias: 'skip', |
| | | customIssuePrefixAlias: 'custom', |
| | | allowCustomIssuePrefix: true, |
| | | allowEmptyIssuePrefix: true, |
| | | confirmColorize: true, |
| | | maxHeaderLength: Infinity, |
| | | maxSubjectLength: Infinity, |
| | | minSubjectLength: 0, |
| | | scopeOverrides: undefined, |
| | | defaultBody: '', |
| | | defaultIssues: '', |
| | | defaultScope: '', |
| | | defaultSubject: '' |
| | | } |
| | | } |
| New file |
| | |
| | | import fs from 'fs' |
| | | import path, { dirname } from 'path' |
| | | import { fileURLToPath } from 'url' |
| | | |
| | | import pluginJs from '@eslint/js' |
| | | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended' |
| | | import pluginVue from 'eslint-plugin-vue' |
| | | import globals from 'globals' |
| | | |
| | | const __filename = fileURLToPath(import.meta.url) |
| | | const __dirname = dirname(__filename) |
| | | const autoImportPath = path.resolve(__dirname, '.auto-import.json') |
| | | const autoImportConfig = loadAutoImportConfig() |
| | | |
| | | export default [ |
| | | { |
| | | ignores: [ |
| | | 'node_modules', |
| | | 'dist', |
| | | 'public', |
| | | '.vscode/**', |
| | | 'src/assets/**', |
| | | 'src/utils/console.ts' |
| | | ] |
| | | }, |
| | | pluginJs.configs.recommended, |
| | | ...pluginVue.configs['flat/essential'], |
| | | { |
| | | files: ['**/*.{js,mjs,cjs,vue}'], |
| | | languageOptions: { |
| | | ecmaVersion: 'latest', |
| | | sourceType: 'module', |
| | | globals: { |
| | | ...globals.browser, |
| | | ...globals.node, |
| | | ...autoImportConfig.globals, |
| | | Api: 'readonly', |
| | | __APP_VERSION__: 'readonly' |
| | | } |
| | | }, |
| | | rules: { |
| | | quotes: ['error', 'single'], |
| | | semi: ['error', 'never'], |
| | | 'no-var': 'error', |
| | | 'vue/multi-word-component-names': 'off', |
| | | 'no-multiple-empty-lines': ['warn', { max: 1 }], |
| | | 'no-unexpected-multiline': 'error' |
| | | } |
| | | }, |
| | | { |
| | | files: ['**/*.vue'], |
| | | languageOptions: { |
| | | parserOptions: { |
| | | ecmaVersion: 'latest', |
| | | sourceType: 'module' |
| | | } |
| | | } |
| | | }, |
| | | eslintPluginPrettierRecommended |
| | | ] |
| | | |
| | | function loadAutoImportConfig() { |
| | | if (!fs.existsSync(autoImportPath)) { |
| | | return { globals: {} } |
| | | } |
| | | |
| | | try { |
| | | return JSON.parse(fs.readFileSync(autoImportPath, 'utf-8')) |
| | | } catch { |
| | | return { globals: {} } |
| | | } |
| | | } |
| New file |
| | |
| | | <!doctype html> |
| | | <html> |
| | | <head> |
| | | <title>Art Design Pro</title> |
| | | <meta charset="UTF-8" /> |
| | | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| | | <meta |
| | | name="description" |
| | | content="Art Design Pro - A modern admin dashboard template built with Vue 3, JavaScript, and Element Plus." |
| | | /> |
| | | <link rel="shortcut icon" type="image/x-icon" href="src/assets/images/favicon.ico" /> |
| | | |
| | | <style> |
| | | /* 防止页面刷新时白屏的初始样式 */ |
| | | html { |
| | | background-color: #fafbfc; |
| | | } |
| | | |
| | | html.dark { |
| | | background-color: #070707; |
| | | } |
| | | </style> |
| | | |
| | | <script> |
| | | // 初始化 html class 主题属性 |
| | | ;(function () { |
| | | try { |
| | | if (typeof Storage === 'undefined' || !window.localStorage) { |
| | | return |
| | | } |
| | | |
| | | const themeType = localStorage.getItem('sys-theme') |
| | | if (themeType === 'dark') { |
| | | document.documentElement.classList.add('dark') |
| | | } |
| | | } catch (e) { |
| | | console.warn('Failed to apply initial theme:', e) |
| | | } |
| | | })() |
| | | </script> |
| | | </head> |
| | | |
| | | <body> |
| | | <div id="app"></div> |
| | | <script type="module" src="/src/main.js"></script> |
| | | </body> |
| | | </html> |
| New file |
| | |
| | | { |
| | | "name": "art-design-pro", |
| | | "version": "0.0.0", |
| | | "type": "module", |
| | | "engines": { |
| | | "node": ">=20.19.0", |
| | | "pnpm": ">=8.8.0" |
| | | }, |
| | | "scripts": { |
| | | "dev": "vite --open", |
| | | "build": "vite build", |
| | | "serve": "vite preview", |
| | | "icons:build-local": "node scripts/build-local-iconify-collections.mjs", |
| | | "clean:dev": "node scripts/clean-dev.js", |
| | | "lint": "eslint . --ext .js,.mjs,.cjs,.vue", |
| | | "fix": "eslint . --ext .js,.mjs,.cjs,.vue --fix", |
| | | "lint:prettier": "prettier --write \"**/*.{js,mjs,cjs,json,css,less,scss,vue,html,md}\"", |
| | | "lint:stylelint": "stylelint \"**/*.{css,scss,vue}\" --fix" |
| | | }, |
| | | "dependencies": { |
| | | "@element-plus/icons-vue": "^2.3.2", |
| | | "@iconify-json/fluent": "^1.2.42", |
| | | "@iconify-json/icon-park-outline": "^1.2.4", |
| | | "@iconify-json/iconamoon": "^1.2.2", |
| | | "@iconify-json/ix": "^1.2.11", |
| | | "@iconify-json/line-md": "^1.2.16", |
| | | "@iconify-json/ri": "^1.2.10", |
| | | "@iconify-json/svg-spinners": "^1.2.4", |
| | | "@iconify-json/system-uicons": "^1.2.4", |
| | | "@iconify-json/vaadin": "^1.2.1", |
| | | "@iconify/vue": "^5.0.0", |
| | | "@tailwindcss/vite": "^4.1.14", |
| | | "@vue/reactivity": "^3.5.21", |
| | | "@vueuse/core": "^13.9.0", |
| | | "@wangeditor/editor": "^5.1.23", |
| | | "@wangeditor/editor-for-vue": "next", |
| | | "axios": "^1.12.2", |
| | | "crypto-js": "^4.2.0", |
| | | "echarts": "^6.0.0", |
| | | "element-plus": "^2.11.2", |
| | | "file-saver": "^2.0.5", |
| | | "highlight.js": "^11.10.0", |
| | | "mitt": "^3.0.1", |
| | | "nprogress": "^0.2.0", |
| | | "ohash": "^2.0.11", |
| | | "pinia": "^3.0.3", |
| | | "pinia-plugin-persistedstate": "^4.3.0", |
| | | "qrcode.vue": "^3.6.0", |
| | | "tailwindcss": "^4.1.14", |
| | | "vue": "^3.5.21", |
| | | "vue-draggable-plus": "^0.6.0", |
| | | "vue-i18n": "^9.14.0", |
| | | "vue-router": "^4.5.1", |
| | | "xgplayer": "^3.0.20", |
| | | "xlsx": "^0.18.5" |
| | | }, |
| | | "devDependencies": { |
| | | "@eslint/js": "^9.9.1", |
| | | "@types/node": "^24.0.5", |
| | | "@vitejs/plugin-vue": "^6.0.1", |
| | | "@vue/compiler-sfc": "^3.0.5", |
| | | "eslint": "^9.9.1", |
| | | "eslint-config-prettier": "^9.1.0", |
| | | "eslint-plugin-prettier": "^5.2.1", |
| | | "eslint-plugin-vue": "^9.27.0", |
| | | "globals": "^15.9.0", |
| | | "prettier": "^3.5.3", |
| | | "rollup-plugin-visualizer": "^5.12.0", |
| | | "sass": "^1.81.0", |
| | | "stylelint": "^16.20.0", |
| | | "stylelint-config-html": "^1.1.0", |
| | | "stylelint-config-recess-order": "^4.6.0", |
| | | "stylelint-config-recommended-scss": "^14.1.0", |
| | | "stylelint-config-recommended-vue": "^1.5.0", |
| | | "stylelint-config-standard": "^36.0.1", |
| | | "terser": "^5.36.0", |
| | | "unplugin-auto-import": "^20.2.0", |
| | | "unplugin-element-plus": "^0.10.0", |
| | | "unplugin-vue-components": "^29.1.0", |
| | | "vite": "^7.1.5", |
| | | "vite-plugin-compression": "^0.5.1", |
| | | "vite-plugin-vue-devtools": "^7.7.6", |
| | | "vue-demi": "^0.14.9", |
| | | "vue-img-cutter": "^3.0.5" |
| | | } |
| | | } |
| New file |
| | |
| | | import fs from 'node:fs' |
| | | import path from 'node:path' |
| | | import { fileURLToPath } from 'node:url' |
| | | import { inspect } from 'node:util' |
| | | |
| | | import { icons as fluentIcons } from '@iconify-json/fluent' |
| | | import { icons as iconParkOutlineIcons } from '@iconify-json/icon-park-outline' |
| | | import { icons as iconamoonIcons } from '@iconify-json/iconamoon' |
| | | import { icons as ixIcons } from '@iconify-json/ix' |
| | | import { icons as lineMdIcons } from '@iconify-json/line-md' |
| | | import { icons as remixIcons } from '@iconify-json/ri' |
| | | import { icons as svgSpinnersIcons } from '@iconify-json/svg-spinners' |
| | | import { icons as systemUiconsIcons } from '@iconify-json/system-uicons' |
| | | import { icons as vaadinIcons } from '@iconify-json/vaadin' |
| | | |
| | | const __dirname = path.dirname(fileURLToPath(import.meta.url)) |
| | | const projectRoot = path.resolve(__dirname, '..') |
| | | const srcRoot = path.join(projectRoot, 'src') |
| | | const outputPath = path.join(srcRoot, 'plugins', 'iconify.collections.js') |
| | | |
| | | const iconCollections = { |
| | | fluent: fluentIcons, |
| | | 'icon-park-outline': iconParkOutlineIcons, |
| | | iconamoon: iconamoonIcons, |
| | | ix: ixIcons, |
| | | 'line-md': lineMdIcons, |
| | | ri: remixIcons, |
| | | 'svg-spinners': svgSpinnersIcons, |
| | | 'system-uicons': systemUiconsIcons, |
| | | vaadin: vaadinIcons |
| | | } |
| | | |
| | | function collectSourceFiles(dir) { |
| | | const entries = fs.readdirSync(dir, { withFileTypes: true }) |
| | | |
| | | return entries.flatMap((entry) => { |
| | | const fullPath = path.join(dir, entry.name) |
| | | |
| | | if (entry.isDirectory()) { |
| | | return collectSourceFiles(fullPath) |
| | | } |
| | | |
| | | return /\.(vue|js)$/.test(entry.name) ? [fullPath] : [] |
| | | }) |
| | | } |
| | | |
| | | function collectUsedIconsByPrefix() { |
| | | const iconPattern = /icon\s*[:=]\s*["']([a-z0-9-]+):([a-z0-9-]+)["']/g |
| | | const usedIconsByPrefix = new Map() |
| | | |
| | | for (const filePath of collectSourceFiles(srcRoot)) { |
| | | const content = fs.readFileSync(filePath, 'utf8') |
| | | |
| | | for (const [, prefix, name] of content.matchAll(iconPattern)) { |
| | | const names = usedIconsByPrefix.get(prefix) || new Set() |
| | | names.add(name) |
| | | usedIconsByPrefix.set(prefix, names) |
| | | } |
| | | } |
| | | |
| | | return usedIconsByPrefix |
| | | } |
| | | |
| | | function buildSubsetCollection(collection, usedNames) { |
| | | const icons = {} |
| | | const aliases = {} |
| | | const sourceAliases = collection.aliases || {} |
| | | const requiredIconNames = new Set() |
| | | const requiredAliasNames = new Set() |
| | | |
| | | function include(name) { |
| | | if (collection.icons[name]) { |
| | | requiredIconNames.add(name) |
| | | return |
| | | } |
| | | |
| | | const alias = sourceAliases[name] |
| | | |
| | | if (!alias) { |
| | | throw new Error(`Icon "${collection.prefix}:${name}" is missing from source collection`) |
| | | } |
| | | |
| | | if (requiredAliasNames.has(name)) { |
| | | return |
| | | } |
| | | |
| | | requiredAliasNames.add(name) |
| | | include(alias.parent) |
| | | } |
| | | |
| | | ;[...usedNames].sort().forEach((name) => include(name)) |
| | | |
| | | for (const name of [...requiredIconNames].sort()) { |
| | | icons[name] = collection.icons[name] |
| | | } |
| | | |
| | | for (const name of [...requiredAliasNames].sort()) { |
| | | aliases[name] = sourceAliases[name] |
| | | } |
| | | |
| | | const subset = { |
| | | prefix: collection.prefix, |
| | | icons |
| | | } |
| | | |
| | | if (collection.width) { |
| | | subset.width = collection.width |
| | | } |
| | | |
| | | if (collection.height) { |
| | | subset.height = collection.height |
| | | } |
| | | |
| | | if (Object.keys(aliases).length > 0) { |
| | | subset.aliases = aliases |
| | | } |
| | | |
| | | return subset |
| | | } |
| | | |
| | | function buildLocalCollections() { |
| | | const usedIconsByPrefix = collectUsedIconsByPrefix() |
| | | const localCollections = {} |
| | | |
| | | for (const [prefix, usedNames] of [...usedIconsByPrefix.entries()].sort(([a], [b]) => |
| | | a.localeCompare(b) |
| | | )) { |
| | | const sourceCollection = iconCollections[prefix] |
| | | |
| | | if (!sourceCollection) { |
| | | throw new Error(`Missing source Iconify collection for prefix "${prefix}"`) |
| | | } |
| | | |
| | | localCollections[prefix] = buildSubsetCollection(sourceCollection, usedNames) |
| | | } |
| | | |
| | | return localCollections |
| | | } |
| | | |
| | | function writeCollectionsFile(localCollections) { |
| | | const serializedCollections = inspect(localCollections, { |
| | | depth: null, |
| | | compact: false, |
| | | sorted: true, |
| | | maxArrayLength: null, |
| | | maxStringLength: null |
| | | }) |
| | | |
| | | const fileContents = `// Auto-generated by scripts/build-local-iconify-collections.mjs |
| | | // Do not edit manually. |
| | | |
| | | export const LOCAL_ICON_COLLECTIONS = Object.freeze(${serializedCollections}) |
| | | ` |
| | | |
| | | fs.writeFileSync(outputPath, fileContents) |
| | | } |
| | | |
| | | const localCollections = buildLocalCollections() |
| | | writeCollectionsFile(localCollections) |
| | | |
| | | console.log(`Wrote local Iconify collections to ${outputPath}`) |
| New file |
| | |
| | | export const CLEAN_DEV_TARGETS = [ |
| | | 'README.md', |
| | | 'README.zh-CN.md', |
| | | 'CHANGELOG.md', |
| | | 'CHANGELOG.zh-CN.md', |
| | | 'src/views/change', |
| | | 'src/views/safeguard', |
| | | 'src/views/article', |
| | | 'src/views/examples', |
| | | 'src/views/system/nested', |
| | | 'src/views/widgets', |
| | | 'src/views/template', |
| | | 'src/views/dashboard/analysis', |
| | | 'src/views/dashboard/ecommerce', |
| | | 'src/mock/json', |
| | | 'src/mock/temp/articleList.js', |
| | | 'src/mock/temp/commentDetail.js', |
| | | 'src/mock/temp/commentList.js', |
| | | 'src/assets/images/cover', |
| | | 'src/assets/images/safeguard', |
| | | 'src/assets/images/3d', |
| | | 'src/components/core/charts/art-map-chart', |
| | | 'src/components/business/comment-widget' |
| | | ] |
| | | |
| | | export const ROUTE_MODULES_TO_REMOVE = [ |
| | | 'template.js', |
| | | 'widgets.js', |
| | | 'examples.js', |
| | | 'article.js', |
| | | 'safeguard.js', |
| | | 'help.js' |
| | | ] |
| | | |
| | | export function buildMinimalRouteModuleFiles() { |
| | | return { |
| | | dashboard: `const dashboardRoutes = { |
| | | name: 'Dashboard', |
| | | path: '/dashboard', |
| | | component: '/index/index', |
| | | meta: { |
| | | title: 'menus.dashboard.title', |
| | | icon: 'ri:pie-chart-line', |
| | | roles: ['R_SUPER', 'R_ADMIN'] |
| | | }, |
| | | children: [ |
| | | { |
| | | path: 'console', |
| | | name: 'Console', |
| | | component: '/dashboard/console', |
| | | meta: { |
| | | title: 'menus.dashboard.console', |
| | | icon: 'ri:home-smile-2-line', |
| | | keepAlive: false, |
| | | fixedTab: true |
| | | } |
| | | } |
| | | ] |
| | | } |
| | | |
| | | export { dashboardRoutes } |
| | | `, |
| | | system: `const systemRoutes = { |
| | | path: '/system', |
| | | name: 'System', |
| | | component: '/index/index', |
| | | meta: { |
| | | title: 'menus.system.title', |
| | | icon: 'ri:user-3-line', |
| | | roles: ['R_SUPER', 'R_ADMIN'] |
| | | }, |
| | | children: [ |
| | | { |
| | | path: 'user', |
| | | name: 'User', |
| | | component: '/system/user', |
| | | meta: { |
| | | title: 'menus.system.user', |
| | | icon: 'ri:user-line', |
| | | keepAlive: true, |
| | | roles: ['R_SUPER', 'R_ADMIN'] |
| | | } |
| | | }, |
| | | { |
| | | path: 'role', |
| | | name: 'Role', |
| | | component: '/system/role', |
| | | meta: { |
| | | title: 'menus.system.role', |
| | | icon: 'ri:user-settings-line', |
| | | keepAlive: true, |
| | | roles: ['R_SUPER'] |
| | | } |
| | | }, |
| | | { |
| | | path: 'user-center', |
| | | name: 'UserCenter', |
| | | component: '/system/user-center', |
| | | meta: { |
| | | title: 'menus.system.userCenter', |
| | | icon: 'ri:user-line', |
| | | isHide: true, |
| | | keepAlive: true, |
| | | isHideTab: true |
| | | } |
| | | }, |
| | | { |
| | | path: 'menu', |
| | | name: 'Menus', |
| | | component: '/system/menu', |
| | | meta: { |
| | | title: 'menus.system.menu', |
| | | icon: 'ri:menu-line', |
| | | keepAlive: true, |
| | | roles: ['R_SUPER'], |
| | | authList: [ |
| | | { title: '新增', authMark: 'add' }, |
| | | { title: '编辑', authMark: 'edit' }, |
| | | { title: '删除', authMark: 'delete' } |
| | | ] |
| | | } |
| | | } |
| | | ] |
| | | } |
| | | |
| | | export { systemRoutes } |
| | | `, |
| | | index: `import { dashboardRoutes } from './dashboard' |
| | | import { systemRoutes } from './system' |
| | | import { resultRoutes } from './result' |
| | | import { exceptionRoutes } from './exception' |
| | | |
| | | const routeModules = [ |
| | | dashboardRoutes, |
| | | systemRoutes, |
| | | resultRoutes, |
| | | exceptionRoutes |
| | | ] |
| | | |
| | | export { routeModules } |
| | | ` |
| | | } |
| | | } |
| | | |
| | | export function createMinimalRoutesAliasContent() { |
| | | return `const RoutesAlias = Object.freeze({ |
| | | Layout: '/index/index', |
| | | Login: '/auth/login' |
| | | }) |
| | | |
| | | export { RoutesAlias } |
| | | ` |
| | | } |
| | | |
| | | export function createMinimalChangeLogContent() { |
| | | return `import { ref } from 'vue' |
| | | |
| | | export const upgradeLogList = ref([]) |
| | | ` |
| | | } |
| | | |
| | | export function pruneLocaleMessages(localeMessages) { |
| | | const nextLocale = structuredClone(localeMessages) |
| | | const menus = nextLocale.menus || {} |
| | | const menusToRemove = ['widgets', 'template', 'article', 'examples', 'safeguard', 'plan', 'help'] |
| | | |
| | | menusToRemove.forEach((menuKey) => { |
| | | delete menus[menuKey] |
| | | }) |
| | | |
| | | if (menus.dashboard) { |
| | | delete menus.dashboard.analysis |
| | | delete menus.dashboard.ecommerce |
| | | } |
| | | |
| | | if (menus.system) { |
| | | ;['nested', 'menu1', 'menu2', 'menu21', 'menu3', 'menu31', 'menu32', 'menu321'].forEach( |
| | | (menuKey) => { |
| | | delete menus.system[menuKey] |
| | | } |
| | | ) |
| | | } |
| | | |
| | | nextLocale.menus = menus |
| | | return nextLocale |
| | | } |
| | | |
| | | export function createMinimalFastEnterContent() { |
| | | return `import { WEB_LINKS } from '@/utils/constants' |
| | | |
| | | const fastEnterConfig = { |
| | | minWidth: 1200, |
| | | applications: [ |
| | | { |
| | | name: '工作台', |
| | | description: '系统概览与数据统计', |
| | | icon: 'ri:pie-chart-line', |
| | | iconColor: '#377dff', |
| | | enabled: true, |
| | | order: 1, |
| | | routeName: 'Console' |
| | | }, |
| | | { |
| | | name: '官方文档', |
| | | description: '使用指南与开发文档', |
| | | icon: 'ri:bill-line', |
| | | iconColor: '#ffb100', |
| | | enabled: true, |
| | | order: 2, |
| | | link: WEB_LINKS.DOCS |
| | | }, |
| | | { |
| | | name: '技术支持', |
| | | description: '技术支持与问题反馈', |
| | | icon: 'ri:user-location-line', |
| | | iconColor: '#ff6b6b', |
| | | enabled: true, |
| | | order: 3, |
| | | link: WEB_LINKS.COMMUNITY |
| | | }, |
| | | { |
| | | name: '哔哩哔哩', |
| | | description: '技术分享与交流', |
| | | icon: 'ri:bilibili-line', |
| | | iconColor: '#FB7299', |
| | | enabled: true, |
| | | order: 4, |
| | | link: WEB_LINKS.BILIBILI |
| | | } |
| | | ], |
| | | quickLinks: [ |
| | | { |
| | | name: '登录', |
| | | enabled: true, |
| | | order: 1, |
| | | routeName: 'Login' |
| | | }, |
| | | { |
| | | name: '注册', |
| | | enabled: true, |
| | | order: 2, |
| | | routeName: 'Register' |
| | | }, |
| | | { |
| | | name: '忘记密码', |
| | | enabled: true, |
| | | order: 3, |
| | | routeName: 'ForgetPassword' |
| | | }, |
| | | { |
| | | name: '个人中心', |
| | | enabled: true, |
| | | order: 4, |
| | | routeName: 'UserCenter' |
| | | } |
| | | ] |
| | | } |
| | | |
| | | export default Object.freeze(fastEnterConfig) |
| | | ` |
| | | } |
| | | |
| | | export function rewriteMenuApiContent(content) { |
| | | return content.replace("/api/v3/system/menus'", "/api/v3/system/menus/simple'") |
| | | } |
| New file |
| | |
| | | import fs from 'node:fs/promises' |
| | | import path from 'node:path' |
| | | import readline from 'node:readline/promises' |
| | | import { stdin as input, stdout as output } from 'node:process' |
| | | |
| | | import { |
| | | CLEAN_DEV_TARGETS, |
| | | ROUTE_MODULES_TO_REMOVE, |
| | | buildMinimalRouteModuleFiles, |
| | | createMinimalChangeLogContent, |
| | | createMinimalFastEnterContent, |
| | | createMinimalRoutesAliasContent, |
| | | pruneLocaleMessages, |
| | | rewriteMenuApiContent |
| | | } from './clean-dev.helpers.mjs' |
| | | |
| | | const projectRoot = process.cwd() |
| | | |
| | | async function pathExists(relativePath) { |
| | | try { |
| | | await fs.access(path.resolve(projectRoot, relativePath)) |
| | | return true |
| | | } catch { |
| | | return false |
| | | } |
| | | } |
| | | |
| | | async function countTargetEntries(targets) { |
| | | let count = 0 |
| | | |
| | | for (const target of targets) { |
| | | if (await pathExists(target)) { |
| | | count += 1 |
| | | } |
| | | } |
| | | |
| | | return count |
| | | } |
| | | |
| | | async function removeTarget(relativePath) { |
| | | await fs.rm(path.resolve(projectRoot, relativePath), { |
| | | recursive: true, |
| | | force: true |
| | | }) |
| | | } |
| | | |
| | | async function writeText(relativePath, content) { |
| | | await fs.writeFile(path.resolve(projectRoot, relativePath), content, 'utf8') |
| | | } |
| | | |
| | | async function rewriteLocale(relativePath) { |
| | | const fullPath = path.resolve(projectRoot, relativePath) |
| | | const locale = JSON.parse(await fs.readFile(fullPath, 'utf8')) |
| | | const nextLocale = pruneLocaleMessages(locale) |
| | | |
| | | await fs.writeFile(fullPath, `${JSON.stringify(nextLocale, null, 2)}\n`, 'utf8') |
| | | } |
| | | |
| | | async function rewriteMenuApi() { |
| | | const relativePath = 'src/api/system-manage.js' |
| | | const fullPath = path.resolve(projectRoot, relativePath) |
| | | const currentContent = await fs.readFile(fullPath, 'utf8') |
| | | const nextContent = rewriteMenuApiContent(currentContent) |
| | | |
| | | await fs.writeFile(fullPath, nextContent, 'utf8') |
| | | } |
| | | |
| | | function printSummary(existingTargetCount) { |
| | | console.log('Art Design Pro clean:dev') |
| | | console.log() |
| | | console.log('将执行最小开发版裁剪:') |
| | | console.log('- 删除演示页面、演示资源和多余 mock 数据') |
| | | console.log('- 重写路由模块,只保留 dashboard/system/result/exception') |
| | | console.log('- 清理语言包中的演示菜单项') |
| | | console.log('- 重写快速入口、路由别名、更新日志数据和菜单接口') |
| | | console.log() |
| | | console.log(`检测到 ${existingTargetCount} 个待清理目标存在于当前仓库中。`) |
| | | console.log('输入 yes 继续,按 Enter 取消。') |
| | | console.log() |
| | | } |
| | | |
| | | async function confirmExecution() { |
| | | const rl = readline.createInterface({ input, output }) |
| | | |
| | | try { |
| | | const answer = await rl.question('> ') |
| | | return answer.trim().toLowerCase() === 'yes' |
| | | } finally { |
| | | rl.close() |
| | | } |
| | | } |
| | | |
| | | async function runCleanup() { |
| | | const existingTargetCount = await countTargetEntries(CLEAN_DEV_TARGETS) |
| | | printSummary(existingTargetCount) |
| | | |
| | | const confirmed = await confirmExecution() |
| | | if (!confirmed) { |
| | | console.log('已取消 clean:dev。') |
| | | return |
| | | } |
| | | |
| | | for (const target of CLEAN_DEV_TARGETS) { |
| | | await removeTarget(target) |
| | | } |
| | | |
| | | const routeFiles = buildMinimalRouteModuleFiles() |
| | | for (const moduleName of ROUTE_MODULES_TO_REMOVE) { |
| | | await removeTarget(path.posix.join('src/router/modules', moduleName)) |
| | | } |
| | | |
| | | await writeText('src/router/modules/dashboard.js', routeFiles.dashboard) |
| | | await writeText('src/router/modules/system.js', routeFiles.system) |
| | | await writeText('src/router/modules/index.js', routeFiles.index) |
| | | await writeText('src/router/routesAlias.js', createMinimalRoutesAliasContent()) |
| | | await writeText('src/mock/upgrade/changeLog.js', createMinimalChangeLogContent()) |
| | | await writeText('src/config/modules/fastEnter.js', createMinimalFastEnterContent()) |
| | | |
| | | await rewriteLocale('src/locales/langs/zh.json') |
| | | await rewriteLocale('src/locales/langs/en.json') |
| | | await rewriteMenuApi() |
| | | |
| | | console.log() |
| | | console.log('clean:dev 执行完成。') |
| | | console.log('当前仓库已切换为最小开发版结构。') |
| | | } |
| | | |
| | | runCleanup().catch((error) => { |
| | | console.error() |
| | | console.error('clean:dev 执行失败。') |
| | | console.error(error) |
| | | process.exit(1) |
| | | }) |
| New file |
| | |
| | | --- |
| | | name: art-design-pro |
| | | description: "Use when 在当前 Art Design Pro JavaScript 版仓库中编写 Vue3 页面、组件、表格、表单、图表或布局,并且需要优先复用现有组件、hooks、页面模式和本地文档时。" |
| | | --- |
| | | |
| | | # Art Design Pro 组件库智能助手 |
| | | |
| | | 让 Claude Code 在编写 Vue3 页面时准确使用项目中已有的 Art Design Pro 组件,避免重复造轮子。 |
| | | |
| | | ## 📖 关于 Art Design Pro |
| | | |
| | | **Art Design Pro** 是一个基于 Vue3 + JavaScript + Vite 的后台管理系统模板,提供丰富的组件和工具函数。 |
| | | |
| | | - **GitHub**: https://github.com/Daymychen/art-design-pro |
| | | - **Gitee**: https://gitee.com/lingchen163/art-design-pro |
| | | - **官网**: https://www.artd.pro |
| | | - **文档**: https://www.artd.pro/docs |
| | | |
| | | ### ⚠️ 使用前必读 |
| | | |
| | | **本 Skill 假设你的项目已经集成了 Art Design Pro 组件**。如果你还没有集成,需要先完成以下步骤: |
| | | |
| | | 1. **获取 Art Design Pro** |
| | | ```bash |
| | | # 方式1: 克隆完整项目(推荐用于新建项目) |
| | | git clone https://github.com/Daymychen/art-design-pro.git your-project |
| | | |
| | | # 方式2: 复制组件目录(适用于现有项目) |
| | | # 将 art-design-pro/src/components/core 复制到你的项目 |
| | | ``` |
| | | |
| | | 2. **安装依赖** |
| | | ```bash |
| | | pnpm add element-plus |
| | | # 或 npm install element-plus |
| | | ``` |
| | | |
| | | 3. **配置路径别名** (vite.config.js) |
| | | ```js |
| | | export default defineConfig({ |
| | | resolve: { |
| | | alias: { |
| | | '@': path.resolve(__dirname, 'src') |
| | | } |
| | | } |
| | | }) |
| | | ``` |
| | | |
| | | 4. **初始化 Skill 配置** |
| | | ```bash |
| | | cd skill/art-design-pro |
| | | python scripts/init.py |
| | | ``` |
| | | |
| | | **重要提示**: |
| | | - 本 skill 中的组件路径使用 `@/` 别名,如果你的项目使用其他别名(如 `~/`),需要在 `project-config.json` 中修改 |
| | | - 组件支持自动导入,需要配置 `unplugin-vue-components` |
| | | - 当前仓库已经是纯 JavaScript 版本,示例与生成器默认输出 `.js` / `<script setup>` |
| | | |
| | | ## 核心原则 |
| | | |
| | | **优先使用现有组件,而不是从零编写 Vue 代码。** |
| | | |
| | | ## 🎨 样式规范(严格执行) |
| | | |
| | | ### ⚠️ 重要:样式使用规则 |
| | | |
| | | **严禁自我发挥编写样式**,必须严格遵循以下规范: |
| | | |
| | | #### 1. 优先使用 Tailwind CSS 工具类 |
| | | |
| | | ✅ **推荐**:使用 Tailwind CSS 工具类 |
| | | ```vue |
| | | <template> |
| | | <!-- ✅ 使用 Tailwind 工具类 --> |
| | | <div class="px-4 py-2 flex flex-wrap gap-2"> |
| | | <div class="w-full md:w-1/2">内容</div> |
| | | </div> |
| | | </template> |
| | | ``` |
| | | |
| | | ❌ **禁止**:自己编写内联样式或自定义类 |
| | | ```vue |
| | | <template> |
| | | <!-- ❌ 不要这样做 --> |
| | | <div style="padding: 16px; display: flex;"> |
| | | <div class="my-custom-class">内容</div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | /* ❌ 不要编写自定义样式 */ |
| | | .my-custom-class { |
| | | padding: 16px; |
| | | display: flex; |
| | | } |
| | | </style> |
| | | ``` |
| | | |
| | | #### 2. 使用项目定义的 CSS 变量 |
| | | |
| | | ✅ **推荐**:使用项目 CSS 变量 |
| | | ```scss |
| | | color: var(--art-gray-800); |
| | | background-color: var(--art-main-bg-color); |
| | | border: 1px solid var(--art-border-color); |
| | | ``` |
| | | |
| | | ❌ **禁止**:硬编码颜色值 |
| | | ```scss |
| | | /* ❌ 不要硬编码颜色 */ |
| | | color: #333; |
| | | background-color: #fff; |
| | | border: 1px solid #ddd; |
| | | ``` |
| | | |
| | | #### 3. 使用项目的响应式断点 |
| | | |
| | | ✅ **推荐**:使用 Tailwind 响应式前缀 |
| | | ```vue |
| | | <template> |
| | | <!-- ✅ 使用响应式类 --> |
| | | <div class="px-4 md:px-6 lg:px-8"> |
| | | <div class="w-full md:w-1/2 lg:w-1/3"> |
| | | </div> |
| | | </template> |
| | | ``` |
| | | |
| | | **项目断点**: |
| | | - `sm:`: 640px (手机) |
| | | - `md:`: 768px (平板竖屏) |
| | | - `lg:`: 1024px (平板横屏) |
| | | - `xl:`: 1280px (小笔记本) |
| | | - `2xl:`: 1536px (桌面) |
| | | |
| | | #### 4. 使用项目 Mixin(如需要) |
| | | |
| | | ```scss |
| | | // 使用项目定义的 mixin |
| | | @include art-card-base(var(--art-card-border)); |
| | | ``` |
| | | |
| | | #### 5. 禁止的行为清单 |
| | | |
| | | ❌ **绝对禁止**: |
| | | - [ ] 编写自定义 CSS 类(除组件库要求外) |
| | | - [ ] 使用内联 `style` 属性(除非是动态绑定) |
| | | - [ ] 硬编码颜色值、间距值 |
| | | - [ ] 使用未定义的 CSS 变量 |
| | | - [ ] 引入外部样式库(Tailwind、Element Plus 除外) |
| | | - [ ] 覆盖 Element Plus 组件的默认样式 |
| | | - [ ] 编写 `!important` 样式(除非修复组件库问题) |
| | | |
| | | ### 📋 样式检查清单 |
| | | |
| | | 在生成代码前,确认: |
| | | - [ ] 所有样式使用 Tailwind CSS 工具类 |
| | | - [ ] 颜色使用 CSS 变量(`var(--art-*)`) |
| | | - [ ] 间距使用 Tailwind 类(`p-4`, `m-2`, `gap-4` 等) |
| | | - [ ] 响应式使用断点前缀(`md:`, `lg:` 等) |
| | | - [ ] 没有自定义 `<style scoped>` 块 |
| | | - [ ] 没有内联 `style` 属性 |
| | | |
| | | ## 何时使用 |
| | | |
| | | 在以下场景中,**必须**先使用本 skill 查找可用组件: |
| | | |
| | | 1. ✅ 创建新的页面或视图 |
| | | 2. ✅ 添加表格、表单、图表等 UI 元素 |
| | | 3. ✅ 实现搜索、筛选、导出等功能 |
| | | 4. ✅ 添加布局组件(面包屑、页头、标签页等) |
| | | 5. ✅ 需要数据可视化(图表、统计卡片等) |
| | | |
| | | ## 组件分类速查 |
| | | |
| | | ### 📊 **表格与表单** |
| | | | 组件 | 用途 | 导入路径 | |
| | | |------|------|---------| |
| | | | `ArtTable` | 数据表格(支持分页、排序、自定义列) | `@/components/core/tables/art-table` | |
| | | | `ArtTableHeader` | 表格头部工具栏 | `@/components/core/tables/art-table-header` | |
| | | | `ArtForm` | 表单(支持响应式、校验、各种表单项) | `@/components/core/forms/art-form` | |
| | | | `ArtSearchBar` | 搜索栏 | `@/components/core/forms/art-search-bar` | |
| | | | `ArtButtonTable` | 表格操作按钮组 | `@/components/core/forms/art-button-table` | |
| | | | `ArtButtonMore` | 更多操作下拉按钮 | `@/components/core/forms/art-button-more` | |
| | | | `ArtDragVerify` | 拖拽验证 | `@/components/core/forms/art-drag-verify` | |
| | | |
| | | ### 📈 **图表与数据展示** |
| | | | 组件 | 用途 | 导入路径 | |
| | | |------|------|---------| |
| | | | `ArtStatsCard` | 统计卡片 | `@/components/core/cards/art-stats-card` | |
| | | | `ArtBarChartCard` | 柱状图卡片 | `@/components/core/cards/art-bar-chart-card` | |
| | | | `ArtLineChartCard` | 折线图卡片 | `@/components/core/cards/art-line-chart-card` | |
| | | | `ArtDonutChartCard` | 环形图卡片 | `@/components/core/cards/art-donut-chart-card` | |
| | | | `ArtProgressCard` | 进度卡片 | `@/components/core/cards/art-progress-card` | |
| | | | `ArtDataListCard` | 数据列表卡片 | `@/components/core/cards/art-data-list-card` | |
| | | | `ArtTimelineListCard` | 时间轴列表卡片 | `@/components/core/cards/art-timeline-list-card` | |
| | | | `ArtImageCard` | 图片卡片 | `@/components/core/cards/art-image-card` | |
| | | | `ArtBarChart` | 柱状图 | `@/components/core/charts/art-bar-chart` | |
| | | | `ArtLineChart` | 折线图 | `@/components/core/charts/art-line-chart` | |
| | | | `ArtRingChart` | 环形图 | `@/components/core/charts/art-ring-chart` | |
| | | | `ArtRadarChart` | 雷达图 | `@/components/core/charts/art-radar-chart` | |
| | | | `ArtMapChart` | 地图图表 | `@/components/core/charts/art-map-chart` | |
| | | |
| | | ### 🎨 **布局与导航** |
| | | | 组件 | 用途 | 导入路径 | |
| | | |------|------|---------| |
| | | | `ArtPageContent` | 页面内容容器 | `@/components/core/layouts/art-page-content` | |
| | | | `ArtBreadcrumb` | 面包屑导航 | `@/components/core/layouts/art-breadcrumb` | |
| | | | `ArtHeaderBar` | 页头工具栏 | `@/components/core/layouts/art-header-bar` | |
| | | | `ArtWorkTab` | 多标签页 | `@/components/core/layouts/art-work-tab` | |
| | | | `ArtSidebarMenu` | 侧边栏菜单 | `@/components/core/layouts/art-menus/art-sidebar-menu` | |
| | | | `ArtHorizontalMenu` | 水平菜单 | `@/components/core/layouts/art-menus/art-horizontal-menu` | |
| | | | `ArtFastEnter` | 快捷入口 | `@/components/core/layouts/art-fast-enter` | |
| | | |
| | | ### 🔧 **工具类组件** |
| | | | 组件 | 用途 | 导入路径 | |
| | | |------|------|---------| |
| | | | `ArtExcelExport` | Excel 导出 | `@/components/core/forms/art-excel-export` | |
| | | | `ArtExcelImport` | Excel 导入 | `@/components/core/forms/art-excel-import` | |
| | | | `ArtWangEditor` | 富文本编辑器 | `@/components/core/forms/art-wang-editor` | |
| | | | `ArtVideoPlayer` | 视频播放器 | `@/components/core/media/art-video-player` | |
| | | | `ArtCutterImg` | 图片裁剪 | `@/components/core/media/art-cutter-img` | |
| | | | `ArtNotification` | 通知中心 | `@/components/core/layouts/art-notification` | |
| | | |
| | | ### 🎯 **基础组件** |
| | | | 组件 | 用途 | 导入路径 | |
| | | |------|------|---------| |
| | | | `ArtLogo` | 系统logo | `@/components/core/base/art-logo` | |
| | | | `ArtSvgIcon` | SVG 图标 | `@/components/core/base/art-svg-icon` | |
| | | | `ArtBackToTop` | 返回顶部 | `@/components/core/base/art-back-to-top` | |
| | | | `ArtIconButton` | 图标按钮 | `@/components/core/widget/art-icon-button` | |
| | | |
| | | ## 使用方法 |
| | | |
| | | ### 方法 1:搜索组件(推荐) |
| | | |
| | | 当需要添加某个功能时,使用 Python 脚本搜索相关组件: |
| | | |
| | | ```bash |
| | | # 搜索关键词(支持中文和英文) |
| | | python skill/art-design-pro/scripts/search.py "表格" |
| | | |
| | | # 搜索表单相关组件 |
| | | python skill/art-design-pro/scripts/search.py "form" |
| | | |
| | | # 搜索图表 |
| | | python skill/art-design-pro/scripts/search.py "chart" |
| | | |
| | | # 搜索特定分类 |
| | | python skill/art-design-pro/scripts/search.py "table" --category tables |
| | | ``` |
| | | |
| | | ### 方法 2:直接查阅组件文档 |
| | | |
| | | 查看完整组件列表: |
| | | |
| | | ```bash |
| | | python skill/art-design-pro/scripts/list.py |
| | | ``` |
| | | |
| | | ## 常见场景组件选择 |
| | | |
| | | ### 场景 1:创建 CRUD 列表页 |
| | | |
| | | **必选组件:** |
| | | - `ArtTable` - 数据表格 |
| | | - `ArtSearchBar` - 搜索栏 |
| | | - `ArtTableHeader` - 表格工具栏(新增、批量删除等) |
| | | - `ArtPageContent` - 页面容器 |
| | | |
| | | **⚠️ ArtSearchBar 必须使用 `v-model`:** |
| | | ```vue |
| | | <ArtSearchBar |
| | | ref="searchBarRef" |
| | | v-model="searchForm" |
| | | :items="searchItems" |
| | | @search="handleSearch" |
| | | @reset="handleReset" |
| | | /> |
| | | |
| | | <script setup> |
| | | const searchBarRef = ref() |
| | | const searchForm = ref({ |
| | | name: '', |
| | | status: '' |
| | | }) |
| | | |
| | | const searchItems = [ |
| | | { |
| | | key: 'name', |
| | | type: 'input', |
| | | label: '名称', |
| | | props: { placeholder: '请输入名称', clearable: true } |
| | | }, |
| | | { |
| | | key: 'status', |
| | | type: 'select', |
| | | label: '状态', |
| | | props: { |
| | | options: [ |
| | | { label: '启用', value: '1' }, |
| | | { label: '禁用', value: '0' } |
| | | ] |
| | | } |
| | | } |
| | | ] |
| | | |
| | | const handleSearch = () => { |
| | | console.log('搜索参数:', searchForm.value) |
| | | } |
| | | |
| | | const handleReset = () => { |
| | | searchForm.value = { |
| | | name: '', |
| | | status: '' |
| | | } |
| | | } |
| | | </script> |
| | | ``` |
| | | |
| | | **可选组件:** |
| | | - `ArtExcelExport` - 数据导出 |
| | | - `ArtButtonTable` - 表格行操作按钮 |
| | | |
| | | ### 场景 2:创建表单页 |
| | | |
| | | **必选组件:** |
| | | - `ArtForm` - 表单组件 |
| | | |
| | | **⚠️ ArtForm 必须使用 `:items` 和 `key`:** |
| | | ```vue |
| | | <ArtForm |
| | | ref="formRef" |
| | | :items="formItems" |
| | | :model="formData" |
| | | :rules="formRules" |
| | | label-width="120px" |
| | | /> |
| | | |
| | | <script setup> |
| | | const formRef = ref() |
| | | const formData = ref({ |
| | | username: '', |
| | | status: '' |
| | | }) |
| | | |
| | | const formItems = [ |
| | | { |
| | | key: 'username', // ✅ 必须使用 key |
| | | label: '用户名', |
| | | type: 'input', |
| | | props: { placeholder: '请输入用户名' } |
| | | }, |
| | | { |
| | | key: 'status', |
| | | label: '状态', |
| | | type: 'select', |
| | | props: { |
| | | options: [ |
| | | { label: '启用', value: '1' }, |
| | | { label: '禁用', value: '0' } |
| | | ] |
| | | } |
| | | } |
| | | ] |
| | | |
| | | const formRules = { |
| | | username: [{ required: true, message: '请输入用户名', trigger: 'blur' }] |
| | | } |
| | | </script> |
| | | ``` |
| | | |
| | | **❌ 错误用法(不要使用):** |
| | | ```vue |
| | | <!-- ❌ 不要使用 schema --> |
| | | <ArtForm :schema="formSchema" /> |
| | | |
| | | <!-- ❌ 不要使用 field --> |
| | | const formSchema = [ |
| | | { field: 'username', label: '用户名' } // 错误:应该是 key |
| | | ] |
| | | ``` |
| | | |
| | | **⚠️ 重要:在 ElDialog 中使用 ArtForm** |
| | | |
| | | 当在模态窗(ElDialog)中使用 ArtForm 时,**必须隐藏内部按钮**并**设置 span 属性**: |
| | | |
| | | ```vue |
| | | <ElDialog v-model="dialogVisible" title="新增用户"> |
| | | <ArtForm |
| | | ref="formRef" |
| | | :items="formItems" |
| | | :model="formData" |
| | | :rules="formRules" |
| | | :show-reset="false" <!-- ✅ 必须设置 --> |
| | | :show-submit="false" <!-- ✅ 必须设置 --> |
| | | label-width="120px" |
| | | /> |
| | | |
| | | <template #footer> |
| | | <ElButton @click="dialogVisible = false">取消</ElButton> |
| | | <ElButton type="primary" @click="handleSubmit">确定</ElButton> |
| | | </template> |
| | | </ElDialog> |
| | | |
| | | <script setup> |
| | | const formItems = [ |
| | | { |
| | | key: 'username', |
| | | label: '用户名', |
| | | type: 'input', |
| | | span: 24, // ✅ 必须设置:模态窗中每个字段占据整行 |
| | | props: { placeholder: '请输入用户名' } |
| | | } |
| | | ] |
| | | </script> |
| | | ``` |
| | | |
| | | **重要提示:** |
| | | |
| | | 1. **隐藏内部按钮**:`:show-reset="false"` 和 `:show-submit="false"` |
| | | 2. **设置 span 属性**:每个表单项必须设置 `span: 24`(整行显示) |
| | | - ArtForm 默认 `span: 6`(每个字段占 1/4 宽度) |
| | | - 在 600px 宽的模态窗中,不设置 span 会导致输入框宽度仅 6px |
| | | |
| | | **原因:** ArtForm 默认显示重置和提交按钮,在模态窗中会与 footer 中的自定义按钮冲突;默认 span 值不适合模态窗场景。 |
| | | |
| | | **可选组件:** |
| | | - `ArtDragVerify` - 滑块验证 |
| | | - `ArtWangEditor` - 富文本编辑 |
| | | |
| | | ### 场景 3:创建仪表板/统计页 |
| | | |
| | | **必选组件:** |
| | | - `ArtStatsCard` - 统计卡片 |
| | | - `ArtLineChart` - 折线图(数据可视化) |
| | | - `ArtBarChart` - 柱状图(数据对比) |
| | | |
| | | **可选组件:** |
| | | - `ArtRingChart` - 环形图(占比分析) |
| | | - `ArtProgressCard` - 进度展示 |
| | | |
| | | **⚠️ 重要:仪表板布局标准** |
| | | |
| | | 仪表板页面必须遵循统一的布局规范: |
| | | |
| | | ```vue |
| | | <template> |
| | | <!-- ✅ 根元素必须使用 art-full-height 类 --> |
| | | <div class="art-full-height"> |
| | | <!-- 统计卡片区域:使用 ElRow 响应式布局 --> |
| | | <ElRow :gutter="20" class="mb-5"> |
| | | <ElCol :xs="24" :sm="12" :md="6" v-for="stat in stats" :key="stat.key"> |
| | | <ArtStatsCard |
| | | :title="stat.title" |
| | | :count="stat.value" |
| | | :description="stat.description" |
| | | :icon="stat.icon" |
| | | :iconStyle="stat.iconStyle" |
| | | /> |
| | | </ElCol> |
| | | </ElRow> |
| | | |
| | | <!-- 图表区域:使用 ElCard 包裹 --> |
| | | <ElRow :gutter="20"> |
| | | <ElCol :xs="24" :md="12" :lg="12"> |
| | | <ElCard class="art-table-card" shadow="never"> |
| | | <template #header> |
| | | <div class="art-card-header"> |
| | | <div class="title"> |
| | | <h4>流量趋势</h4> |
| | | <p>最近7天</p> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | <ArtLineChart |
| | | :data="chartData" |
| | | :xAxisData="xAxis" |
| | | :showLegend="true" |
| | | :showAreaColor="true" |
| | | /> |
| | | </ElCard> |
| | | </ElCol> |
| | | </ElRow> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { ref } from 'vue' |
| | | import { ElRow, ElCol, ElCard } from 'element-plus' |
| | | import ArtStatsCard from '@/components/core/cards/art-stats-card/index.vue' |
| | | import ArtLineChart from '@/components/core/charts/art-line-chart/index.vue' |
| | | |
| | | const stats = ref([ |
| | | { |
| | | key: 'total', |
| | | title: '总数', |
| | | value: 100, |
| | | description: '系统总数', |
| | | icon: 'ri:user-line', |
| | | iconStyle: 'background: linear-gradient(135deg, #667eea 0%, #764ba2 100%)' |
| | | } |
| | | ]) |
| | | |
| | | const chartData = ref([ |
| | | { name: '流量', data: [120, 132, 101], smooth: true, showAreaColor: true } |
| | | ]) |
| | | </script> |
| | | ``` |
| | | |
| | | **布局规范说明:** |
| | | - ✅ 根元素使用 `art-full-height` 类(自动计算页面剩余高度) |
| | | - ✅ 统计卡片使用 `ElRow` + `ElCol` 响应式布局 |
| | | - ✅ 图表区域使用 `ElCard class="art-table-card" shadow="never"` |
| | | - ✅ 图表标题使用 ElCard 的 header slot |
| | | - ❌ 不要使用自定义 padding(如 `p-5`) |
| | | - ❌ 不要在 ElCol 上添加间距类(如 `mb-5`) |
| | | |
| | | **⚠️ 常见错误:** |
| | | ```vue |
| | | <!-- ❌ 错误:使用统计卡片组件(ArtLineChartCard 是带迷你图的卡片) --> |
| | | import ArtLineChartCard from '@/components/core/cards/art-line-chart-card' |
| | | |
| | | <!-- ✅ 正确:使用图表组件(ArtLineChart 是完整的图表) --> |
| | | import ArtLineChart from '@/components/core/charts/art-line-chart' |
| | | ``` |
| | | |
| | | ### 场景 4:创建详情页 |
| | | |
| | | **必选组件:** |
| | | - `ArtPageContent` - 页面容器 |
| | | - `ArtBreadcrumb` - 面包屑 |
| | | |
| | | **可选组件:** |
| | | - `ArtTimelineListCard` - 时间轴 |
| | | - `ArtImageCard` - 图片展示 |
| | | |
| | | ## 组件使用示例 |
| | | |
| | | ### ArtTable - 数据表格 |
| | | |
| | | ```vue |
| | | <template> |
| | | <ElCard class="art-table-card" shadow="never"> |
| | | <!-- ⚠️ 重要:ArtTableHeader 需要通过 v-model:columns 绑定列配置 --> |
| | | <ArtTableHeader v-model:columns="columns" :loading="loading" /> |
| | | |
| | | <ArtTable |
| | | :data="tableData" |
| | | :columns="columns" |
| | | :pagination="pagination" |
| | | :loading="loading" |
| | | fit |
| | | @pagination:current-change="handlePageChange" |
| | | > |
| | | <!-- 自定义列 --> |
| | | <template #status="{ row }"> |
| | | <el-tag :type="row.status === 1 ? 'success' : 'danger'"> |
| | | {{ row.status === 1 ? '启用' : '禁用' }} |
| | | </el-tag> |
| | | </template> |
| | | |
| | | <!-- 操作列 --> |
| | | <template #action="{ row }"> |
| | | <el-button link @click="handleEdit(row)">编辑</el-button> |
| | | <el-button link type="danger" @click="handleDelete(row)">删除</el-button> |
| | | </template> |
| | | </ArtTable> |
| | | </ElCard> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import ArtTable from '@/components/core/tables/art-table/index.vue' |
| | | import ArtTableHeader from '@/components/core/tables/art-table-header/index.vue' |
| | | |
| | | const columns = [ |
| | | { prop: 'name', label: '名称' }, |
| | | { prop: 'status', label: '状态', useSlot: true }, |
| | | { prop: 'action', label: '操作', useSlot: true, width: 200 } |
| | | ] |
| | | |
| | | const tableData = ref([]) |
| | | const pagination = reactive({ current: 1, size: 10, total: 0 }) |
| | | </script> |
| | | ``` |
| | | |
| | | **⚠️ 重要注意事项:** |
| | | |
| | | 1. **列设置功能**:如果需要使用 `ArtTableHeader` 的列设置功能(显示/隐藏列),必须通过 `v-model:columns="columns"` 将列配置传递给 `ArtTableHeader` |
| | | 2. **fit 属性**:建议在 `ArtTable` 上添加 `fit` 属性,确保表格列宽自适应容器宽度 |
| | | 3. **columns 来源**:`columns` 必须是从 `useTable` Hook 返回的响应式数据,或定义为响应式引用 |
| | | 4. **⚠️ 列宽配置原则(重要)**: |
| | | - ✅ **使用 `width`**:内容长度固定的列(状态、角色、操作列、端口等) |
| | | - ✅ **使用 `minWidth`**:内容长度可变的列(用户名、备注、域名、时间等) |
| | | - ❌ **避免全部使用固定 `width`**:会导致表格总宽度固定,屏幕更宽时右侧出现空白 |
| | | |
| | | **示例**: |
| | | ```js |
| | | // ✅ 正确:混合使用 width 和 minWidth |
| | | const columns = [ |
| | | { prop: 'username', label: '用户名', minWidth: 120 }, // 自动扩展 |
| | | { prop: 'remark', label: '备注', minWidth: 150 }, // 自动扩展 |
| | | { prop: 'status', label: '状态', width: 80 }, // 固定宽度 |
| | | { prop: 'action', label: '操作', width: 200, fixed: 'right' } // 固定宽度 |
| | | ] |
| | | |
| | | // ❌ 错误:全部使用固定 width |
| | | const columns = [ |
| | | { prop: 'username', label: '用户名', width: 150 }, // 会导致右侧空白 |
| | | { prop: 'remark', label: '备注', width: 200 }, |
| | | { prop: 'status', label: '状态', width: 100 } |
| | | ] |
| | | ``` |
| | | |
| | | ### ArtForm - 表单 |
| | | |
| | | ```vue |
| | | <template> |
| | | <art-form |
| | | v-model="formData" |
| | | :items="formItems" |
| | | @submit="handleSubmit" |
| | | @reset="handleReset" |
| | | /> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import ArtForm from '@/components/core/forms/art-form/index.vue' |
| | | |
| | | const formData = ref({}) |
| | | |
| | | const formItems = [ |
| | | { key: 'name', label: '名称', type: 'input', span: 12 }, |
| | | { key: 'email', label: '邮箱', type: 'input', span: 12 }, |
| | | { key: 'status', label: '状态', type: 'switch', span: 12 }, |
| | | { key: 'role', label: '角色', type: 'select', options: [ |
| | | { label: '管理员', value: 'admin' }, |
| | | { label: '用户', value: 'user' } |
| | | ], span: 12 } |
| | | ] |
| | | </script> |
| | | ``` |
| | | |
| | | ### ArtStatsCard - 统计卡片 |
| | | |
| | | ```vue |
| | | <template> |
| | | <ArtStatsCard |
| | | title="总用户数" |
| | | :count="userCount" |
| | | description="系统所有用户" |
| | | icon="ri:user-line" |
| | | :icon-style="iconStyle" |
| | | /> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import ArtStatsCard from '@/components/core/cards/art-stats-card/index.vue' |
| | | |
| | | const userCount = ref(1234) |
| | | const iconStyle = 'background: linear-gradient(135deg, #667eea 0%, #764ba2 100%)' |
| | | </script> |
| | | ``` |
| | | |
| | | **⚠️ 重要 Props:** |
| | | - `:count` - 数值(不是 `:value`) |
| | | - `description` - 描述文本(必需) |
| | | - `:icon` - Remix Icon 图标(如 `ri:user-line`) |
| | | - `:iconStyle` - 图标背景样式(CSS 字符串) |
| | | |
| | | ## 工作流程 |
| | | |
| | | 当用户请求编写页面时: |
| | | |
| | | 1. **分析需求** - 确定页面类型(列表、表单、仪表板等) |
| | | 2. **搜索组件** - 使用 `search.py` 查找相关组件 |
| | | 3. **选择组件** - 根据功能需求选择最合适的组件 |
| | | 4. **查看文档** - 阅读组件的 props、slots、events |
| | | 5. **编写代码** - 使用组件而不是自己编写 Vue 代码 |
| | | |
| | | ## 禁止行为 |
| | | |
| | | ### 组件使用 |
| | | ❌ **不要**自己编写表格组件 → 使用 `ArtTable` |
| | | ❌ **不要**自己编写表单组件 → 使用 `ArtForm` |
| | | ❌ **不要**自己编写图表组件 → 使用 `Art*Chart` |
| | | ❌ **不要**自己编写统计卡片 → 使用 `ArtStatsCard` |
| | | ❌ **不要**自己编写搜索栏 → 使用 `ArtSearchBar` |
| | | |
| | | ### 样式编写(⚠️ 严格执行) |
| | | ❌ **不要**编写自定义 CSS 类 → 使用 Tailwind CSS 工具类 |
| | | ❌ **不要**使用内联 `style` 属性 → 使用 Tailwind 类 |
| | | ❌ **不要**硬编码颜色值 → 使用 `var(--art-*)` CSS 变量 |
| | | ❌ **不要**覆盖 Element Plus 样式 → 使用组件 props 控制 |
| | | ❌ **不要**编写 `<style scoped>` → 使用 Tailwind 工具类 |
| | | ❌ **不要**引入外部样式库 → 只用 Tailwind + Element Plus |
| | | |
| | | **为什么这么严格?** |
| | | - ✅ 保持 UI 风格统一 |
| | | - ✅ 避免样式冲突 |
| | | - ✅ 减少代码体积 |
| | | - ✅ 确保主题切换正常 |
| | | - ✅ 符合项目设计规范 |
| | | |
| | | ## 组件位置 |
| | | |
| | | 所有组件位于:`src/components/core/` |
| | | |
| | | ## 相关资源 |
| | | |
| | | ### 官方文档 |
| | | - Art Design Pro 官方文档:https://www.artd.pro/docs/ |
| | | - Element Plus 文档:https://element-plus.org/ |
| | | |
| | | ### 本地文档 |
| | | |
| | | **文档已重组为面向集成的结构**,查看完整索引:`docs/00-index.md` |
| | | |
| | | #### 集成相关(最重要) |
| | | - `INTEGRATION_GUIDE.md` - **完整集成指南**:将 Art Design Pro 集成到其他项目 |
| | | - `templates/` - 配置模板:vite.config.js、package.json、.env.example 等 |
| | | - `scripts/verify.py` - 集成验证工具 |
| | | |
| | | #### 快速开始 |
| | | - `docs/getting-started/01-introduce.md` - 框架介绍和特色 |
| | | - `docs/getting-started/02-quick-start.md` - 快速开始指南 |
| | | - `docs/getting-started/03-must-read.md` - **必读**:接口对接、网络请求、菜单配置 |
| | | - `docs/getting-started/04-standard.md` - 代码规范 |
| | | - `docs/getting-started/components-basics.md` - 组件和图标基础 |
| | | - `docs/getting-started/configuration-guide.md` - 系统配置指南(主题、环境变量) |
| | | |
| | | #### 核心概念 |
| | | - `docs/core-concepts/project-structure.md` - 项目结构 |
| | | - `docs/core-concepts/routing.md` - 路由和菜单配置 |
| | | |
| | | #### 组件和 Hooks |
| | | - `docs/components/art-search-bar.md` - ArtSearchBar 组件文档 |
| | | - `docs/hooks/use-table.md` - useTable Hook 文档 |
| | | - `CHEATSHEET.md` - **组件速查表**:全部 56 个组件的导入路径和用法 |
| | | |
| | | #### 辅助文档 |
| | | - `FAQ.md` - 常见问题 |
| | | - `BEST_PRACTICES.md` - 最佳实践 |
| | | - `generator-guide.md` - 代码生成器使用指南 |
| | | |
| | | **使用方法**: |
| | | 所有文档都是本地 Markdown 文件,在编写代码前先查阅相关文档,确保遵循官方规范。 |
| | | |
| | | |
| | | |
| New file |
| | |
| | | category,component,name_cn,name_en,description,import_path,props,slots,common_usage |
| | | tables,ArtTable,数据表格,Data Table,支持分页、排序、自定义列、响应式高度的数据表格,@/components/core/tables/art-table,data,columns,pagination,loading,height,border,stripe,default,action,status,custom column,CRUD列表页 |
| | | tables,ArtTableHeader,表格头部工具栏,Table Header Toolbar,表格顶部工具栏(新增、批量删除、导出等),@/components/core/tables/art-table-header,title,buttons,table-actions,default,列表页工具栏 |
| | | forms,ArtForm,表单组件,Form Component,支持响应式、校验、各种表单项类型,@/components/core/forms/art-form,items,modelValue,span,gutter,labelPosition,showReset,showSubmit,default,表单页、筛选条件 |
| | | forms,ArtSearchBar,搜索栏,Search Bar,表单式搜索栏,自动布局,@/components/core/forms/art-search-bar,items,modelValue,span,search-button-text,default,列表页搜索 |
| | | forms,ArtButtonTable,表格操作按钮组,Table Action Buttons,表格行的操作按钮(编辑、删除等),@/components/core/forms/art-button-table,buttons,row,default,表格操作列 |
| | | forms,ArtButtonMore,更多操作按钮,More Actions Button,下拉式更多操作按钮,@/components/core/forms/art-button-more,button,row,commands,default,表格操作列溢出 |
| | | forms,ArtExcelExport,Excel导出,Excel Export,数据导出到Excel,@/components/core/forms/art-excel-export,data,filename,columns,default,数据导出功能 |
| | | forms,ArtExcelImport,Excel导入,Excel Import,Excel数据导入,@/components/core/forms/art-excel-import,upload-url,template-url,accept,default,批量导入数据 |
| | | forms,ArtWangEditor,富文本编辑器,Rich Text Editor,基于wangEditor的富文本编辑,@/components/core/forms/art-wang-editor,modelValue,height,placeholder,default,文章内容、公告 |
| | | forms,ArtDragVerify,拖拽验证,Drag Verify,滑块验证组件,@/components/core/forms/art-drag-verify,v-model,width,height,bar-size,default,表单验证 |
| | | cards,ArtStatsCard,统计卡片,Stats Card,展示统计数据和趋势,@/components/core/cards/art-stats-card,title,value,icon,color,trend,default,仪表板、数据统计 |
| | | cards,ArtBarChartCard,柱状图卡片,Bar Chart Card,带图表的卡片组件,@/components/core/cards/art-bar-chart-card,title,data,x-key,y-key,color,default,数据可视化 |
| | | cards,ArtLineChartCard,折线图卡片,Line Chart Card,带折线图的卡片,@/components/core/cards/art-line-chart-card,title,data,x-key,y-key,color,default,趋势展示 |
| | | cards,ArtDonutChartCard,环形图卡片,Donut Chart Card,带环形图的卡片,@/components/core/cards/art-donut-chart-card,title,data,name-key,value-key,default,占比分析 |
| | | cards,ArtProgressCard,进度卡片,Progress Card,显示进度百分比,@/components/core/cards/art-progress-card,title,percentage,color,status,default,任务进度、完成度 |
| | | cards,ArtDataListCard,数据列表卡片,Data List Card,列表形式的数据展示,@/components/core/cards/art-data-list-card,title,data,columns,default,简洁数据展示 |
| | | cards,ArtTimelineListCard,时间轴列表卡片,Timeline List Card,时间轴形式的数据列表,@/components/core/cards/art-timeline-list-card,title,data,timestamp-key,content-key,default,操作日志、变更记录 |
| | | cards,ArtImageCard,图片卡片,Image Card,展示图片的卡片,@/components/core/cards/art-image-card,src,title,description,actions,default,图片展示 |
| | | charts,ArtBarChart,柱状图,Bar Chart,ECharts柱状图,@/components/core/charts/art-bar-chart,data,x-key,y-key,color,default,数据对比 |
| | | charts,ArtLineChart,折线图,Line Chart,ECharts折线图,@/components/core/charts/art-line-chart,data,x-key,y-key,color,default,趋势分析 |
| | | charts,ArtRingChart,环形图,Ring Chart,ECharts环形图,@/components/core/charts/art-ring-chart,data,name-key,value-key,default,占比分析 |
| | | charts,ArtRadarChart,雷达图,Radar Chart,ECharts雷达图,@/components/core/charts/art-radar-chart,data,indicators,default,多维对比 |
| | | charts,ArtMapChart,地图图表,Map Chart,ECharts地图图表,@/components/core/charts/art-map-chart,data,map-key,value-key,default,地理数据 |
| | | charts,ArtDualBarCompareChart,双柱状对比图,Dual Bar Compare,双系列柱状图对比,@/components/core/charts/art-dual-bar-compare-chart,data,x-key,y1-key,y2-key,default,数据对比 |
| | | charts,ArtHBarChart,横向柱状图,Horizontal Bar,横向柱状图,@/components/core/charts/art-h-bar-chart,data,x-key,y-key,default,排名展示 |
| | | charts,ArtKLineChart,K线图,K-Line Chart,ECharts K线图,@/components/core/charts/art-k-line-chart,data,date,values,default,金融数据 |
| | | charts,ArtScatterChart,散点图,Scatter Chart,ECharts散点图,@/components/core/charts/art-scatter-chart,data,x-key,y-key,default,相关性分析 |
| | | layouts,ArtPageContent,页面内容容器,Page Content,页面内容容器,自动计算高度,@/components/core/layouts/art-page-content,default,all pages,所有页面容器 |
| | | layouts,ArtBreadcrumb,面包屑导航,Breadcrumb,面包屑导航,@/components/core/layouts/art-breadcrumb,routes,default,详情页、多级页面 |
| | | layouts,ArtHeaderBar,页头工具栏,Header Bar,页面头部工具栏,@/components/core/layouts/art-header-bar,title,actions,default,页面顶部 |
| | | layouts,ArtWorkTab,多标签页,Work Tab,页面标签页切换,@/components/core/layouts/art-work-tab,default,多标签页管理 |
| | | layouts,ArtSidebarMenu,侧边栏菜单,Sidebar Menu,左侧菜单导航,@/components/core/layouts/art-menus/art-sidebar-menu,default,主菜单 |
| | | layouts,ArtHorizontalMenu,水平菜单,Horizontal Menu,顶部水平菜单,@/components/core/layouts/art-menus/art-horizontal-menu,default,顶部导航 |
| | | layouts,ArtMixedMenu,混合菜单,Mixed Menu,侧边栏+顶部混合菜单,@/components/core/layouts/art-menus/art-mixed-menu,default,主菜单 |
| | | layouts,ArtFastEnter,快捷入口,Fast Entry,快捷功能入口,@/components/core/layouts/art-fast-enter,items,default,仪表板、首页 |
| | | layouts,ArtNotification,通知中心,Notification Center,消息通知,@/components/core/layouts/art-notification,default,消息通知 |
| | | layouts,ArtGlobalComponent,全局组件,Global Component,全局组件容器,@/components/core/layouts/art-global-component,default,全局功能 |
| | | layouts,ArtGlobalSearch,全局搜索,Global Search,全局搜索功能,@/components/core/layouts/art-global-search,default,全局搜索 |
| | | layouts,ArtSettingsPanel,设置面板,Settings Panel,设置抽屉面板,@/components/core/layouts/art-settings-panel,default,系统设置 |
| | | layouts,ArtScreenLock,屏幕锁,Screen Lock,屏幕锁定,@/components/core/layouts/art-screen-lock,default,安全功能 |
| | | layouts,ArtChatWindow,聊天窗口,Chat Window,聊天窗口组件,@/components/core/layouts/art-chat-window,default,聊天功能 |
| | | layouts,ArtFireworksEffect,烟花特效,Fireworks Effect,烟花动画效果,@/components/core/layouts/art-fireworks-effect,default,节日特效 |
| | | media,ArtVideoPlayer,视频播放器,Video Player,视频播放组件,@/components/core/media/art-video-player,src,poster,autoplay,default,视频播放 |
| | | media,ArtCutterImg,图片裁剪,Image Cropper,图片裁剪组件,@/components/core/media/art-cutter-img,src,width,height,default,头像上传、图片裁剪 |
| | | banners,ArtBasicBanner,基础横幅,Basic Banner,基础横幅组件,@/components/core/banners/art-basic-banner,title,description,actions,default,页面横幅 |
| | | banners,ArtCardBanner,卡片横幅,Card Banner,卡片式横幅,@/components/core/banners/art-card-banner,cards,default,首页横幅 |
| | | text-effect,ArtCountTo,数字动画,Count To,数字滚动动画,@/components/core/text-effect/art-count-to,start,end,duration,default,统计数字动画 |
| | | text-effect,ArtTextScroll,文本滚动,Text Scroll,文本滚动效果,@/components/core/text-effect/art-text-scroll,text,speed,default,公告、通知 |
| | | text-effect,ArtFestivalTextScroll,节日文本滚动,Festival Text Scroll,节日主题文本滚动,@/components/core/text-effect/art-festival-text-scroll,text,speed,default,节日特效 |
| | | base,ArtLogo,系统logo,System Logo,系统Logo组件,@/components/core/base/art-logo,size,default,登录页、侧边栏 |
| | | base,ArtSvgIcon,SVG图标,SVG Icon,SVG图标组件,@/components/core/base/art-svg-icon,name,color,size,default,图标展示 |
| | | base,ArtBackToTop,返回顶部,Back to Top,返回顶部按钮,@/components/core/base/art-back-to-top,default,长页面 |
| | | widget,ArtIconButton,图标按钮,Icon Button,图标按钮组件,@/components/core/widget/art-icon-button,icon,color,size,default,快捷操作 |
| | | others,ArtMenuRight,右键菜单,Context Menu,右键菜单,@/components/core/others/art-menu-right,items,default,表格行操作 |
| | | others,ArtWatermark,水印,Watermark,页面水印,@/components/core/others/art-watermark,text,default,安全防护 |
| | | theme,ArtThemeSvg,主题SVG,Theme SVG,让SVG图片跟随主题的组件,@/components/core/theme/theme-svg,size,themeColor,src,default,主题相关SVG |
| | | |
| New file |
| | |
| | | # Art Design Pro 文档索引 |
| | | |
| | | **本地化状态**: ✅ 100% |
| | | |
| | | 本目录包含 Art Design Pro 的文档,已重组为面向集成的结构。 |
| | | |
| | | --- |
| | | |
| | | ## 📚 快速开始 |
| | | |
| | | | 文件 | 标题 | 说明 | |
| | | |------|------|------| |
| | | | [01-introduce.md](getting-started/01-introduce.md) | 介绍 | 框架介绍和核心特色 | |
| | | | [02-quick-start.md](getting-started/02-quick-start.md) | 快速开始 | 安装和运行指南 | |
| | | | [03-must-read.md](getting-started/03-must-read.md) | 必读文档 | 接口对接、网络请求、菜单配置 | |
| | | | [04-standard.md](getting-started/04-standard.md) | 规范 | 代码规范和提交规范 | |
| | | |
| | | ## 🔧 配置和集成 |
| | | |
| | | | 文件 | 标题 | 说明 | |
| | | |------|------|------| |
| | | | [components-basics.md](getting-started/components-basics.md) | 组件和图标基础 | Element Plus、系统组件、图标系统 | |
| | | | [configuration-guide.md](getting-started/configuration-guide.md) | 系统配置指南 | 主题配置、环境变量、全局设置 | |
| | | | [style-guide.md](getting-started/style-guide.md) | **样式规范(重要)** | Tailwind CSS、CSS 变量、响应式设计 | |
| | | |
| | | ## 🏗️ 核心概念 |
| | | |
| | | | 文件 | 标题 | 说明 | |
| | | |------|------|------| |
| | | | [project-structure.md](core-concepts/project-structure.md) | 项目结构 | 目录结构和文件说明 | |
| | | | [routing.md](core-concepts/routing.md) | 路由和菜单 | 路由配置和菜单生成 | |
| | | | [permission.md](core-concepts/permission.md) | 权限管理 | 前端/后端权限模式、角色配置 | |
| | | |
| | | ## 🧩 组件 |
| | | |
| | | | 文件 | 标题 | 说明 | |
| | | |------|------|------| |
| | | | [art-form.md](components/art-form.md) | **ArtForm** | **表单组件完整文档(新增)** | |
| | | | [art-search-bar.md](components/art-search-bar.md) | ArtSearchBar | 搜索栏组件完整文档 | |
| | | |
| | | ## 🪝 Hooks |
| | | |
| | | | 文件 | 标题 | 说明 | |
| | | |------|------|------| |
| | | | [use-table.md](hooks/use-table.md) | UseTable | 表格 Hook 文档 | |
| | | |
| | | ## 🎯 示例 |
| | | |
| | | | 目录 | 说明 | |
| | | |------|------| |
| | | | [examples/](examples/) | 使用示例 | |
| | | |
| | | --- |
| | | |
| | | ## 📖 辅助文档 |
| | | |
| | | | 文件 | 标题 | 说明 | |
| | | |------|------|------| |
| | | | [FAQ.md](../FAQ.md) | 常见问题 | 常见问题解答 | |
| | | | [BEST_PRACTICES.md](../BEST_PRACTICES.md) | 最佳实践 | 最佳实践指南 | |
| | | | [CHEATSHEET.md](../CHEATSHEET.md) | 速查表 | 组件速查表 | |
| | | | [generator-guide.md](generator-guide.md) | 代码生成器 | 代码生成器使用指南 | |
| | | | [INTEGRATION_GUIDE.md](../INTEGRATION_GUIDE.md) | 集成指南 | 集成到其他项目的完整指南 | |
| | | |
| | | --- |
| | | |
| | | ## 🎯 使用建议 |
| | | |
| | | ### 集成到其他项目 |
| | | 1. **首先阅读** [INTEGRATION_GUIDE.md](../INTEGRATION_GUIDE.md) - 完整的集成指南 |
| | | 2. **配置模板** - 参考 `../templates/` 目录中的配置文件 |
| | | 3. **验证集成** - 运行 `python ../scripts/verify.py` |
| | | |
| | | ### 在 Art Design Pro 项目中开发 |
| | | 1. **开发前必读**:[03-must-read.md](getting-started/03-must-read.md) |
| | | 2. **了解项目**:[project-structure.md](core-concepts/project-structure.md) |
| | | 3. **遵循规范**:[04-standard.md](getting-started/04-standard.md) |
| | | |
| | | ### 组件使用参考 |
| | | - 表单组件:[art-form.md](components/art-form.md) ⭐ |
| | | - 搜索栏:[art-search-bar.md](components/art-search-bar.md) |
| | | - 表格 Hook:[use-table.md](hooks/use-table.md) |
| | | |
| | | --- |
| | | |
| | | ## ✅ 本地化优势 |
| | | |
| | | - **零网络依赖** - 所有文档本地保存 |
| | | - **零幻觉风险** - 内容来自官方文档和实际代码 |
| | | - **快速访问** - 无需等待网络加载 |
| | | - **永久可用** - 不受 URL 变化影响 |
| | | |
| | | --- |
| | | |
| | | **更新日期**: 2026-03-04 |
| | | **版本**: v2.2.1+ |
| | | **官方文档**: https://www.artd.pro/docs/ |
| | | |
| | | |
| | | |
| New file |
| | |
| | | # ArtForm 表单组件 | Art Design Pro |
| | | |
| | | ## 概述 |
| | | |
| | | ArtForm 是一个功能强大的表单组件,基于 Element Plus 封装,支持双向绑定、表单验证、响应式布局等特性。 |
| | | |
| | | ## 特性 |
| | | |
| | | - **双向绑定支持** - 使用 `defineModel`,支持 `v-model` 和 `:model` 两种绑定方式 |
| | | - **表单验证** - 完整的表单验证支持,与 Element Plus Form 规则兼容 |
| | | - **响应式布局** - 基于 24 栅格系统,自适应不同屏幕尺寸 |
| | | - **多种控件类型** - 支持 input、number、select、switch、date 等 20+ 种表单控件 |
| | | - **动态控制** - 支持动态显示隐藏表单项 |
| | | - **按钮控制** - 可配置显示/隐藏提交和重置按钮 |
| | | |
| | | ## 源码定义 |
| | | |
| | | ```js |
| | | interface FormProps { |
| | | items: FormItem[] // 表单项配置数组 |
| | | span?: number // 每列的宽度(基于 24 格布局) |
| | | gutter?: number // 表单控件间隙 |
| | | labelPosition?: 'left' | 'right' | 'top' // 标签位置 |
| | | labelWidth?: string | number // 文字宽度(驼峰命名) |
| | | buttonLeftLimit?: number // 按钮靠左对齐限制 |
| | | showReset?: boolean // 是否显示重置按钮 |
| | | showSubmit?: boolean // 是否显示提交按钮 |
| | | disabledSubmit?: boolean // 是否禁用提交按钮 |
| | | } |
| | | |
| | | const modelValue = defineModel({ default: {} }) |
| | | ``` |
| | | |
| | | ## 数据绑定方式 |
| | | |
| | | ### 方式 1:v-model 双向绑定(推荐用于配置页面) |
| | | |
| | | ```vue |
| | | <template> |
| | | <ArtForm |
| | | v-model="formData" |
| | | :items="formItems" |
| | | :show-reset="false" |
| | | :show-submit="false" |
| | | labelWidth="200px" |
| | | /> |
| | | <div class="flex justify-end"> |
| | | <ElButton type="primary" @click="handleSave">保存</ElButton> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | const formData = ref({ |
| | | username: '', |
| | | status: '1' |
| | | }) |
| | | |
| | | const formItems = [ |
| | | { key: 'username', label: '用户名', type: 'input' }, |
| | | { key: 'status', label: '状态', type: 'select', props: { options: [...] } } |
| | | ] |
| | | </script> |
| | | ``` |
| | | |
| | | **适用场景:** |
| | | - 配置页面(直接编辑,实时同步) |
| | | - 简单表单(不需要手动控制提交时机) |
| | | - 需要独立保存按钮的场景 |
| | | |
| | | ### 方式 2::model 单向绑定(推荐用于弹窗表单) |
| | | |
| | | ```vue |
| | | <template> |
| | | <ElDialog v-model="dialogVisible" title="新增用户"> |
| | | <ArtForm |
| | | ref="formRef" |
| | | :items="formItems" |
| | | :model="formData" |
| | | :rules="formRules" |
| | | :show-reset="false" |
| | | :show-submit="false" |
| | | /> |
| | | |
| | | <template #footer> |
| | | <ElButton @click="dialogVisible = false">取消</ElButton> |
| | | <ElButton type="primary" @click="handleSubmit">确定</ElButton> |
| | | </template> |
| | | </ElDialog> |
| | | </template> |
| | | |
| | | <script setup> |
| | | const formRef = ref() |
| | | const formData = ref({}) |
| | | |
| | | const handleSubmit = async () => { |
| | | // 手动控制提交逻辑 |
| | | await formRef.value.validate() |
| | | // 提交数据... |
| | | } |
| | | </script> |
| | | ``` |
| | | |
| | | **⚠️ 重要:在 ElDialog 中使用 ArtForm 时,必须设置:** |
| | | - `:show-reset="false"` - 隐藏 ArtForm 内部的重置按钮 |
| | | - `:show-submit="false"` - 隐藏 ArtForm 内部的提交按钮 |
| | | |
| | | **原因:** ElDialog 的 footer 插槽已经有自定义按钮,如果不隐藏 ArtForm 内部按钮,会出现两套按钮导致样式错乱。 |
| | | |
| | | **适用场景:** |
| | | - 弹窗表单(需要手动控制提交时机) |
| | | - 需要表单验证的场景 |
| | | - 需要在提交前进行额外处理的场景 |
| | | |
| | | ## 表单项配置 |
| | | |
| | | ### 基础配置 |
| | | |
| | | ```js |
| | | interface FormItem { |
| | | key: string // 表单项唯一标识(必填) |
| | | label: string | Component // 标签文本或自定义渲染函数 |
| | | type?: string // 表单项类型 |
| | | span?: number // 栅格占位格数(0-24) |
| | | labelWidth?: string | number // 标签宽度(覆盖全局设置) |
| | | hidden?: boolean // 是否隐藏该表单项 |
| | | props?: Object // 传递给表单项组件的属性 |
| | | placeholder?: string // 占位符文本 |
| | | } |
| | | ``` |
| | | |
| | | ### 支持的表单控件类型 |
| | | |
| | | | type 值 | 说明 | 组件 | props 示例 | |
| | | |--------|------|------|-----------| |
| | | | `input` | 输入框 | ElInput | `{ placeholder: '请输入', clearable: true }` | |
| | | | `number` | 数字输入框 | ElInputNumber | `{ min: 0, max: 100, step: 1 }` | |
| | | | `select` | 下拉选择 | ElSelect | `{ options: [{ label: '选项1', value: '1' }] }` | |
| | | | `switch` | 开关 | ElSwitch | `{ }` | |
| | | | `checkbox` | 复选框 | ElCheckbox | `{ }` | |
| | | | `checkboxgroup` | 复选框组 | ElCheckboxGroup | `{ options: [...] }` | |
| | | | `radiogroup` | 单选框组 | ElRadioGroup | `{ options: [...] }` | |
| | | | `date` | 日期选择 | ElDatePicker | `{ type: 'date', valueFormat: 'YYYY-MM-DD' }` | |
| | | | `daterange` | 日期范围 | ElDatePicker | `{ type: 'daterange' }` | |
| | | | `datetime` | 日期时间 | ElDatePicker | `{ type: 'datetime', valueFormat: 'YYYY-MM-DD HH:mm:ss' }` | |
| | | | `timepicker` | 时间选择 | ElTimePicker | `{ valueFormat: 'HH:mm:ss' }` | |
| | | | `cascader` | 级联选择 | ElCascader | `{ options: [...] }` | |
| | | | `treeselect` | 树选择 | ElTreeSelect | `{ data: [...] }` | |
| | | | `slider` | 滑块 | ElSlider | `{ max: 100 }` | |
| | | | `rate` | 评分 | ElRate | `{ max: 5 }` | |
| | | |
| | | ### 动态显示隐藏 |
| | | |
| | | ```vue |
| | | <script setup> |
| | | const showAdvanced = ref(false) |
| | | |
| | | const formItems = computed(() => [ |
| | | { key: 'username', label: '用户名', type: 'input' }, |
| | | { |
| | | key: 'advanced', |
| | | label: '高级选项', |
| | | type: 'input', |
| | | hidden: !showAdvanced.value // 动态控制 |
| | | } |
| | | ]) |
| | | </script> |
| | | ``` |
| | | |
| | | ### 自定义组件 |
| | | |
| | | ```vue |
| | | <script setup> |
| | | import { h } from 'vue' |
| | | import CustomComponent from './CustomComponent.vue' |
| | | |
| | | const formItems = [ |
| | | { |
| | | key: 'custom', |
| | | label: '自定义组件', |
| | | type: () => h(CustomComponent, { |
| | | prop1: 'value1', |
| | | onCustomEvent: handleCustomEvent |
| | | }) |
| | | } |
| | | ] |
| | | </script> |
| | | ``` |
| | | |
| | | ## 表单验证 |
| | | |
| | | ```vue |
| | | <template> |
| | | <ArtForm |
| | | ref="formRef" |
| | | v-model="formData" |
| | | :items="formItems" |
| | | :rules="formRules" |
| | | /> |
| | | </template> |
| | | |
| | | <script setup> |
| | | const formRules = { |
| | | username: [ |
| | | { required: true, message: '请输入用户名', trigger: 'blur' }, |
| | | { min: 3, max: 20, message: '长度在 3 到 20 个字符', trigger: 'blur' } |
| | | ], |
| | | email: [ |
| | | { required: true, message: '请输入邮箱', trigger: 'blur' }, |
| | | { type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' } |
| | | ] |
| | | } |
| | | |
| | | const handleSubmit = async () => { |
| | | try { |
| | | await formRef.value.validate() |
| | | console.log('验证通过') |
| | | } catch (error) { |
| | | console.log('验证失败') |
| | | } |
| | | } |
| | | </script> |
| | | ``` |
| | | |
| | | ## 命名规范 |
| | | |
| | | ### Props 命名 |
| | | |
| | | | Prop | 类型 | 默认值 | 说明 | |
| | | |------|------|--------|------| |
| | | | `labelWidth` | `string \| number` | `'70px'` | 使用驼峰命名 | |
| | | | `labelPosition` | `'left' \| 'right' \| 'top'` | `'right'` | 标签位置 | |
| | | | `showReset` | `boolean` | `true` | 是否显示重置按钮 | |
| | | | `showSubmit` | `boolean` | `true` | 是否显示提交按钮 | |
| | | |
| | | **注意:** 虽然 Vue 3 支持短横线命名(如 `label-width`),但为了保持一致性,建议使用驼峰命名 `labelWidth`。 |
| | | |
| | | ## 布局配置 |
| | | |
| | | ### 栅格布局 |
| | | |
| | | ```vue |
| | | <ArtForm |
| | | v-model="formData" |
| | | :items="formItems" |
| | | :span="8" // 每个表单项占据 8 格(每行 3 个) |
| | | :gutter="16" // 表单项之间的间距 |
| | | /> |
| | | ``` |
| | | |
| | | ### 响应式布局 |
| | | |
| | | 组件会自动适配不同屏幕尺寸: |
| | | - **移动端**:每行显示 1 个表单项 |
| | | - **平板**:每行显示 2 个表单项 |
| | | - **桌面端**:根据 `span` 属性控制 |
| | | |
| | | ## 完整示例 |
| | | |
| | | ### 示例 1:配置页面(使用 v-model) |
| | | |
| | | ```vue |
| | | <template> |
| | | <div class="config-page art-full-height"> |
| | | <ElCard class="art-table-card" shadow="never"> |
| | | <ArtForm |
| | | v-model="config" |
| | | :items="configItems" |
| | | :show-reset="false" |
| | | :show-submit="false" |
| | | labelWidth="200px" |
| | | /> |
| | | <div class="flex justify-end"> |
| | | <ElButton type="primary" :loading="saving" @click="handleSave"> |
| | | 保存配置 |
| | | </ElButton> |
| | | </div> |
| | | </ElCard> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { ref, onMounted } from 'vue' |
| | | import { ElMessage } from 'element-plus' |
| | | import ArtForm from '@/components/core/forms/art-form/index.vue' |
| | | |
| | | const config = ref({ |
| | | bridge_port: 8384, |
| | | http_port: 8081, |
| | | ip_limit: 0, |
| | | log_level: 'info' |
| | | }) |
| | | |
| | | const configItems = [ |
| | | { |
| | | key: 'bridge_port', |
| | | label: '桥接端口', |
| | | type: 'number', |
| | | props: { min: 1024, max: 65535 }, |
| | | span: 12 |
| | | }, |
| | | { |
| | | key: 'http_port', |
| | | label: 'HTTP 端口', |
| | | type: 'number', |
| | | props: { min: 1024, max: 65535 }, |
| | | span: 12 |
| | | }, |
| | | { |
| | | key: 'log_level', |
| | | label: '日志级别', |
| | | type: 'select', |
| | | props: { |
| | | options: [ |
| | | { label: 'Debug', value: 'debug' }, |
| | | { label: 'Info', value: 'info' }, |
| | | { label: 'Warn', value: 'warn' }, |
| | | { label: 'Error', value: 'error' } |
| | | ] |
| | | }, |
| | | span: 12 |
| | | } |
| | | ] |
| | | |
| | | const saving = ref(false) |
| | | |
| | | const handleSave = async () => { |
| | | saving.value = true |
| | | try { |
| | | // 保存配置... |
| | | ElMessage.success('保存成功') |
| | | } finally { |
| | | saving.value = false |
| | | } |
| | | } |
| | | </script> |
| | | ``` |
| | | |
| | | ### 示例 2:弹窗表单(使用 :model) |
| | | |
| | | ```vue |
| | | <template> |
| | | <ElDialog |
| | | v-model="dialogVisible" |
| | | :title="dialogMode === 'create' ? '新增用户' : '编辑用户'" |
| | | width="600px" |
| | | > |
| | | <ArtForm |
| | | ref="formRef" |
| | | :items="formItems" |
| | | :model="formData" |
| | | :rules="formRules" |
| | | :show-reset="false" |
| | | :show-submit="false" |
| | | /> |
| | | |
| | | <template #footer> |
| | | <ElButton @click="dialogVisible = false">取消</ElButton> |
| | | <ElButton type="primary" :loading="submitLoading" @click="handleSubmit"> |
| | | 确定 |
| | | </ElButton> |
| | | </template> |
| | | </ElDialog> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { ref, reactive } from 'vue' |
| | | import { ElMessage } from 'element-plus' |
| | | import type { FormInstance, FormRules } from 'element-plus' |
| | | import ArtForm from '@/components/core/forms/art-form/index.vue' |
| | | |
| | | const dialogVisible = ref(false) |
| | | const dialogMode = ref<'create' | 'edit'>('create') |
| | | const formRef = ref<FormInstance>() |
| | | const submitLoading = ref(false) |
| | | |
| | | const formData = ref({ |
| | | username: '', |
| | | email: '', |
| | | status: '1' |
| | | }) |
| | | |
| | | const formRules: FormRules = { |
| | | username: [ |
| | | { required: true, message: '请输入用户名', trigger: 'blur' } |
| | | ], |
| | | email: [ |
| | | { required: true, message: '请输入邮箱', trigger: 'blur' }, |
| | | { type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' } |
| | | ] |
| | | } |
| | | |
| | | const formItems = [ |
| | | { |
| | | key: 'username', |
| | | label: '用户名', |
| | | type: 'input', |
| | | span: 24, // ⚠️ 重要:模态窗中必须设置 span: 24(整行显示) |
| | | props: { placeholder: '请输入用户名' } |
| | | }, |
| | | { |
| | | key: 'email', |
| | | label: '邮箱', |
| | | type: 'input', |
| | | span: 24, // 整行显示 |
| | | props: { placeholder: '请输入邮箱' } |
| | | }, |
| | | { |
| | | key: 'status', |
| | | label: '状态', |
| | | type: 'select', |
| | | span: 24, // 整行显示 |
| | | props: { |
| | | options: [ |
| | | { label: '启用', value: '1' }, |
| | | { label: '禁用', value: '0' } |
| | | ] |
| | | } |
| | | } |
| | | ] |
| | | |
| | | const handleSubmit = async () => { |
| | | try { |
| | | await formRef.value?.validate() |
| | | submitLoading.value = true |
| | | // 提交逻辑... |
| | | ElMessage.success('提交成功') |
| | | dialogVisible.value = false |
| | | } catch (error) { |
| | | console.log('验证失败') |
| | | } finally { |
| | | submitLoading.value = false |
| | | } |
| | | } |
| | | </script> |
| | | ``` |
| | | |
| | | **⚠️ 重要提示:** |
| | | |
| | | 1. **隐藏内部按钮**:在 ElDialog 中使用时,必须设置: |
| | | - `:show-reset="false"` - 隐藏 ArtForm 内部的重置按钮 |
| | | - `:show-submit="false"` - 隐藏 ArtForm 内部的提交按钮 |
| | | |
| | | 2. **设置 span 属性**:在模态窗中,每个表单项**必须设置 `span: 24`**(整行显示) |
| | | - ArtForm 默认 `span: 6`(每个字段占 1/4 宽度) |
| | | - 在 600px 宽的模态窗中,不设置 span 会导致输入框宽度极窄(仅 6px) |
| | | |
| | | 3. **布局建议**: |
| | | - 模态窗表单:**所有字段设置 `span: 24`**(整行显示) |
| | | - 配置页面表单:可使用 `span: 12`(半行)或 `span: 24`(整行) |
| | | |
| | | ## API 参考 |
| | | |
| | | ### Props |
| | | |
| | | | 参数 | 说明 | 类型 | 默认值 | |
| | | |------|------|------|--------| |
| | | | modelValue | 表单数据对象(支持 v-model) | `Object` | `{}` | |
| | | | items | 表单项配置数组 | `FormItem[]` | `[]` | |
| | | | span | 每列的宽度(基于 24 格布局) | `number` | `6` | |
| | | | gutter | 表单控件间隙 | `number` | `12` | |
| | | | labelPosition | 标签位置 | `'left' \| 'right' \| 'top'` | `'right'` | |
| | | | labelWidth | 标签宽度 | `string \| number` | `'70px'` | |
| | | | buttonLeftLimit | 按钮靠左对齐限制 | `number` | `2` | |
| | | | showReset | 是否显示重置按钮 | `boolean` | `true` | |
| | | | showSubmit | 是否显示提交按钮 | `boolean` | `true` | |
| | | | disabledSubmit | 是否禁用提交按钮 | `boolean` | `false` | |
| | | |
| | | ### Events |
| | | |
| | | | 事件名 | 说明 | 参数 | |
| | | |--------|------|------| |
| | | | reset | 点击重置按钮时触发 | - | |
| | | | submit | 点击提交按钮时触发 | - | |
| | | |
| | | ### Methods |
| | | |
| | | | 方法名 | 说明 | 参数 | |
| | | |--------|------|------| |
| | | | validate | 验证表单 | `() => Promise<boolean>` | |
| | | | resetFields | 重置表单 | `() => void` | |
| | | | clearValidate | 清除验证 | `() => void` | |
| | | |
| | | ## 最佳实践 |
| | | |
| | | ### 1. 根据场景选择绑定方式 |
| | | |
| | | | 场景 | 推荐方式 | 原因 | |
| | | |------|---------|------| |
| | | | 配置页面 | `v-model` | 直接编辑,实时同步 | |
| | | | 弹窗表单 | `:model` | 手动控制提交时机 | |
| | | | 搜索表单 | `v-model` | 简化代码,自动同步 | |
| | | |
| | | ### 2. 统一使用驼峰命名 |
| | | |
| | | ```vue |
| | | <!-- ✅ 推荐 --> |
| | | <ArtForm labelWidth="200px" /> |
| | | |
| | | <!-- ❌ 不推荐(虽然有效) --> |
| | | <ArtForm label-width="200px" /> |
| | | ``` |
| | | |
| | | ### 3. 合理使用栅格布局 |
| | | |
| | | ```js |
| | | // ✅ 推荐:根据内容长度设置 span |
| | | const formItems = [ |
| | | { key: 'username', label: '用户名', span: 12 }, // 半行 |
| | | { key: 'email', label: '邮箱', span: 12 }, // 半行 |
| | | { key: 'remark', label: '备注', span: 24 } // 整行 |
| | | ] |
| | | |
| | | // ❌ 不推荐:所有字段都占据整行 |
| | | const formItems = [ |
| | | { key: 'username', label: '用户名', span: 24 }, |
| | | { key: 'email', label: '邮箱', span: 24 } |
| | | ] |
| | | ``` |
| | | |
| | | ### 4. 使用 computed 实现动态表单 |
| | | |
| | | ```js |
| | | const formItems = computed(() => [ |
| | | { key: 'username', label: '用户名', type: 'input' }, |
| | | { |
| | | key: 'password', |
| | | label: '密码', |
| | | type: 'input', |
| | | hidden: dialogMode.value !== 'create' // 编辑时不显示密码 |
| | | } |
| | | ]) |
| | | ``` |
| | | |
| | | ## 常见问题 |
| | | |
| | | ### Q0: 在 ElDialog 中使用 ArtForm 时出现两套按钮怎么办? |
| | | |
| | | **A:** 这是 ArtForm 的常见问题。在模态窗中使用时,**必须隐藏 ArtForm 内部的按钮**: |
| | | |
| | | ```vue |
| | | <!-- ❌ 错误:会出现两套按钮 --> |
| | | <ElDialog v-model="dialogVisible" title="新增用户"> |
| | | <ArtForm |
| | | ref="formRef" |
| | | :items="formItems" |
| | | :model="formData" |
| | | /> |
| | | <template #footer> |
| | | <ElButton>取消</ElButton> |
| | | <ElButton>确定</ElButton> |
| | | </template> |
| | | </ElDialog> |
| | | |
| | | <!-- ✅ 正确:隐藏 ArtForm 内部按钮 --> |
| | | <ElDialog v-model="dialogVisible" title="新增用户"> |
| | | <ArtForm |
| | | ref="formRef" |
| | | :items="formItems" |
| | | :model="formData" |
| | | :show-reset="false" |
| | | :show-submit="false" |
| | | /> |
| | | <template #footer> |
| | | <ElButton>取消</ElButton> |
| | | <ElButton>确定</ElButton> |
| | | </template> |
| | | </ElDialog> |
| | | ``` |
| | | |
| | | **原因:** ArtForm 默认显示重置和提交按钮(`showReset: true`, `showSubmit: true`),在模态窗中会与 footer 中的自定义按钮冲突。 |
| | | |
| | | ### Q1: v-model 和 :model 有什么区别? |
| | | |
| | | **A:** |
| | | - `v-model`:双向绑定,表单数据修改自动同步到父组件 |
| | | - `:model`:单向绑定,需要手动处理数据同步 |
| | | |
| | | 根据场景选择: |
| | | - 配置页面:使用 `v-model` |
| | | - 弹窗表单:使用 `:model` + 手动提交 |
| | | |
| | | ### Q2: 如何动态控制表单项显示? |
| | | |
| | | **A:** 使用 `hidden` 属性: |
| | | |
| | | ```js |
| | | const formItems = computed(() => [ |
| | | { key: 'field1', label: '字段1', hidden: !showField1.value }, |
| | | { key: 'field2', label: '字段2' } |
| | | ]) |
| | | ``` |
| | | |
| | | ### Q3: 如何自定义表单项? |
| | | |
| | | **A:** 使用 `render` 函数或插槽: |
| | | |
| | | ```js |
| | | // 方式 1:使用 render 函数 |
| | | import { h } from 'vue' |
| | | import CustomComponent from './CustomComponent.vue' |
| | | |
| | | { |
| | | key: 'custom', |
| | | label: '自定义', |
| | | type: () => h(CustomComponent, { prop: 'value' }) |
| | | } |
| | | |
| | | // 方式 2:使用插槽 |
| | | <ArtForm v-model="formData" :items="formItems"> |
| | | <template #customField="{ item, modelValue }"> |
| | | <CustomComponent v-model="modelValue[item.key]" /> |
| | | </template> |
| | | </ArtForm> |
| | | ``` |
| | | |
| | | ## 相关文档 |
| | | |
| | | - [ArtSearchBar 组件](./art-search-bar.md) |
| | | - [useTable Hook](../hooks/use-table.md) |
| | | - [CRUD 页面示例](../examples/templates/crud-page.md) |
| | | - [Element Plus Form 文档](https://element-plus.org/zh-CN/component/form.html) |
| | | |
| | | ## 官方文档 |
| | | |
| | | - [ArtForm 官方文档](https://www.artd.pro/docs/zh/guide/components/art-form.html) |
| | | |
| | | --- |
| | | |
| | | **最后更新**:2026-03-04 |
| | | **维护者**:Art Design Pro Skill Team |
| | | |
| | | |
| New file |
| | |
| | | # ArtSearchBar 搜索栏组件 | Art Design Pro |
| | | |
| | | 来源:https://www.artd.pro/docs/zh/guide/components/art-search-bar.html |
| | | |
| | | ## 概述 |
| | | |
| | | 一个功能强大、高度可配置的表单搜索组件,支持多种表单控件类型、动态显示隐藏、表单验证等特性。 |
| | | |
| | | ## 特性 |
| | | |
| | | - **多种表单控件** - 支持输入框、选择器、日期选择器、级联选择器等 20+ 种表单控件 |
| | | - **高度可配置** - 支持自定义布局、标签位置、间距等 |
| | | - **响应式设计** - 自适应不同屏幕尺寸 |
| | | - **插槽支持** - 支持自定义组件和插槽渲染 |
| | | - **表单验证** - 完整的表单验证支持 |
| | | - **动态控制** - 支持动态显示隐藏表单项 |
| | | |
| | | ## 基础用法 |
| | | |
| | | ```vue |
| | | <template> |
| | | <ArtSearchBar |
| | | v-model="formData" |
| | | :items="formItems" |
| | | @search="handleSearch" |
| | | @reset="handleReset" |
| | | /> |
| | | </template> |
| | | |
| | | <script setup> |
| | | const formData = ref({ |
| | | name: '', |
| | | status: '' |
| | | }) |
| | | |
| | | const formItems = [ |
| | | { |
| | | label: '用户名', |
| | | key: 'name', |
| | | type: 'input', |
| | | placeholder: '请输入用户名' |
| | | }, |
| | | { |
| | | label: '状态', |
| | | key: 'status', |
| | | type: 'select', |
| | | props: { |
| | | options: [ |
| | | { label: '启用', value: '1' }, |
| | | { label: '禁用', value: '0' } |
| | | ] |
| | | } |
| | | } |
| | | ] |
| | | |
| | | const handleSearch = () => { |
| | | console.log('搜索参数:', formData.value) |
| | | } |
| | | |
| | | const handleReset = () => { |
| | | console.log('重置表单') |
| | | } |
| | | </script> |
| | | ``` |
| | | |
| | | ## 支持的表单控件类型 |
| | | |
| | | ### 输入类控件 |
| | | |
| | | ```javascript |
| | | // 普通输入框 |
| | | { |
| | | label: '用户名', |
| | | key: 'name', |
| | | type: 'input', |
| | | placeholder: '请输入用户名' |
| | | } |
| | | |
| | | // 数字输入框 |
| | | { |
| | | label: '年龄', |
| | | key: 'age', |
| | | type: 'number', |
| | | props: { |
| | | min: 0, |
| | | max: 120 |
| | | } |
| | | } |
| | | |
| | | // 多行文本 |
| | | { |
| | | label: '备注', |
| | | key: 'remark', |
| | | type: 'input', |
| | | props: { |
| | | type: 'textarea', |
| | | rows: 3 |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | ### 选择类控件 |
| | | |
| | | ```javascript |
| | | // 下拉选择 |
| | | { |
| | | label: '状态', |
| | | key: 'status', |
| | | type: 'select', |
| | | props: { |
| | | options: [ |
| | | { label: '启用', value: '1' }, |
| | | { label: '禁用', value: '0' } |
| | | ] |
| | | } |
| | | } |
| | | |
| | | // 级联选择器 |
| | | { |
| | | label: '地区', |
| | | key: 'region', |
| | | type: 'cascader', |
| | | props: { |
| | | options: cascaderOptions, |
| | | props: { multiple: true } |
| | | } |
| | | } |
| | | |
| | | // 树选择器 |
| | | { |
| | | label: '部门', |
| | | key: 'department', |
| | | type: 'treeselect', |
| | | props: { |
| | | data: treeData, |
| | | multiple: true, |
| | | showCheckbox: true |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | ### 日期时间控件 |
| | | |
| | | ```javascript |
| | | // 日期选择 |
| | | { |
| | | label: '创建日期', |
| | | key: 'createDate', |
| | | type: 'datetime', |
| | | props: { |
| | | type: 'date', |
| | | valueFormat: 'YYYY-MM-DD' |
| | | } |
| | | } |
| | | |
| | | // 日期范围 |
| | | { |
| | | label: '时间范围', |
| | | key: 'dateRange', |
| | | type: 'datetime', |
| | | props: { |
| | | type: 'daterange', |
| | | rangeSeparator: '至', |
| | | startPlaceholder: '开始日期', |
| | | endPlaceholder: '结束日期' |
| | | } |
| | | } |
| | | |
| | | // 时间选择器 |
| | | { |
| | | label: '时间', |
| | | key: 'time', |
| | | type: 'timepicker', |
| | | props: { |
| | | valueFormat: 'HH:mm:ss' |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | ### 其他控件 |
| | | |
| | | ```javascript |
| | | // 开关 |
| | | { |
| | | label: '是否启用', |
| | | key: 'enabled', |
| | | type: 'switch' |
| | | } |
| | | |
| | | // 单选框组 |
| | | { |
| | | label: '性别', |
| | | key: 'gender', |
| | | type: 'radiogroup', |
| | | props: { |
| | | options: [ |
| | | { label: '男', value: '1' }, |
| | | { label: '女', value: '2' } |
| | | ] |
| | | } |
| | | } |
| | | |
| | | // 复选框组 |
| | | { |
| | | label: '兴趣爱好', |
| | | key: 'hobbies', |
| | | type: 'checkboxgroup', |
| | | props: { |
| | | options: [ |
| | | { label: '读书', value: 'reading' }, |
| | | { label: '运动', value: 'sports' } |
| | | ] |
| | | } |
| | | } |
| | | |
| | | // 评分 |
| | | { |
| | | label: '评分', |
| | | key: 'rating', |
| | | type: 'rate' |
| | | } |
| | | |
| | | // 滑块 |
| | | { |
| | | label: '价格区间', |
| | | key: 'priceRange', |
| | | type: 'slider', |
| | | props: { |
| | | range: true, |
| | | max: 1000 |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | ## 自定义组件 |
| | | |
| | | ### 使用渲染函数 |
| | | |
| | | ```javascript |
| | | import { h } from 'vue' |
| | | import CustomComponent from './CustomComponent.vue' |
| | | |
| | | { |
| | | label: '自定义组件', |
| | | key: 'custom', |
| | | type: () => h(CustomComponent, { |
| | | prop1: 'value1', |
| | | onCustomEvent: handleCustomEvent |
| | | }) |
| | | } |
| | | ``` |
| | | |
| | | ### 使用插槽 |
| | | |
| | | ```vue |
| | | <template> |
| | | <ArtSearchBar v-model="formData" :items="formItems"> |
| | | <template #customSlot="{ item, modelValue }"> |
| | | <el-input |
| | | v-model="modelValue[item.key]" |
| | | placeholder="我是插槽渲染的组件" |
| | | /> |
| | | </template> |
| | | </ArtSearchBar> |
| | | </template> |
| | | |
| | | <script setup> |
| | | const formItems = [ |
| | | { |
| | | label: '自定义插槽', |
| | | key: 'customSlot', |
| | | type: 'input' // 这里的type会被插槽覆盖 |
| | | } |
| | | ] |
| | | </script> |
| | | ``` |
| | | |
| | | ## 表单验证 |
| | | |
| | | ```vue |
| | | <template> |
| | | <ArtSearchBar |
| | | ref="searchBarRef" |
| | | v-model="formData" |
| | | :items="formItems" |
| | | :rules="rules" |
| | | @search="handleSearch" |
| | | /> |
| | | </template> |
| | | |
| | | <script setup> |
| | | const searchBarRef = ref() |
| | | |
| | | const rules = { |
| | | name: [ |
| | | { required: true, message: '请输入用户名', trigger: 'blur' } |
| | | ], |
| | | phone: [ |
| | | { required: true, message: '请输入手机号', trigger: 'blur' }, |
| | | { pattern: /^1[3456789]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' } |
| | | ] |
| | | } |
| | | |
| | | const handleSearch = async () => { |
| | | try { |
| | | await searchBarRef.value.validate() |
| | | console.log('验证通过,执行搜索') |
| | | } catch (error) { |
| | | console.log('验证失败') |
| | | } |
| | | } |
| | | </script> |
| | | ``` |
| | | |
| | | ## 动态控制 |
| | | |
| | | ### 动态显示隐藏 |
| | | |
| | | ```javascript |
| | | const formItems = computed(() => [ |
| | | { |
| | | label: '用户名', |
| | | key: 'name', |
| | | type: 'input' |
| | | }, |
| | | { |
| | | label: '高级选项', |
| | | key: 'advanced', |
| | | type: 'input', |
| | | hidden: !showAdvanced.value // 动态控制显示隐藏 |
| | | } |
| | | ]) |
| | | ``` |
| | | |
| | | ### 动态更新配置 |
| | | |
| | | ```javascript |
| | | const userNameItem = ref({ |
| | | label: '用户名', |
| | | key: 'name', |
| | | type: 'input', |
| | | placeholder: '请输入用户名' |
| | | }) |
| | | |
| | | // 动态修改配置 |
| | | const updateUserNameConfig = () => { |
| | | userNameItem.value = { |
| | | ...userNameItem.value, |
| | | label: '昵称', |
| | | placeholder: '请输入昵称' |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | ## 布局配置 |
| | | |
| | | ### 栅格布局 |
| | | |
| | | ```vue |
| | | <ArtSearchBar |
| | | v-model="formData" |
| | | :items="formItems" |
| | | :span="8" |
| | | :gutter="16" |
| | | /> |
| | | ``` |
| | | |
| | | ### 标签配置 |
| | | |
| | | ```vue |
| | | <ArtSearchBar |
| | | v-model="formData" |
| | | :items="formItems" |
| | | label-position="top" |
| | | :label-width="120" |
| | | /> |
| | | ``` |
| | | |
| | | ### 响应式布局 |
| | | |
| | | 组件会自动适配不同屏幕尺寸: |
| | | - **移动端**:每行显示 1 个表单项 |
| | | - **平板**:每行显示 2 个表单项 |
| | | - **桌面端**:根据 `span` 属性控制每行显示的表单项数量 |
| | | |
| | | ## API |
| | | |
| | | ### Props |
| | | |
| | | | 参数 | 说明 | 类型 | 默认值 | |
| | | | --- | --- | --- | --- | |
| | | | modelValue | 表单数据对象 | `Object` | `{}` | |
| | | | items | 表单项配置数组 | `SearchFormItem[]` | `[]` | |
| | | | span | 每个表单项占据的栅格数 | `number` | `6` | |
| | | | gutter | 栅格间隔 | `number` | `12` | |
| | | | labelPosition | 标签位置 | `'left' \| 'right' \| 'top'` | `'right'` | |
| | | | labelWidth | 标签宽度 | `string \| number` | `'70px'` | |
| | | | defaultExpanded | 默认是否展开 | `boolean` | `false` | |
| | | | showExpand | 是否显示展开收起按钮 | `boolean` | `true` | |
| | | | showReset | 是否显示重置按钮 | `boolean` | `true` | |
| | | | showSearch | 是否显示搜索按钮 | `boolean` | `true` | |
| | | | disabledSearch | 是否禁用搜索按钮 | `boolean` | `false` | |
| | | |
| | | ### SearchFormItem 配置 |
| | | |
| | | | 参数 | 说明 | 类型 | 默认值 | |
| | | | --- | --- | --- | --- | |
| | | | key | 表单项唯一标识 | `string` | - | |
| | | | label | 标签文本 | `string` | - | |
| | | | type | 表单项类型 | `string \| (() => VNode)` | `'input'` | |
| | | | hidden | 是否隐藏 | `boolean` | `false` | |
| | | | span | 栅格占位格数 | `number` | - | |
| | | | labelWidth | 标签宽度 | `string \| number` | - | |
| | | | placeholder | 占位符 | `string` | - | |
| | | | props | 传递给组件的属性 | `Object` | - | |
| | | | slots | 插槽配置 | `Record<string, () => any>` | - | |
| | | |
| | | ### Events |
| | | |
| | | | 事件名 | 说明 | 参数 | |
| | | | --- | --- | --- | |
| | | | search | 点击搜索按钮时触发 | - | |
| | | | reset | 点击重置按钮时触发 | - | |
| | | |
| | | ### Methods |
| | | |
| | | | 方法名 | 说明 | 参数 | |
| | | | --- | --- | --- | |
| | | | validate | 验证表单 | `() => Promise<boolean>` | |
| | | | reset | 重置表单 | `() => void` | |
| | | |
| | | ### Slots |
| | | |
| | | | 插槽名 | 说明 | 参数 | |
| | | | --- | --- | --- | |
| | | | [key] | 自定义表单项内容 | `{ item: SearchFormItem, modelValue: Object }` | |
| | | |
| | | ## 注意事项 |
| | | |
| | | 1. **表单项 key 值必须唯一**,用于表单数据绑定和验证 |
| | | 2. **props 属性会直接传递给对应的 Element Plus 组件**,请参考 Element Plus 官方文档 |
| | | 3. **表单验证规则格式与 Element Plus Form 组件一致** |
| | | |
| | | ## 完整示例 |
| | | |
| | | ```vue |
| | | <template> |
| | | <div class="search-example"> |
| | | <ArtSearchBar |
| | | ref="searchBarRef" |
| | | v-model="formData" |
| | | :items="formItems" |
| | | :rules="rules" |
| | | :defaultExpanded="true" |
| | | :labelWidth="100" |
| | | labelPosition="right" |
| | | :span="6" |
| | | :gutter="16" |
| | | @search="handleSearch" |
| | | @reset="handleReset" |
| | | > |
| | | <template #customSlot> |
| | | <el-input |
| | | v-model="formData.customSlot" |
| | | placeholder="我是插槽渲染的组件" |
| | | /> |
| | | </template> |
| | | </ArtSearchBar> |
| | | |
| | | <div class="result"> |
| | | <h3>搜索结果:</h3> |
| | | <pre>{{ JSON.stringify(formData, null, 2) }}</pre> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { ref } from 'vue' |
| | | |
| | | const searchBarRef = ref() |
| | | |
| | | const formData = ref({ |
| | | name: '', |
| | | phone: '', |
| | | status: '', |
| | | dateRange: [], |
| | | customSlot: '' |
| | | }) |
| | | |
| | | const rules = { |
| | | name: [{ required: true, message: '请输入用户名', trigger: 'blur' }], |
| | | phone: [ |
| | | { required: true, message: '请输入手机号', trigger: 'blur' }, |
| | | { pattern: /^1[3456789]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' } |
| | | ] |
| | | } |
| | | |
| | | const formItems = [ |
| | | { |
| | | label: '用户名', |
| | | key: 'name', |
| | | type: 'input', |
| | | placeholder: '请输入用户名', |
| | | props: { clearable: true } |
| | | }, |
| | | { |
| | | label: '手机号', |
| | | key: 'phone', |
| | | type: 'input', |
| | | placeholder: '请输入手机号', |
| | | props: { maxlength: 11 } |
| | | }, |
| | | { |
| | | label: '状态', |
| | | key: 'status', |
| | | type: 'select', |
| | | props: { |
| | | placeholder: '请选择状态', |
| | | options: [ |
| | | { label: '启用', value: '1' }, |
| | | { label: '禁用', value: '0' } |
| | | ] |
| | | } |
| | | }, |
| | | { |
| | | label: '日期范围', |
| | | key: 'dateRange', |
| | | type: 'datetime', |
| | | props: { |
| | | type: 'daterange', |
| | | rangeSeparator: '至', |
| | | startPlaceholder: '开始日期', |
| | | endPlaceholder: '结束日期', |
| | | valueFormat: 'YYYY-MM-DD' |
| | | } |
| | | }, |
| | | { |
| | | label: '自定义插槽', |
| | | key: 'customSlot', |
| | | type: 'input' |
| | | } |
| | | ] |
| | | |
| | | const handleSearch = async () => { |
| | | try { |
| | | await searchBarRef.value.validate() |
| | | console.log('搜索参数:', formData.value) |
| | | } catch (error) { |
| | | console.log('表单验证失败') |
| | | } |
| | | } |
| | | |
| | | const handleReset = () => { |
| | | console.log('重置表单') |
| | | } |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .search-example { |
| | | padding: 20px; |
| | | } |
| | | |
| | | .result { |
| | | margin-top: 20px; |
| | | padding: 16px; |
| | | background-color: #f5f5f5; |
| | | border-radius: 4px; |
| | | } |
| | | |
| | | .result pre { |
| | | margin: 0; |
| | | font-size: 12px; |
| | | } |
| | | </style> |
| | | ``` |
| | | |
| | | |
| New file |
| | |
| | | # 14 Permission |
| | | > 来源:14 Permission |
| | | |
| | | 本系统支持两种权限控制模式,基于用户角色或菜单列表动态管理页面访问和按钮显示权限。 |
| | | ## 权限控制模式 |
| | | ### 概述 |
| | | 系统提供以下两种权限控制模式: |
| | | - 基于角色:通过接口获取用户角色,控制页面访问和按钮显示权限。 |
| | | - 基于菜单:通过接口获取菜单列表,依据菜单结构控制页面访问和按钮权限。 |
| | | ### 配置方式 |
| | | 权限控制模式通过根目录下的.env文件配置。修改VITE_ACCESS_MODE的值可切换模式: |
| | | `.env``VITE_ACCESS_MODE`- frontend:前端控制模式,基于后端返回的角色标识进行权限控制。 |
| | | - backend:后端控制模式,基于后端返回的菜单列表进行权限控制。 |
| | | ``` |
| | | # 权限控制模式(frontend | backend) |
| | | VITE_ACCESS_MODE=frontend |
| | | ``` |
| | | ## 前端控制模式 |
| | | ### 原理 |
| | | 前端维护菜单列表。用户登录后,接口返回角色标识(如R_SUPER)。前端根据角色遍历菜单列表,若菜单的roles字段包含该角色,则允许访问对应路由。若未设置roles,则默认所有用户可访问。 |
| | | `R_SUPER``roles``roles`### 配置示例 |
| | | 菜单配置文件位于:/src/router/routes/asyncRoutes.ts |
| | | `/src/router/routes/asyncRoutes.ts```` |
| | | [ |
| | | { |
| | | id: 4, |
| | | path: "/system", |
| | | name: "System", |
| | | component: "/index/index", |
| | | meta: { |
| | | title: "menus.system.title", |
| | | icon: "ri:user-3-line", |
| | | keepAlive: false, |
| | | }, |
| | | children: [ |
| | | // 仅 R_SUPER 和 R_ADMIN 角色可访问 |
| | | { |
| | | id: 41, |
| | | path: "user", |
| | | name: "User", |
| | | component: "/system/user", |
| | | meta: { |
| | | title: "menus.system.user", |
| | | keepAlive: true, |
| | | roles: ["R_SUPER", "R_ADMIN"], |
| | | }, |
| | | }, |
| | | // 未设置 roles,所有用户可访问 |
| | | { |
| | | id: 42, |
| | | path: "role", |
| | | name: "Role", |
| | | component: "/system/role", |
| | | meta: { |
| | | title: "menus.system.role", |
| | | keepAlive: true, |
| | | }, |
| | | }, |
| | | ], |
| | | }, |
| | | ]; |
| | | ``` |
| | | ### 注意事项 |
| | | - 确保接口返回的角色标识与路由表的roles字段匹配,否则用户无法访问受限页面。 |
| | | ## 后端控制模式 |
| | | ### 原理 |
| | | 后端生成菜单列表。用户登录后,接口返回菜单数据,前端校验后动态注册路由,实现权限控制。 |
| | | ### 数据结构 |
| | | 菜单数据结构定义位于:/src/router/routes/asyncRoutes.ts |
| | | `/src/router/routes/asyncRoutes.ts```` |
| | | [ |
| | | { |
| | | id: 4, |
| | | path: "/system", |
| | | name: "System", |
| | | component: "/index/index", |
| | | meta: { |
| | | title: "menus.system.title", |
| | | icon: "ri:user-3-line", |
| | | keepAlive: false, |
| | | }, |
| | | children: [ |
| | | { |
| | | id: 41, |
| | | path: "user", |
| | | name: "User", |
| | | component: "/system/user", |
| | | meta: { |
| | | title: "menus.system.user", |
| | | keepAlive: true, |
| | | }, |
| | | }, |
| | | { |
| | | id: 42, |
| | | path: "role", |
| | | name: "Role", |
| | | component: "/system/role", |
| | | meta: { |
| | | title: "menus.system.role", |
| | | keepAlive: true, |
| | | }, |
| | | }, |
| | | ], |
| | | }, |
| | | ]; |
| | | ``` |
| | | ### 注意事项 |
| | | - 后端返回的菜单数据结构必须与前端定义一致,否则可能导致路由注册失败。 |
| | | ## 前后端控制模式对比 |
| | | - 前端控制模式:适用于角色固定的系统。后端角色变更需同步更新前端路由配置。实现简单,适合小型项目。 |
| | | - 后端控制模式:适用于权限复杂的系统。后端返回完整菜单列表,前端动态注册路由。更灵活,但需确保前后端数据结构一致。 |
| | | ## 按钮权限控制 |
| | | 按钮权限控制支持精细化管理,通过用户角色或接口返回的权限码动态控制按钮显示。 |
| | | ### 权限码 |
| | | 权限码适用于前端和后端控制模式: |
| | | - 前端控制模式:登录接口需返回权限码列表。 |
| | | - 后端控制模式:菜单列表需包含authList字段,定义按钮权限。 |
| | | #### 配置示例(后端控制模式) |
| | | ``` |
| | | [ |
| | | { |
| | | id: 44, |
| | | path: "menu", |
| | | name: "Menus", |
| | | component: "/system/menu", |
| | | meta: { |
| | | title: "menus.system.menu", |
| | | keepAlive: true, |
| | | authList: [ |
| | | { id: 441, title: "新增", authMark: "add" }, |
| | | { id: 442, title: "编辑", authMark: "edit" }, |
| | | ], |
| | | }, |
| | | }, |
| | | ]; |
| | | ``` |
| | | #### 使用方式 |
| | | 通过系统提供的hasAuth方法控制按钮显示: |
| | | `hasAuth```` |
| | | import { useAuth } from "@/composables/useAuth"; |
| | | const { hasAuth } = useAuth(); |
| | | ``` |
| | | ``` |
| | | <ElButton v-if="hasAuth('add')">添加</ElButton> |
| | | ``` |
| | | ### 自定义指令(v-auth) |
| | | 在后端控制模式下,可通过自定义指令v-auth基于authList的authMark控制按钮显示。 |
| | | `v-auth``authList``authMark`#### 配置示例 |
| | | ``` |
| | | [ |
| | | { |
| | | id: 44, |
| | | path: "menu", |
| | | name: "Menus", |
| | | component: "/system/menu", |
| | | meta: { |
| | | title: "menus.system.menu", |
| | | keepAlive: true, |
| | | authList: [ |
| | | { id: 441, title: "新增", authMark: "add" }, |
| | | { id: 442, title: "编辑", authMark: "edit" }, |
| | | { id: 443, title: "删除", authMark: "delete" }, |
| | | ], |
| | | }, |
| | | }, |
| | | ]; |
| | | ``` |
| | | #### 使用方式 |
| | | ``` |
| | | <ElButton v-auth="'add'">添加</ElButton> |
| | | ``` |
| | | ### 自定义指令(v-roles) |
| | | 可基于用户信息接口中返回的roles进行权限控制。 |
| | | `roles`#### 用户接口 |
| | | ``` |
| | | { |
| | | "userId": "1", |
| | | "userName": "Super", |
| | | "roles": [ |
| | | "R_SUPER" |
| | | ], |
| | | "buttons": [ |
| | | "B_CODE1", |
| | | "B_CODE2", |
| | | "B_CODE3" |
| | | ] |
| | | } |
| | | ``` |
| | | #### 使用示例 |
| | | ``` |
| | | <el-button v-roles="['R_SUPER', 'R_ADMIN']">按钮</el-button> |
| | | <el-button v-roles="'R_ADMIN'">按钮</el-button> |
| | | ``` |
| | | ## 注意事项 |
| | | - 确保登录接口返回的角色或权限码与路由表配置一致。 |
| | | - 后端控制模式下,菜单数据需严格遵循前端定义的结构。 |
| | | - 测试权限控制时,验证不同角色用户的页面和按钮显示是否符合预期。 |
| | | |
| | | |
| New file |
| | |
| | | # 项目结构 | Art Design Pro |
| | | |
| | | 来源:https://www.artd.pro/docs/zh/guide/essentials/project-introduce.html |
| | | |
| | | ``` |
| | | ├── src |
| | | │ ├── api # API 接口相关代码 |
| | | │ │ ├── articleApi.ts # 文章相关的 API 接口定义 |
| | | │ │ ├── menuApi.ts # 菜单相关的 API 接口定义 |
| | | │ │ ├── modules # API 模块化目录 |
| | | │ │ └── usersApi.ts # 用户相关的 API 接口定义 |
| | | │ ├── App.vue # Vue 根组件 |
| | | │ ├── assets # 静态资源目录 |
| | | │ │ ├── fonts # 字体文件 |
| | | │ │ ├── icons # 图标文件 |
| | | │ │ ├── img # 图片资源 |
| | | │ │ ├── styles # 全局 CSS/SCSS 样式文件 |
| | | │ │ └── svg # SVG 图标资源 |
| | | │ ├── components # 组件目录 |
| | | │ │ ├── core # 系统组件(Art Design Pro 组件库) |
| | | │ │ └── custom # 自定义组件(开发者组件库) |
| | | │ ├── composables # Vue 3 Composable 函数 |
| | | │ │ ├── useAuth.ts # 认证相关逻辑 |
| | | │ │ ├── useChart.ts # 图表相关逻辑 |
| | | │ │ ├── useCommon.ts # 通用的 Composable 函数 |
| | | │ │ └── useTheme.ts # 主题切换逻辑 |
| | | │ ├── config # 项目配置目录 |
| | | │ │ ├── assets # 静态资源配置 |
| | | │ │ └── index.ts # 全局配置文件 |
| | | │ ├── directives # Vue 自定义指令 |
| | | │ │ ├── highlight.ts # 高亮指令 |
| | | │ │ ├── permission.ts # 权限指令 |
| | | │ │ └── ripple.ts # 波纹效果指令 |
| | | │ ├── enums # 枚举定义 |
| | | │ ├── locales # 国际化(i18n)资源 |
| | | │ ├── main.ts # 项目主入口文件 |
| | | │ ├── mock # Mock 数据目录 |
| | | │ ├── router # Vue Router 路由相关代码 |
| | | │ │ ├── guards # 路由守卫 |
| | | │ │ ├── routes # 路由定义 |
| | | │ │ └── utils # 路由工具函数 |
| | | │ ├── store # Pinia 状态管理 |
| | | │ │ └── modules # 分模块的状态管理 |
| | | │ ├── types # 可选的结构说明目录(当前 JS 版默认不保留) |
| | | │ ├── utils # 工具函数目录 |
| | | │ │ ├── browser # 浏览器相关工具 |
| | | │ │ ├── constants # 常量定义 |
| | | │ │ ├── http # HTTP 请求工具 |
| | | │ │ ├── navigation # 导航相关工具 |
| | | │ │ └── storage # 存储相关工具 |
| | | │ └── views # 页面组件目录 |
| | | └── vite.config.js # Vite 配置文件 |
| | | ``` |
| | | |
| | | ## 核心目录说明 |
| | | |
| | | ### components/core/ |
| | | 这是 Art Design Pro 的核心组件库,包含所有可复用的业务组件: |
| | | - **表格**:ArtTable, ArtTableHeader |
| | | - **表单**:ArtForm, ArtSearchBar, ArtButtonTable |
| | | - **图表**:ArtStatsCard, ArtLineChart, ArtBarChart |
| | | - **布局**:ArtPageContent, ArtBreadcrumb, ArtHeaderBar |
| | | |
| | | ### views/ |
| | | 页面组件目录,所有业务页面都应该放在这里。 |
| | | |
| | | ### router/routes/ |
| | | - **staticRoutes.ts**: 静态路由(登录、404等) |
| | | - **asyncRoutes.ts**: 动态路由(业务页面) |
| | | |
| | | ### config/ |
| | | 项目配置,包括系统名称、主题配置等。 |
| | | |
| | | |
| | | |
| New file |
| | |
| | | # 路由和菜单 | Art Design Pro |
| | | |
| | | 来源:https://www.artd.pro/docs/zh/guide/essentials/route.html |
| | | |
| | | ## 路由类型 |
| | | |
| | | 项目中的路由分为两类:**静态路由** 和 **动态路由**。 |
| | | |
| | | - **静态路由**:无需权限即可访问的基础页面路由(登录页、404等) |
| | | - **动态路由**:需要权限控制的业务页面路由(用户管理、菜单管理等) |
| | | |
| | | ## 静态路由 |
| | | |
| | | 配置位置:`src/router/routes/staticRoutes.ts` |
| | | |
| | | ```js |
| | | export const staticRoutes: AppRouteRecordRaw[] = [ |
| | | { |
| | | path: RoutesAlias.Login, |
| | | name: "Login", |
| | | component: () => import("@views/auth/login/index.vue"), |
| | | meta: { title: "menus.login.title", isHideTab: true, setTheme: true }, |
| | | }, |
| | | { |
| | | path: "/exception", |
| | | component: Home, |
| | | name: "Exception", |
| | | children: [ |
| | | { |
| | | path: RoutesAlias.Exception404, |
| | | name: "Exception404", |
| | | component: () => import("@views/exception/404/index.vue"), |
| | | meta: { title: "404" }, |
| | | } |
| | | ] |
| | | } |
| | | ]; |
| | | ``` |
| | | |
| | | ## 动态路由 |
| | | |
| | | 配置位置:`src/router/routes/asyncRoutes.ts` |
| | | |
| | | ```js |
| | | export const asyncRoutes: AppRouteRecord[] = [ |
| | | { |
| | | name: "Dashboard", |
| | | path: "/dashboard/", |
| | | component: RoutesAlias.Layout, |
| | | meta: { |
| | | title: "menus.dashboard.title", |
| | | icon: "", |
| | | }, |
| | | children: [ |
| | | { |
| | | path: "console", |
| | | name: "Console", |
| | | component: RoutesAlias.Dashboard, |
| | | meta: { |
| | | title: "menus.dashboard.console", |
| | | keepAlive: false, |
| | | fixedTab: true, |
| | | }, |
| | | } |
| | | ], |
| | | } |
| | | ]; |
| | | ``` |
| | | |
| | | ## 路由元信息(meta) |
| | | |
| | | ```js |
| | | meta: { |
| | | title: string; // 路由标题 |
| | | icon?: string; // 路由图标 |
| | | showBadge?: boolean; // 是否显示徽章 |
| | | showTextBadge?: string; // 文本徽章 |
| | | isHide?: boolean; // 是否在菜单中隐藏 |
| | | isHideTab?: boolean; // 是否在标签页中隐藏 |
| | | link?: string; // 外部链接 |
| | | isIframe?: boolean; // 是否为 iframe |
| | | keepAlive?: boolean; // 是否缓存 |
| | | roles?: string[]; // 角色权限 |
| | | fixedTab?: boolean; // 是否固定标签页 |
| | | isFullPage?: boolean; // 是否为全屏页面 |
| | | activePath?: string; // 激活的菜单路径 |
| | | } |
| | | ``` |
| | | |
| | | ## 新建页面步骤 |
| | | |
| | | ### 1. 创建页面文件 |
| | | |
| | | 在 `/src/views/` 目录下创建页面: |
| | | |
| | | ```vue |
| | | <template> |
| | | <div class="page-content"> |
| | | <h1>test page</h1> |
| | | </div> |
| | | </template> |
| | | ``` |
| | | |
| | | **注意**:使用 `class="page-content"` 让页面高度占满屏幕剩余高度。 |
| | | |
| | | ### 2. 注册路由 |
| | | |
| | | 在 `src/router/routes/asyncRoutes.ts` 中添加路由配置: |
| | | |
| | | ```js |
| | | // 一级路由 |
| | | { |
| | | path: "/test/index", |
| | | name: "Test", |
| | | component: "/test/index", |
| | | meta: { |
| | | title: "测试页", |
| | | keepAlive: true, |
| | | }, |
| | | } |
| | | |
| | | // 多级路由 |
| | | { |
| | | name: "Form", |
| | | path: "/form/", |
| | | component: RoutesAlias.Layout, |
| | | meta: { |
| | | title: "表单", |
| | | icon: "", |
| | | }, |
| | | children: [ |
| | | { |
| | | path: "basic", |
| | | name: "Basic", |
| | | component: "/form/basic", |
| | | meta: { |
| | | title: "基础表单", |
| | | keepAlive: true, |
| | | }, |
| | | } |
| | | ], |
| | | } |
| | | ``` |
| | | |
| | | ### 3. 访问页面 |
| | | |
| | | 访问 `http://localhost:3006/form/basic` 即可查看新建的页面。 |
| | | |
| | | |
| New file |
| | |
| | | # 搜索栏使用示例 |
| | | |
| | | ## 概述 |
| | | |
| | | `ArtSearchBar` 是 Art Design Pro 的搜索栏组件,支持动态表单项、折叠展开、表单校验等功能。 |
| | | |
| | | ## 基础用法 |
| | | |
| | | ```vue |
| | | <template> |
| | | <div class="search-page"> |
| | | <ArtSearchBar |
| | | v-model="formData" |
| | | :items="formItems" |
| | | @search="handleSearch" |
| | | @reset="handleReset" |
| | | /> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import type { SearchFormItem } from '@/components/core/forms/art-search-bar/index.vue' |
| | | |
| | | const formData = ref({ |
| | | username: '', |
| | | email: '', |
| | | status: '' |
| | | }) |
| | | |
| | | // 表单项配置 |
| | | const formItems: SearchFormItem[] = [ |
| | | { |
| | | prop: 'username', |
| | | label: '用户名', |
| | | component: 'ElInput', |
| | | componentProps: { |
| | | placeholder: '请输入用户名', |
| | | clearable: true |
| | | } |
| | | }, |
| | | { |
| | | prop: 'email', |
| | | label: '邮箱', |
| | | component: 'ElInput', |
| | | componentProps: { |
| | | placeholder: '请输入邮箱', |
| | | clearable: true |
| | | } |
| | | }, |
| | | { |
| | | prop: 'status', |
| | | label: '状态', |
| | | component: 'ElSelect', |
| | | componentProps: { |
| | | placeholder: '请选择状态', |
| | | clearable: true, |
| | | options: [ |
| | | { label: '启用', value: '1' }, |
| | | { label: '禁用', value: '0' } |
| | | ] |
| | | } |
| | | } |
| | | ] |
| | | |
| | | const handleSearch = (params) => { |
| | | console.log('搜索参数:', params) |
| | | // 执行搜索逻辑 |
| | | } |
| | | |
| | | const handleReset = () => { |
| | | console.log('重置表单') |
| | | } |
| | | </script> |
| | | ``` |
| | | |
| | | ## 高级配置 |
| | | |
| | | ### 默认展开 |
| | | |
| | | ```vue |
| | | <ArtSearchBar |
| | | v-model="formData" |
| | | :items="formItems" |
| | | :defaultExpanded="true" |
| | | @search="handleSearch" |
| | | /> |
| | | ``` |
| | | |
| | | ### 自定义标签宽度 |
| | | |
| | | ```vue |
| | | <ArtSearchBar |
| | | v-model="formData" |
| | | :items="formItems" |
| | | :labelWidth="120" |
| | | @search="handleSearch" |
| | | /> |
| | | ``` |
| | | |
| | | ### 设置一行显示的组件数 |
| | | |
| | | ```vue |
| | | <ArtSearchBar |
| | | v-model="formData" |
| | | :items="formItems" |
| | | :span="8" |
| | | @search="handleSearch" |
| | | /> |
| | | ``` |
| | | |
| | | ### 表单校验 |
| | | |
| | | ```vue |
| | | <ArtSearchBar |
| | | v-model="formData" |
| | | :items="formItems" |
| | | :rules="rules" |
| | | @search="handleSearch" |
| | | /> |
| | | |
| | | <script setup> |
| | | const rules = { |
| | | username: [ |
| | | { required: true, message: '请输入用户名', trigger: 'blur' } |
| | | ], |
| | | email: [ |
| | | { type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' } |
| | | ] |
| | | } |
| | | </script> |
| | | ``` |
| | | |
| | | ## 表单项配置类型 |
| | | |
| | | ```js |
| | | interface SearchFormItem { |
| | | prop: string // 字段名 |
| | | label: string // 标签文本 |
| | | component: string // 组件名称(如 'ElInput') |
| | | componentProps?: Object // 组件 props |
| | | span?: number // 栅格占位格数 |
| | | itemProps?: Object // FormItem props |
| | | } |
| | | ``` |
| | | |
| | | ## 支持的组件 |
| | | |
| | | - `ElInput` - 输入框 |
| | | - `ElSelect` - 下拉选择 |
| | | - `ElDatePicker` - 日期选择 |
| | | - `ElTimePicker` - 时间选择 |
| | | - `ElCascader` - 级联选择 |
| | | - `ElSwitch` - 开关 |
| | | |
| | | ## 动态操作 |
| | | |
| | | ### 添加表单项 |
| | | |
| | | ```js |
| | | const addFormItem = () => { |
| | | formItems.value.push({ |
| | | prop: 'newField', |
| | | label: '新字段', |
| | | component: 'ElInput', |
| | | componentProps: { |
| | | placeholder: '请输入' |
| | | } |
| | | }) |
| | | } |
| | | ``` |
| | | |
| | | ### 修改表单项 |
| | | |
| | | ```js |
| | | const updateFormItem = () => { |
| | | const index = formItems.value.findIndex(item => item.prop === 'username') |
| | | if (index !== -1) { |
| | | formItems.value[index].label = '用户名(已修改)' |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | ### 删除表单项 |
| | | |
| | | ```js |
| | | const deleteFormItem = () => { |
| | | const index = formItems.value.findIndex(item => item.prop === 'email') |
| | | if (index !== -1) { |
| | | formItems.value.splice(index, 1) |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | ## 插槽使用 |
| | | |
| | | ```vue |
| | | <ArtSearchBar |
| | | v-model="formData" |
| | | :items="formItems" |
| | | > |
| | | <template #slots> |
| | | <ElInput v-model="formData.custom" placeholder="自定义组件" /> |
| | | </template> |
| | | </ArtSearchBar> |
| | | ``` |
| | | |
| | | ## 实际应用示例 |
| | | |
| | | ### 结合 useTable 使用 |
| | | |
| | | ```vue |
| | | <template> |
| | | <div class="user-page art-full-height"> |
| | | <!-- 搜索栏 --> |
| | | <ArtSearchBar |
| | | v-model="searchForm" |
| | | :items="searchItems" |
| | | @search="handleSearch" |
| | | @reset="handleReset" |
| | | /> |
| | | |
| | | <!-- 表格 --> |
| | | <ElCard class="art-table-card" shadow="never"> |
| | | <ArtTable |
| | | :data="data" |
| | | :columns="columns" |
| | | :pagination="pagination" |
| | | @pagination:current-change="handleCurrentChange" |
| | | /> |
| | | </ElCard> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useTable } from '@/hooks/core/useTable' |
| | | |
| | | const searchForm = ref({ |
| | | username: '', |
| | | status: '' |
| | | }) |
| | | |
| | | const searchItems = [ |
| | | { |
| | | prop: 'username', |
| | | label: '用户名', |
| | | component: 'ElInput' |
| | | }, |
| | | { |
| | | prop: 'status', |
| | | label: '状态', |
| | | component: 'ElSelect', |
| | | componentProps: { |
| | | options: [ |
| | | { label: '启用', value: '1' }, |
| | | { label: '禁用', value: '0' } |
| | | ] |
| | | } |
| | | } |
| | | ] |
| | | |
| | | const { |
| | | data, |
| | | columns, |
| | | pagination, |
| | | getData, |
| | | searchParams |
| | | } = useTable({ |
| | | core: { |
| | | apiFn: fetchGetUserList, |
| | | apiParams: { |
| | | current: 1, |
| | | size: 20 |
| | | }, |
| | | columnsFactory: () => [...] |
| | | } |
| | | }) |
| | | |
| | | const handleSearch = (params) => { |
| | | Object.assign(searchParams, params) |
| | | getData() |
| | | } |
| | | |
| | | const handleReset = () => { |
| | | searchForm.value = { |
| | | username: '', |
| | | status: '' |
| | | } |
| | | } |
| | | </script> |
| | | ``` |
| | | |
| | | ## 相关组件 |
| | | |
| | | - [组件速查表](../../cheatsheet.md) |
| | | - [ArtSearchBar 组件文档](../../17-art-search-bar.md) |
| | | - [表单集合示例](./form-collection.md) |
| | | |
| | | ## 完整示例位置 |
| | | |
| | | `src/views/examples/forms/search-bar.vue` |
| | | |
| | | |
| New file |
| | |
| | | # Art Design Pro 使用示例 |
| | | |
| | | 本目录包含从 HCG 项目中提取的真实使用案例,帮助你快速上手 Art Design Pro 组件库。 |
| | | |
| | | ## 📚 示例目录 |
| | | |
| | | ### 表格示例 ([tables/](./tables/)) |
| | | |
| | | | 示例 | 说明 | 难度 | |
| | | |------|------|------| |
| | | | [基础表格](./tables/basic-table.md) | 最简单的表格使用示例 | ⭐ | |
| | | | [高级表格](./tables/advanced-table.md) | 包含自定义列、排序、筛选等功能 | ⭐⭐⭐ | |
| | | | [树形表格](./tables/tree-table.md) | 树形结构数据展示 | ⭐⭐ | |
| | | |
| | | ### 表单示例 ([forms/](./forms/)) |
| | | |
| | | | 示例 | 说明 | 难度 | |
| | | |------|------|------| |
| | | | [搜索栏](./forms/search-bar.md) | ArtSearchBar 组件完整使用指南 | ⭐⭐ | |
| | | | [表单集合](./forms/form-collection.md) | 各种表单组件的组合使用 | ⭐⭐⭐ | |
| | | |
| | | ### 权限控制示例 ([permission/](./permission/)) |
| | | |
| | | | 示例 | 说明 | 难度 | |
| | | |------|------|------| |
| | | | [按钮权限](./permission/button-permission.md) | v-roles 和 v-auth 指令使用 | ⭐⭐ | |
| | | | [角色控制](./permission/role-control.md) | 基于角色的权限管理 | ⭐⭐⭐ | |
| | | | [页面可见性](./permission/page-visibility.md) | 页面级别的权限控制 | ⭐⭐ | |
| | | |
| | | ### 页面模板 ([templates/](./templates/)) |
| | | |
| | | | 模板 | 说明 | 难度 | |
| | | |------|------|------| |
| | | | [CRUD 页面](./templates/crud-page.md) | 完整的增删改查页面示例 | ⭐⭐⭐ | |
| | | | [仪表板](./templates/dashboard.md) | 数据统计和图表展示页面 | ⭐⭐⭐ | |
| | | | [列表页](./templates/list-page.md) | 简单的数据列表页面 | ⭐⭐ | |
| | | |
| | | ## 🚀 快速开始 |
| | | |
| | | ### 1. 查看基础示例 |
| | | |
| | | 如果你是第一次使用 Art Design Pro,建议按以下顺序阅读: |
| | | |
| | | 1. [基础表格](./tables/basic-table.md) - 了解最简单的表格使用 |
| | | 2. [搜索栏](./forms/search-bar.md) - 学习搜索功能实现 |
| | | 3. [CRUD 页面](./templates/crud-page.md) - 查看完整功能组合 |
| | | |
| | | ### 2. 使用代码生成器 |
| | | |
| | | 使用代码生成器快速创建页面模板: |
| | | |
| | | ```bash |
| | | # 生成 CRUD 页面 |
| | | python skill/art-design-pro/scripts/generate.py \ |
| | | crud \ |
| | | --name "User" \ |
| | | --path "system/user" \ |
| | | --fields "username,email,phone,status" |
| | | ``` |
| | | |
| | | 详见:[代码生成器使用指南](../generator-guide.md) |
| | | |
| | | ### 3. 参考实际项目代码 |
| | | |
| | | 所有示例都提取自实际项目,你可以在以下位置找到完整代码: |
| | | |
| | | - 表格示例:`src/views/examples/tables/` |
| | | - 表单示例:`src/views/examples/forms/` |
| | | - 权限示例:`src/views/examples/permission/` |
| | | - 系统页面:`src/views/system/` |
| | | |
| | | ## 📖 常见模式 |
| | | |
| | | ### 模式 1:搜索 + 表格 |
| | | |
| | | ```vue |
| | | <template> |
| | | <div class="page art-full-height"> |
| | | <!-- 搜索栏 --> |
| | | <ArtSearchBar v-model="searchForm" @search="handleSearch" /> |
| | | |
| | | <!-- 表格 --> |
| | | <ElCard class="art-table-card"> |
| | | <ArtTable :data="data" :columns="columns" /> |
| | | </ElCard> |
| | | </div> |
| | | </template> |
| | | ``` |
| | | |
| | | ### 模式 2:搜索 + 表格 + 弹窗 |
| | | |
| | | ```vue |
| | | <template> |
| | | <div class="page art-full-height"> |
| | | <ArtSearchBar v-model="searchForm" @search="handleSearch" /> |
| | | |
| | | <ElCard class="art-table-card"> |
| | | <ArtTableHeader> |
| | | <template #left> |
| | | <ElButton @click="showDialog">新增</ElButton> |
| | | </template> |
| | | </ArtTableHeader> |
| | | |
| | | <ArtTable :data="data" :columns="columns" /> |
| | | |
| | | <ElDialog v-model="dialogVisible"> |
| | | <ElForm>...</ElForm> |
| | | </ElDialog> |
| | | </ElCard> |
| | | </div> |
| | | </template> |
| | | ``` |
| | | |
| | | ### 模式 3:权限控制 |
| | | |
| | | ```vue |
| | | <template> |
| | | <ElButton v-auth="'user:create'">新增</ElButton> |
| | | <ElButton v-roles="['admin']">管理</ElButton> |
| | | </template> |
| | | ``` |
| | | |
| | | ## 🎯 学习路径 |
| | | |
| | | ### 初级(1-2 天) |
| | | |
| | | - [ ] 阅读基础表格示例 |
| | | - [ ] 阅读搜索栏示例 |
| | | - [ ] 创建一个简单的列表页面 |
| | | |
| | | ### 中级(3-5 天) |
| | | |
| | | - [ ] 学习完整的 CRUD 页面 |
| | | - [ ] 掌握自定义列渲染 |
| | | - [ ] 实现权限控制 |
| | | |
| | | ### 高级(5-7 天) |
| | | |
| | | - [ ] 学习高级表格功能 |
| | | - [ ] 掌握表单校验 |
| | | - [ ] 实现复杂业务逻辑 |
| | | |
| | | ## 💡 最佳实践 |
| | | |
| | | ### 1. 使用 useTable Hook |
| | | |
| | | `useTable` Hook 提供了表格的完整功能,推荐所有表格页面使用: |
| | | |
| | | ```js |
| | | const { data, columns, loading, pagination } = useTable({...}) |
| | | ``` |
| | | |
| | | ### 2. 使用 ArtSearchBar |
| | | |
| | | `ArtSearchBar` 组件封装了搜索栏的通用逻辑: |
| | | |
| | | ```vue |
| | | <ArtSearchBar v-model="formData" :items="formItems" /> |
| | | ``` |
| | | |
| | | ### 3. 权限控制 |
| | | |
| | | 使用 `v-auth` 和 `v-roles` 指令控制按钮权限: |
| | | |
| | | ```vue |
| | | <ElButton v-auth="'user:create'">新增</ElButton> |
| | | ``` |
| | | |
| | | ### 4. 样式类 |
| | | |
| | | 使用 Art Design Pro 提供的样式类: |
| | | |
| | | - `art-full-height`: 自动计算剩余高度 |
| | | - `art-table-card`: 表格卡片样式 |
| | | - `flex-c`: Flex 垂直居中 |
| | | |
| | | ## 📚 相关文档 |
| | | |
| | | - [组件文档](../components/) |
| | | - [Hook 文档](../hooks/) |
| | | - [官方文档](https://www.artd.pro/docs/zh/guide/) |
| | | - [代码生成器](../generator-guide.md) |
| | | |
| | | ## 🤝 贡献示例 |
| | | |
| | | 如果你有好的示例想要分享,请: |
| | | |
| | | 1. 将代码提交到 `src/views/examples/` |
| | | 2. 在本目录添加对应的文档 |
| | | 3. 更新索引文件 |
| | | |
| | | --- |
| | | |
| | | **最后更新**:2026-03-03 |
| | | **维护者**:Art Design Pro Skill Team |
| | | |
| | | |
| New file |
| | |
| | | # 权限控制使用示例 |
| | | |
| | | ## 概述 |
| | | |
| | | Art Design Pro 提供了完善的权限控制系统,支持基于角色(`v-roles`)和基于权限码(`v-auth`)的按钮级别权限控制。 |
| | | |
| | | ## 权限指令 |
| | | |
| | | ### v-roles - 基于角色的权限控制 |
| | | |
| | | 根据用户角色控制元素显示: |
| | | |
| | | ```vue |
| | | <template> |
| | | <div> |
| | | <!-- 只有 admin 角色可见 --> |
| | | <ElButton v-roles="['admin']">管理员操作</ElButton> |
| | | |
| | | <!-- admin 或 editor 角色可见 --> |
| | | <ElButton v-roles="['admin', 'editor']">编辑操作</ElButton> |
| | | |
| | | <!-- 多个角色都具备时才可见(AND 逻辑) --> |
| | | <ElButton v-roles="['admin', 'editor']" :mode="'and'"> |
| | | 高级操作 |
| | | </ElButton> |
| | | </div> |
| | | </template> |
| | | ``` |
| | | |
| | | ### v-auth - 基于权限码的控制 |
| | | |
| | | 根据用户权限码控制元素显示: |
| | | |
| | | ```vue |
| | | <template> |
| | | <div> |
| | | <!-- 具备特定权限码可见 --> |
| | | <ElButton v-auth="'user:create'">新增用户</ElButton> |
| | | <ElButton v-auth="'user:edit'">编辑用户</ElButton> |
| | | <ElButton v-auth="'user:delete'">删除用户</ElButton> |
| | | |
| | | <!-- 多个权限码都具备时才可见(AND 逻辑) --> |
| | | <ElButton v-auth="['user:create', 'user:edit']" :mode="'and'"> |
| | | 完整操作 |
| | | </ElButton> |
| | | </div> |
| | | </template> |
| | | ``` |
| | | |
| | | ## 实际应用示例 |
| | | |
| | | ### 表格操作列权限控制 |
| | | |
| | | ```vue |
| | | <template> |
| | | <ArtTable |
| | | :data="data" |
| | | :columns="columns" |
| | | > |
| | | <template #operation="{ row }"> |
| | | <ElSpace> |
| | | <!-- 查看权限 - 所有用户可见 --> |
| | | <ElButton |
| | | type="primary" |
| | | size="small" |
| | | @click="handleView(row)" |
| | | > |
| | | 查看 |
| | | </ElButton> |
| | | |
| | | <!-- 编辑权限 - admin 和 editor 可见 --> |
| | | <ElButton |
| | | v-roles="['admin', 'editor']" |
| | | type="warning" |
| | | size="small" |
| | | @click="handleEdit(row)" |
| | | > |
| | | 编辑 |
| | | </ElButton> |
| | | |
| | | <!-- 删除权限 - 只有 admin 可见 --> |
| | | <ElButton |
| | | v-roles="['admin']" |
| | | type="danger" |
| | | size="small" |
| | | @click="handleDelete(row)" |
| | | > |
| | | 删除 |
| | | </ElButton> |
| | | </ElSpace> |
| | | </template> |
| | | </ArtTable> |
| | | </template> |
| | | ``` |
| | | |
| | | ### 页面头部按钮权限控制 |
| | | |
| | | ```vue |
| | | <template> |
| | | <div class="user-page art-full-height"> |
| | | <ElCard class="art-table-card" shadow="never"> |
| | | <!-- 表格头部 --> |
| | | <ArtTableHeader v-model:columns="columnChecks" :loading="loading"> |
| | | <template #left> |
| | | <ElSpace wrap> |
| | | <!-- 新增权限 --> |
| | | <ElButton |
| | | v-auth="'user:create'" |
| | | type="primary" |
| | | @click="handleAdd" |
| | | > |
| | | 新增用户 |
| | | </ElButton> |
| | | |
| | | <!-- 批量删除权限 --> |
| | | <ElButton |
| | | v-auth="'user:delete:batch'" |
| | | type="danger" |
| | | :disabled="selectedRows.length === 0" |
| | | @click="handleBatchDelete" |
| | | > |
| | | 批量删除 |
| | | </ElButton> |
| | | |
| | | <!-- 导出权限 --> |
| | | <ElButton |
| | | v-auth="'user:export'" |
| | | @click="handleExport" |
| | | > |
| | | 导出数据 |
| | | </ElButton> |
| | | </ElSpace> |
| | | </template> |
| | | </ArtTableHeader> |
| | | |
| | | <!-- 表格 --> |
| | | <ArtTable |
| | | :data="data" |
| | | :columns="columns" |
| | | @selection-change="handleSelectionChange" |
| | | /> |
| | | </ElCard> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useTable } from '@/hooks/core/useTable' |
| | | |
| | | const selectedRows = ref([]) |
| | | |
| | | const { data, columns } = useTable({...}) |
| | | |
| | | const handleSelectionChange = (selection: any[]) => { |
| | | selectedRows.value = selection |
| | | } |
| | | |
| | | // 各种操作方法... |
| | | </script> |
| | | ``` |
| | | |
| | | ### 自定义权限检查 |
| | | |
| | | 使用 `useAuth` Hook 进行编程式权限检查: |
| | | |
| | | ```vue |
| | | <script setup> |
| | | import { useAuth } from '@/hooks/core/useAuth' |
| | | |
| | | const { hasRole, hasAuth, hasRoles, hasAuths } = useAuth() |
| | | |
| | | // 检查单个角色 |
| | | const isAdmin = hasRole('admin') |
| | | |
| | | // 检查多个角色(OR 逻辑) |
| | | const canEdit = hasRoles(['admin', 'editor']) |
| | | |
| | | // 检查单个权限码 |
| | | const canCreate = hasAuth('user:create') |
| | | |
| | | // 检查多个权限码(OR 逻辑) |
| | | const canManage = hasAuths(['user:create', 'user:edit']) |
| | | |
| | | // 使用示例 |
| | | const handleOperation = () => { |
| | | if (!canManage) { |
| | | ElMessage.error('权限不足') |
| | | return |
| | | } |
| | | // 执行操作 |
| | | } |
| | | </script> |
| | | ``` |
| | | |
| | | ## 权限配置 |
| | | |
| | | ### 角色定义 |
| | | |
| | | 在用户数据结构中定义角色: |
| | | |
| | | ```js |
| | | interface User { |
| | | id: string |
| | | username: string |
| | | roles: string[] // 用户角色列表 |
| | | permissions: string[] // 用户权限码列表 |
| | | } |
| | | ``` |
| | | |
| | | ### 权限码配置 |
| | | |
| | | ```js |
| | | // 常见权限码命名规范 |
| | | const permissionCodes = { |
| | | // 用户管理 |
| | | 'user:view': '查看用户', |
| | | 'user:create': '创建用户', |
| | | 'user:edit': '编辑用户', |
| | | 'user:delete': '删除用户', |
| | | 'user:export': '导出用户', |
| | | |
| | | // 角色管理 |
| | | 'role:view': '查看角色', |
| | | 'role:create': '创建角色', |
| | | 'role:edit': '编辑角色', |
| | | 'role:delete': '删除角色', |
| | | |
| | | // 系统设置 |
| | | 'system:view': '查看系统设置', |
| | | 'system:edit': '编辑系统设置' |
| | | } |
| | | ``` |
| | | |
| | | ## 指令参数说明 |
| | | |
| | | ### v-roles 参数 |
| | | |
| | | | 参数 | 类型 | 默认值 | 说明 | |
| | | |------|------|--------|------| |
| | | | value | string\|string[] | - | 角色代码 | |
| | | | mode | 'or'\|'and' | 'or' | 多角色判断逻辑 | |
| | | |
| | | ### v-auth 参数 |
| | | |
| | | | 参数 | 类型 | 默认值 | 说明 | |
| | | |------|------|--------|------| |
| | | | value | string\|string[] | - | 权限码 | |
| | | | mode | 'or'\|'and' | 'or' | 多权限码判断逻辑 | |
| | | |
| | | ## 最佳实践 |
| | | |
| | | ### 1. 权限粒度设计 |
| | | |
| | | ```js |
| | | // ✅ 推荐:细粒度权限控制 |
| | | <ElButton v-auth="'user:create'">新增</ElButton> |
| | | <ElButton v-auth="'user:edit'">编辑</ElButton> |
| | | <ElButton v-auth="'user:delete'">删除</ElButton> |
| | | |
| | | // ❌ 不推荐:粗粒度权限控制 |
| | | <ElButton v-auth="'user:manage'">管理</ElButton> |
| | | ``` |
| | | |
| | | ### 2. 前后端权限验证 |
| | | |
| | | 前端权限控制只是为了用户体验,真正的权限验证必须在后端进行: |
| | | |
| | | ```js |
| | | // 前端:控制 UI 显示 |
| | | <ElButton v-auth="'user:delete'" @click="deleteUser">删除</ElButton> |
| | | |
| | | // 后端:验证权限 |
| | | async function deleteUser(userId: string) { |
| | | // 检查用户是否有删除权限 |
| | | if (!hasPermission('user:delete')) { |
| | | throw new Error('权限不足') |
| | | } |
| | | |
| | | // 执行删除操作 |
| | | await api.deleteUser(userId) |
| | | } |
| | | ``` |
| | | |
| | | ### 3. 权限缓存 |
| | | |
| | | 使用缓存提升权限检查性能: |
| | | |
| | | ```js |
| | | const { hasAuth } = useAuth() |
| | | |
| | | // 缓存权限检查结果 |
| | | const canDelete = computed(() => hasAuth('user:delete')) |
| | | ``` |
| | | |
| | | ## 相关文档 |
| | | |
| | | - [权限系统文档](../../14-permission.md) |
| | | - [useAuth Hook 文档](https://www.artd.pro/docs/zh/guide/hooks/use-auth.html) |
| | | - [角色管理示例](./role-control.md) |
| | | |
| | | ## 完整示例位置 |
| | | |
| | | - `src/views/examples/permission/button-auth/index.vue` |
| | | - `src/views/examples/permission/page-visibility/index.vue` |
| | | - `src/views/examples/permission/switch-role/index.vue` |
| | | |
| | | |
| New file |
| | |
| | | # 基础表格使用示例 |
| | | |
| | | ## 概述 |
| | | |
| | | 这是一个最简单的表格使用示例,展示了如何使用 `useTable` Hook 快速创建一个数据表格。 |
| | | |
| | | ## 完整代码 |
| | | |
| | | ```vue |
| | | <!-- 基础表格 --> |
| | | <template> |
| | | <div class="user-page art-full-height"> |
| | | <ElCard class="art-table-card" shadow="never"> |
| | | <!-- 表格头部(可选,提供刷新、列设置等功能) --> |
| | | <ArtTableHeader v-model:columns="columns" :loading="loading" /> |
| | | |
| | | <!-- 表格 --> |
| | | <ArtTable |
| | | :loading="loading" |
| | | :data="data" |
| | | :columns="columns" |
| | | :pagination="pagination" |
| | | fit |
| | | @pagination:size-change="handleSizeChange" |
| | | @pagination:current-change="handleCurrentChange" |
| | | > |
| | | </ArtTable> |
| | | </ElCard> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useTable } from '@/hooks/core/useTable' |
| | | import { fetchGetUserList } from '@/api/system-manage' |
| | | |
| | | defineOptions({ name: 'BasicTable' }) |
| | | |
| | | const { |
| | | data, |
| | | columns, |
| | | loading, |
| | | pagination, |
| | | handleSizeChange, |
| | | handleCurrentChange |
| | | } = useTable({ |
| | | core: { |
| | | apiFn: fetchGetUserList, |
| | | apiParams: { |
| | | current: 1, |
| | | size: 20 |
| | | }, |
| | | columnsFactory: () => [ |
| | | { |
| | | prop: 'id', |
| | | label: 'ID' |
| | | }, |
| | | { |
| | | prop: 'nickName', |
| | | label: '昵称' |
| | | }, |
| | | { |
| | | prop: 'userGender', |
| | | label: '性别', |
| | | sortable: true, |
| | | formatter: (row) => row.userGender || '未知' |
| | | }, |
| | | { |
| | | prop: 'userPhone', |
| | | label: '手机号' |
| | | }, |
| | | { |
| | | prop: 'userEmail', |
| | | label: '邮箱' |
| | | } |
| | | ] |
| | | } |
| | | }) |
| | | </script> |
| | | ``` |
| | | |
| | | ## 关键点说明 |
| | | |
| | | ### 1. useTable Hook |
| | | |
| | | `useTable` 是 Art Design Pro 的核心 Hook,提供了表格的完整功能: |
| | | |
| | | ```js |
| | | const { |
| | | data, // 表格数据 |
| | | columns, // 列配置 |
| | | loading, // 加载状态 |
| | | pagination, // 分页配置 |
| | | handleSizeChange, // 每页数量变化处理 |
| | | handleCurrentChange // 当前页变化处理 |
| | | } = useTable({...}) |
| | | ``` |
| | | |
| | | ### 2. 列配置 |
| | | |
| | | 通过 `columnsFactory` 定义表格列: |
| | | |
| | | ```js |
| | | columnsFactory: () => [ |
| | | { |
| | | prop: 'id', // 数据字段名 |
| | | label: 'ID' // 列标题 |
| | | }, |
| | | { |
| | | prop: 'userGender', |
| | | label: '性别', |
| | | sortable: true, // 启用排序 |
| | | formatter: (row) => // 自定义格式化 |
| | | row.userGender || '未知' |
| | | } |
| | | ] |
| | | ``` |
| | | |
| | | ### 3. 样式类 |
| | | |
| | | - `art-full-height`: 自动计算页面剩余高度 |
| | | - `art-table-card`: 符合系统样式的表格卡片,自动撑满剩余高度 |
| | | |
| | | ## 常用配置 |
| | | |
| | | ### 添加序号列 |
| | | |
| | | ```js |
| | | columnsFactory: () => [ |
| | | { type: 'index', width: 60, label: '序号' }, |
| | | // ... 其他列 |
| | | ] |
| | | ``` |
| | | |
| | | ### 添加复选框列 |
| | | |
| | | ```js |
| | | columnsFactory: () => [ |
| | | { type: 'selection' }, |
| | | // ... 其他列 |
| | | ] |
| | | ``` |
| | | |
| | | ### 自定义列渲染 |
| | | |
| | | 使用 `formatter` 函数自定义列内容: |
| | | |
| | | ```js |
| | | { |
| | | prop: 'status', |
| | | label: '状态', |
| | | formatter: (row) => { |
| | | return h(ElTag, { type: 'success' }, () => '启用') |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | ## 分页配置 |
| | | |
| | | `useTable` 自动处理分页,默认配置: |
| | | |
| | | ```js |
| | | // 默认分页参数 |
| | | apiParams: { |
| | | current: 1, // 当前页 |
| | | size: 20 // 每页数量 |
| | | } |
| | | ``` |
| | | |
| | | ## 相关组件 |
| | | |
| | | - [组件速查表](../../cheatsheet.md) |
| | | - [useTable Hook 文档](../../16-use-table.md) |
| | | - [高级表格示例](./advanced-table.md) |
| | | - [树形表格示例](./tree-table.md) |
| | | |
| | | ## 完整示例位置 |
| | | |
| | | `src/views/examples/tables/basic.vue` |
| | | |
| | | |
| New file |
| | |
| | | # 完整 CRUD 页面示例 |
| | | |
| | | ## 概述 |
| | | |
| | | 这是一个完整的用户管理 CRUD 页面示例,展示了 Art Design Pro 的核心功能组合使用。 |
| | | |
| | | ## 页面结构 |
| | | |
| | | ``` |
| | | system/user/ |
| | | ├── index.vue # 主页面 |
| | | ├── modules/ |
| | | │ ├── user-search.vue # 搜索栏组件 |
| | | │ └── user-dialog.vue # 弹窗组件 |
| | | ``` |
| | | |
| | | ## 主页面代码 |
| | | |
| | | ```vue |
| | | <!-- 用户管理页面 --> |
| | | <template> |
| | | <div class="user-page art-full-height"> |
| | | <!-- 搜索栏 --> |
| | | <UserSearch |
| | | v-model="searchForm" |
| | | @search="handleSearch" |
| | | @reset="resetSearchParams" |
| | | /> |
| | | |
| | | <ElCard class="art-table-card" shadow="never"> |
| | | <!-- 表格头部 --> |
| | | <ArtTableHeader |
| | | v-model:columns="columnChecks" |
| | | :loading="loading" |
| | | @refresh="refreshData" |
| | | > |
| | | <template #left> |
| | | <ElSpace wrap> |
| | | <ElButton |
| | | v-auth="'user:create'" |
| | | type="primary" |
| | | @click="showDialog('add')" |
| | | v-ripple |
| | | > |
| | | 新增用户 |
| | | </ElButton> |
| | | </ElSpace> |
| | | </template> |
| | | </ArtTableHeader> |
| | | |
| | | <!-- 表格 --> |
| | | <ArtTable |
| | | :loading="loading" |
| | | :data="data" |
| | | :columns="columns" |
| | | :pagination="pagination" |
| | | @selection-change="handleSelectionChange" |
| | | @pagination:size-change="handleSizeChange" |
| | | @pagination:current-change="handleCurrentChange" |
| | | /> |
| | | |
| | | <!-- 用户弹窗 --> |
| | | <UserDialog |
| | | v-model:visible="dialogVisible" |
| | | :type="dialogType" |
| | | :user-data="currentUserData" |
| | | @submit="handleDialogSubmit" |
| | | /> |
| | | </ElCard> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useTable } from '@/hooks/core/useTable' |
| | | import { fetchGetUserList } from '@/api/system-manage' |
| | | import UserSearch from './modules/user-search.vue' |
| | | import UserDialog from './modules/user-dialog.vue' |
| | | import { ElTag, ElMessageBox, ElImage } from 'element-plus' |
| | | import { DialogType } from '@/types' |
| | | |
| | | defineOptions({ name: 'User' }) |
| | | |
| | | type UserListItem = Api.SystemManage.UserListItem |
| | | |
| | | // 弹窗相关 |
| | | const dialogType = ref<DialogType>('add') |
| | | const dialogVisible = ref(false) |
| | | const currentUserData = ref<Partial<UserListItem>>({}) |
| | | |
| | | // 选中行 |
| | | const selectedRows = ref<UserListItem[]>([]) |
| | | |
| | | // 搜索表单 |
| | | const searchForm = ref({ |
| | | userName: undefined, |
| | | userGender: undefined, |
| | | userPhone: undefined, |
| | | userEmail: undefined, |
| | | status: '1' |
| | | }) |
| | | |
| | | // 用户状态配置 |
| | | const USER_STATUS_CONFIG = { |
| | | '1': { type: 'success' as const, text: '在线' }, |
| | | '2': { type: 'info' as const, text: '离线' }, |
| | | '3': { type: 'warning' as const, text: '异常' }, |
| | | '4': { type: 'danger' as const, text: '注销' } |
| | | } as const |
| | | |
| | | const { |
| | | columns, |
| | | columnChecks, |
| | | data, |
| | | loading, |
| | | pagination, |
| | | getData, |
| | | searchParams, |
| | | resetSearchParams, |
| | | handleSizeChange, |
| | | handleCurrentChange, |
| | | refreshData |
| | | } = useTable({ |
| | | core: { |
| | | apiFn: fetchGetUserList, |
| | | apiParams: { |
| | | current: 1, |
| | | size: 20, |
| | | ...searchForm.value |
| | | }, |
| | | columnsFactory: () => [ |
| | | { type: 'selection' }, |
| | | { type: 'index', width: 60, label: '序号' }, |
| | | { |
| | | prop: 'userInfo', |
| | | label: '用户信息', |
| | | width: 280, |
| | | formatter: (row) => { |
| | | return h('div', { class: 'user flex-c' }, [ |
| | | h(ElImage, { |
| | | class: 'size-9.5 rounded-md', |
| | | src: row.avatar, |
| | | previewSrcList: [row.avatar], |
| | | previewTeleported: true |
| | | }), |
| | | h('div', { class: 'ml-2' }, [ |
| | | h('p', { class: 'user-name' }, row.userName), |
| | | h('p', { class: 'email' }, row.userEmail) |
| | | ]) |
| | | ]) |
| | | } |
| | | }, |
| | | { |
| | | prop: 'userGender', |
| | | label: '性别', |
| | | sortable: true, |
| | | formatter: (row) => row.userGender |
| | | }, |
| | | { prop: 'userPhone', label: '手机号' }, |
| | | { |
| | | prop: 'status', |
| | | label: '状态', |
| | | formatter: (row) => { |
| | | const statusConfig = USER_STATUS_CONFIG[row.status as keyof typeof USER_STATUS_CONFIG] |
| | | return h(ElTag, { type: statusConfig.type }, () => statusConfig.text) |
| | | } |
| | | }, |
| | | { |
| | | prop: 'createTime', |
| | | label: '创建日期', |
| | | sortable: true |
| | | }, |
| | | { |
| | | prop: 'operation', |
| | | label: '操作', |
| | | width: 120, |
| | | fixed: 'right', |
| | | formatter: (row) => |
| | | h('div', [ |
| | | h(ArtButtonTable, { |
| | | type: 'edit', |
| | | onClick: () => showDialog('edit', row) |
| | | }), |
| | | h(ArtButtonTable, { |
| | | type: 'delete', |
| | | onClick: () => deleteUser(row) |
| | | }) |
| | | ]) |
| | | } |
| | | ] |
| | | } |
| | | }) |
| | | |
| | | /** |
| | | * 搜索处理 |
| | | */ |
| | | const handleSearch = (params) => { |
| | | Object.assign(searchParams, params) |
| | | getData() |
| | | } |
| | | |
| | | /** |
| | | * 显示用户弹窗 |
| | | */ |
| | | const showDialog = (type: DialogType, row?: UserListItem): void => { |
| | | dialogType.value = type |
| | | currentUserData.value = row || {} |
| | | nextTick(() => { |
| | | dialogVisible.value = true |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 删除用户 |
| | | */ |
| | | const deleteUser = (row: UserListItem): void => { |
| | | ElMessageBox.confirm('确定要删除该用户吗?', '删除用户', { |
| | | confirmButtonText: '确定', |
| | | cancelButtonText: '取消', |
| | | type: 'error' |
| | | }).then(async () => { |
| | | // 调用删除 API |
| | | // await deleteUserApi(row.id) |
| | | ElMessage.success('删除成功') |
| | | refreshData() |
| | | }) |
| | | } |
| | | |
| | | /** |
| | | * 处理弹窗提交事件 |
| | | */ |
| | | const handleDialogSubmit = async () => { |
| | | dialogVisible.value = false |
| | | currentUserData.value = {} |
| | | refreshData() |
| | | } |
| | | |
| | | /** |
| | | * 处理表格行选择变化 |
| | | */ |
| | | const handleSelectionChange = (selection: UserListItem[]): void => { |
| | | selectedRows.value = selection |
| | | } |
| | | </script> |
| | | ``` |
| | | |
| | | ## 关键功能点 |
| | | |
| | | ### 1. useTable Hook 集成 |
| | | |
| | | ```js |
| | | const { |
| | | columns, // 列配置 |
| | | columnChecks, // 列显示控制 |
| | | data, // 表格数据 |
| | | loading, // 加载状态 |
| | | pagination, // 分页信息 |
| | | getData, // 获取数据 |
| | | searchParams, // 搜索参数 |
| | | resetSearchParams, // 重置搜索 |
| | | handleSizeChange, // 每页数量变化 |
| | | handleCurrentChange, // 当前页变化 |
| | | refreshData // 刷新数据 |
| | | } = useTable({...}) |
| | | ``` |
| | | |
| | | ### 2. 自定义列渲染 |
| | | |
| | | 使用 `formatter` 函数实现复杂的列渲染: |
| | | |
| | | ```js |
| | | { |
| | | prop: 'userInfo', |
| | | label: '用户信息', |
| | | formatter: (row) => { |
| | | return h('div', { class: 'user flex-c' }, [ |
| | | h(ElImage, { src: row.avatar }), |
| | | h('div', { class: 'ml-2' }, [ |
| | | h('p', row.userName), |
| | | h('p', row.userEmail) |
| | | ]) |
| | | ]) |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | ### 3. 权限控制 |
| | | |
| | | ```vue |
| | | <ElButton |
| | | v-auth="'user:create'" |
| | | type="primary" |
| | | @click="showDialog('add')" |
| | | > |
| | | 新增用户 |
| | | </ElButton> |
| | | ``` |
| | | |
| | | ### 4. 搜索栏集成 |
| | | |
| | | ```vue |
| | | <UserSearch |
| | | v-model="searchForm" |
| | | @search="handleSearch" |
| | | @reset="resetSearchParams" |
| | | /> |
| | | ``` |
| | | |
| | | ### 5. 弹窗表单 |
| | | |
| | | ```vue |
| | | <UserDialog |
| | | v-model:visible="dialogVisible" |
| | | :type="dialogType" |
| | | :user-data="currentUserData" |
| | | @submit="handleDialogSubmit" |
| | | /> |
| | | ``` |
| | | |
| | | ## 样式说明 |
| | | |
| | | - `art-full-height`: 自动计算页面剩余高度 |
| | | - `art-table-card`: 符合系统样式的表格卡片,自动撑满剩余高度 |
| | | - `flex-c`: Flex 布局,垂直居中 |
| | | - `size-9.5`: 固定尺寸(约 38px) |
| | | |
| | | ## 相关组件 |
| | | |
| | | - [组件速查表](../../cheatsheet.md) |
| | | - [useTable Hook 文档](../../16-use-table.md) |
| | | - [ArtSearchBar 组件文档](../../17-art-search-bar.md) |
| | | |
| | | ## 完整示例位置 |
| | | |
| | | `src/views/system/user/index.vue` |
| | | |
| | | ## 使用代码生成器 |
| | | |
| | | 可以使用代码生成器快速创建类似页面: |
| | | |
| | | ```bash |
| | | python skill/art-design-pro/scripts/generate.py \ |
| | | crud \ |
| | | --name "User" \ |
| | | --path "system/user" \ |
| | | --fields "username,email,phone,status" |
| | | ``` |
| | | |
| | | 详见 [代码生成器使用指南](../../generator-guide.md)。 |
| | | |
| | | |
| New file |
| | |
| | | # Art Design Pro 代码生成器使用指南 |
| | | |
| | | > **重要更新 (2026-03-04)**: 代码生成器已更新为使用 ArtForm 组件和正确的 ArtSearchBar 用法。 |
| | | |
| | | ## 📖 简介 |
| | | |
| | | 代码生成器是 Art Design Pro Skill 的核心工具之一,可以自动生成标准的 Vue3 页面代码,大幅提升开发效率。 |
| | | |
| | | ## ✨ 最新更新 |
| | | |
| | | ### v2.3.0 (2026-03-04) |
| | | |
| | | - ✅ **修正 ArtSearchBar 用法** - 使用 `v-model` + `:items` + `@search` + `@reset` |
| | | - ✅ **弹窗使用 ArtForm** - 替换原来的 `ElForm`,使用 `ArtForm` + `:model` |
| | | - ✅ **添加表单项配置生成** - 自动生成 `searchItems` 和 `formItems` 配置 |
| | | - ✅ **支持多种数据类型** - 自动识别 `number`、`boolean`、`date` 等类型 |
| | | - ✅ **修正导入语句** - 添加 `FormInstance` 类型导入 |
| | | |
| | | ### 生成代码特点 |
| | | |
| | | **搜索栏组件:** |
| | | ```vue |
| | | <ArtSearchBar |
| | | v-model="searchForm" |
| | | :items="searchItems" |
| | | @search="handleSearch" |
| | | @reset="handleReset" |
| | | /> |
| | | ``` |
| | | |
| | | **弹窗表单:** |
| | | ```vue |
| | | <ElDialog v-model="dialogVisible" :title="dialogTitle"> |
| | | <ArtForm |
| | | ref="formRef" |
| | | :items="formItems" |
| | | :model="formData" |
| | | :rules="rules" |
| | | label-width="120px" |
| | | /> |
| | | <template #footer> |
| | | <ElButton @click="handleClose">取消</ElButton> |
| | | <ElButton type="primary" @click="handleSubmit">确定</ElButton> |
| | | </template> |
| | | </ElDialog> |
| | | ``` |
| | | |
| | | --- |
| | | |
| | | ## 🎯 支持的页面类型 |
| | | |
| | | ### 1. CRUD 列表页(`crud`) |
| | | |
| | | 生成完整的增删改查页面,包含: |
| | | - ✅ 主页面(index.vue) |
| | | - ✅ 搜索栏组件(modules/xxx-search.vue) |
| | | - ✅ 弹窗组件(modules/xxx-dialog.vue) |
| | | - ✅ 集成 useTable Hook |
| | | - ✅ 集成 ArtSearchBar 和 ArtTable 组件 |
| | | - ✅ 完整的类型定义 |
| | | |
| | | **使用示例**: |
| | | |
| | | ```bash |
| | | # 生成用户管理页面 |
| | | python skill/art-design-pro/scripts/generate.py crud \ |
| | | --name "User" \ |
| | | --path "system/user" \ |
| | | --fields "username,email,phone,status" |
| | | |
| | | # 生成产品管理页面 |
| | | python skill/art-design-pro/scripts/generate.py crud \ |
| | | --name "Product" \ |
| | | --path "product/list" \ |
| | | --fields "name,price,stock,category,status" |
| | | ``` |
| | | |
| | | **生成的文件结构**: |
| | | |
| | | ``` |
| | | src/views/system/user/ |
| | | ├── index.vue # 主页面 |
| | | └── modules/ |
| | | ├── user-search.vue # 搜索栏组件 |
| | | └── user-dialog.vue # 弹窗组件 |
| | | ``` |
| | | |
| | | **特性**: |
| | | - 自动生成表格列配置 |
| | | - 自动生成搜索表单 |
| | | - 自动生成弹窗表单 |
| | | - 包含完整的类型定义 |
| | | - 集成 Art Design Pro 组件 |
| | | - 符合项目规范 |
| | | |
| | | --- |
| | | |
| | | ### 2. 基础表格页(`table`) |
| | | |
| | | 生成简单的数据展示表格页面,适用于: |
| | | - 只读数据列表 |
| | | - 不需要复杂操作的页面 |
| | | - 数据展示和导出 |
| | | |
| | | **使用示例**: |
| | | |
| | | ```bash |
| | | # 生成订单列表页面 |
| | | python skill/art-design-pro/scripts/generate.py table \ |
| | | --name "Order" \ |
| | | --path "order/list" \ |
| | | --fields "order_no,customer,total,status,created_at" |
| | | |
| | | # 生成日志列表页面 |
| | | python skill/art-design-pro/scripts/generate.py table \ |
| | | --name "Log" \ |
| | | --path "system/logs" \ |
| | | --fields "id,level,message,created_at" |
| | | ``` |
| | | |
| | | **特性**: |
| | | - 简洁的页面结构 |
| | | - 只包含表格展示 |
| | | - 支持分页 |
| | | - 集成 useTable Hook |
| | | |
| | | --- |
| | | |
| | | ### 3. 仪表板页面(`dashboard`) |
| | | |
| | | 生成数据统计和图表展示页面,包含: |
| | | - 统计卡片(ArtStatsCard) |
| | | - 图表组件(ArtLineChart、ArtBarChart 等) |
| | | - 响应式布局 |
| | | |
| | | **使用示例**: |
| | | |
| | | ```bash |
| | | # 生成数据分析仪表板 |
| | | python skill/art-design-pro/scripts/generate.py dashboard \ |
| | | --name "Analytics" \ |
| | | --path "dashboard/analytics" \ |
| | | --charts "line,bar,pie" |
| | | |
| | | # 生成销售统计仪表板 |
| | | python skill/art-design-pro/scripts/generate.py dashboard \ |
| | | --name "Sales" \ |
| | | --path "dashboard/sales" \ |
| | | --charts "line,bar" |
| | | ``` |
| | | |
| | | **支持的图表类型**: |
| | | - `line` - 折线图(ArtLineChart) |
| | | - `bar` - 柱状图(ArtBarChart) |
| | | - `pie` - 饼图(ArtRingChart) |
| | | - `radar` - 雷达图(ArtRadarChart) |
| | | |
| | | --- |
| | | |
| | | ## 🔧 参数说明 |
| | | |
| | | ### 通用参数 |
| | | |
| | | | 参数 | 必填 | 说明 | 示例 | |
| | | |------|------|------|------| |
| | | | `--name` | ✅ | 实体名称(英文) | `User`, `Product`, `Order` | |
| | | | `--path` | ✅ | 页面路径(相对于 views) | `system/user`, `product/list` | |
| | | | `--fields` | ✅* | 字段列表(逗号分隔) | `username,email,status` | |
| | | | `--charts` | ❌ | 图表类型(仅 dashboard) | `line,bar,pie` | |
| | | | `--output` | ❌ | 输出目录 | `/path/to/output` | |
| | | |
| | | * `--fields` 对 `crud` 和 `table` 类型是必填的,对 `dashboard` 类型不需要。 |
| | | |
| | | ### 字段类型定义 |
| | | |
| | | 支持在字段名后指定类型(使用 `:` 分隔): |
| | | |
| | | ```bash |
| | | # 默认为 string 类型 |
| | | python generate.py crud --name "User" --fields "username,email" |
| | | |
| | | # 指定类型 |
| | | python generate.py crud --name "User" --fields "username:string,age:number,active:boolean" |
| | | ``` |
| | | |
| | | 支持的类型: |
| | | - `string` - 字符串(默认) |
| | | - `number` - 数字 |
| | | - `boolean` - 布尔值 |
| | | - `date` - 日期 |
| | | |
| | | --- |
| | | |
| | | ## 📋 工作流程 |
| | | |
| | | ### 标准开发流程 |
| | | |
| | | 1. **生成代码** |
| | | ```bash |
| | | python skill/art-design-pro/scripts/generate.py crud \ |
| | | --name "User" \ |
| | | --path "system/user" \ |
| | | --fields "username,email,status" |
| | | ``` |
| | | |
| | | 2. **复制代码到项目** |
| | | - 将输出的代码保存到对应文件 |
| | | - 创建目录结构:`src/views/system/user/` |
| | | |
| | | 3. **调整代码** |
| | | - 替换 `yourApiFunction` 为实际的 API 函数 |
| | | - 更新类型定义(`Api.YourModule.UserItem`) |
| | | - 调整字段映射和校验规则 |
| | | - 添加业务逻辑 |
| | | |
| | | 4. **测试页面** |
| | | - 访问页面验证功能 |
| | | - 测试增删改查操作 |
| | | - 检查样式和响应式 |
| | | |
| | | --- |
| | | |
| | | ## 💡 最佳实践 |
| | | |
| | | ### 1. 字段命名规范 |
| | | |
| | | 使用英文命名,代码生成器会自动生成中文标签: |
| | | |
| | | ```bash |
| | | # 推荐命名 |
| | | --fields "username,email,phone,status,created_at" |
| | | |
| | | # 自动生成的标签 |
| | | # username → 用户名 |
| | | # email → 邮箱 |
| | | # phone → 手机号 |
| | | # status → 状态 |
| | | # created_at → 创建时间 |
| | | ``` |
| | | |
| | | ### 2. 字段数量控制 |
| | | |
| | | - **搜索字段**:建议 3-5 个(最多显示前 5 个) |
| | | - **表格列**:建议 5-8 个(最多显示前 8 个) |
| | | - **表单字段**:不限制,按需添加 |
| | | |
| | | ### 3. 图表搭配 |
| | | |
| | | ```bash |
| | | # 分析类仪表板(趋势分析) |
| | | --charts "line,bar" |
| | | |
| | | # 概览类仪表板(数据分布) |
| | | --charts "pie,radar" |
| | | |
| | | # 综合仪表板 |
| | | --charts "line,bar,pie,radar" |
| | | ``` |
| | | |
| | | --- |
| | | |
| | | ## 🐛 常见问题 |
| | | |
| | | ### Q1: 生成的代码需要手动调整哪些地方? |
| | | |
| | | **A**:主要需要调整: |
| | | 1. API 函数:`yourApiFunction` → 实际的 API 函数 |
| | | 2. 类型定义:`Api.YourModule.UserItem` → 实际的类型定义 |
| | | 3. 字段校验规则:在弹窗组件的 `rules` 对象中添加 |
| | | 4. 业务逻辑:删除、提交等操作的实际实现 |
| | | |
| | | ### Q2: 如何添加自定义列? |
| | | |
| | | **A**:在生成代码后,修改 `columnsFactory` 中的列配置: |
| | | |
| | | ```js |
| | | { prop: 'status', label: '状态', |
| | | formatter: (row) => { |
| | | return h(ElTag, { type: row.status === '1' ? 'success' : 'danger' }, |
| | | () => row.status === '1' ? '启用' : '禁用') |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | ### Q3: 如何集成真实的 API? |
| | | |
| | | **A**:修改 `useTable` 中的 `apiFn` 参数: |
| | | |
| | | ```js |
| | | // 修改前 |
| | | apiFn: yourApiFunction |
| | | |
| | | // 修改后 |
| | | import { fetchGetUserList } from '@/api/system-manage' |
| | | |
| | | apiFn: fetchGetUserList |
| | | ``` |
| | | |
| | | ### Q4: 生成的代码不符合项目规范? |
| | | |
| | | **A**:代码生成器生成的代码符合 Art Design Pro 项目规范,如果需要调整: |
| | | 1. 修改生成器模板(`scripts/generate.py`) |
| | | 2. 或者生成代码后手动调整 |
| | | |
| | | --- |
| | | |
| | | ## 🚀 高级用法 |
| | | |
| | | ### 1. 批量生成页面 |
| | | |
| | | 使用 shell 脚本批量生成多个页面: |
| | | |
| | | ```bash |
| | | #!/bin/bash |
| | | # batch-generate.sh |
| | | |
| | | pages=( |
| | | "User:system/user:username,email,phone,status" |
| | | "Role:system/role:name,code,description" |
| | | "Permission:system/permission:name,code,type" |
| | | ) |
| | | |
| | | for page in "${pages[@]}"; do |
| | | IFS=':' read -r name path fields <<< "$page" |
| | | python skill/art-design-pro/scripts/generate.py crud \ |
| | | --name "$name" \ |
| | | --path "$path" \ |
| | | --fields "$fields" |
| | | done |
| | | ``` |
| | | |
| | | ### 2. 自定义模板 |
| | | |
| | | 修改 `scripts/generate.py` 中的模板字符串,自定义生成的代码风格。 |
| | | |
| | | ### 3. 集成到项目脚本 |
| | | |
| | | 在项目的 `package.json` 中添加快捷命令: |
| | | |
| | | ```json |
| | | { |
| | | "scripts": { |
| | | "generate:page": "python skill/art-design-pro/scripts/generate.py" |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | 使用: |
| | | ```bash |
| | | npm run generate:page crud --name "User" --path "system/user" --fields "username,email" |
| | | ``` |
| | | |
| | | --- |
| | | |
| | | ## 📊 效率对比 |
| | | |
| | | | 操作 | 手动编写 | 使用生成器 | 效率提升 | |
| | | |------|----------|------------|----------| |
| | | | CRUD 页面(3 个文件) | 60-90 分钟 | 5-10 分钟 | **600%-900%** | |
| | | | 基础表格页 | 20-30 分钟 | 2-5 分钟 | **400%-500%** | |
| | | | 仪表板页 | 40-60 分钟 | 5-10 分钟 | **400%-500%** | |
| | | |
| | | --- |
| | | |
| | | ## 📚 相关文档 |
| | | |
| | | - [useTable Hook 文档](https://www.artd.pro/docs/zh/guide/hooks/use-table.html) |
| | | - [ArtTable 组件文档](https://www.artd.pro/docs/zh/guide/components/art-table.html) |
| | | - [ArtSearchBar 组件文档](https://www.artd.pro/docs/zh/guide/components/art-search-bar.html) |
| | | - [项目示例页面](src/views/) |
| | | |
| | | --- |
| | | |
| | | **最后更新**:2026-03-03 |
| | | **维护者**:Art Design Pro Skill Team |
| | | |
| | | |
| | | |
| New file |
| | |
| | | # 介绍 | Art Design Pro |
| | | |
| | | 来源:https://www.artd.pro/docs/zh/guide/introduce.html |
| | | |
| | | ## 关于 Art Design Pro |
| | | |
| | | 作为一名开发者,我发现传统系统在用户体验和视觉设计上不能完全满足需求。这让我开始思考,如何设计一款既精致美观又能提供顺畅操作体验的后台系统。 |
| | | |
| | | 经过反复思考,我决定推出 Art Design Pro —— 它不仅融汇了最新的技术,更秉持着对完美用户体验和视觉设计的追求。 |
| | | |
| | | ## 解决的问题 |
| | | |
| | | ### 视觉疲劳与乏味 |
| | | |
| | | 长时间面对冷冰冰的界面,难免会让人感到疲惫。本模板力求通过精心设计的配色、排版和动效,让每一次点击、每一次滚动都像是一段美好的互动体验。 |
| | | |
| | | ### 用户体验不佳 |
| | | |
| | | 管理系统不仅要看得舒服,更要用得顺手。我希望通过简洁直观的布局和流畅自然的交互,让用户能迅速找到所需功能,享受到一种人性化的关怀。 |
| | | |
| | | ### 重复造轮子,开发成本高 |
| | | |
| | | 我都曾为从零开始设计一套完整的前端界面而头疼。这个模板提供了一整套经过反复打磨的 UI 组件和设计规范,让你可以快速搭建出既美观又实用的后台管理系统。 |
| | | |
| | | ## 核心特色 |
| | | |
| | | ### 美学与实用并重 |
| | | |
| | | 我相信技术和艺术可以并肩同行。模板采用了流行而不浮夸的设计风格,每一处细节都经过精心打磨,力求让界面既赏心悦目,又不会喧宾夺主。 |
| | | |
| | | ### 情感化的用户体验 |
| | | |
| | | 每一个交互动效,每一处按钮反馈,都是为了让用户在操作时感受到温暖与人性化。 |
| | | |
| | | ### 模块化、灵活定制 |
| | | |
| | | 我提供了高度模块化的设计,各个组件都可以根据实际需求自由调整。 |
| | | |
| | | ### 全响应式设计 |
| | | |
| | | 模板不仅适配各种屏幕尺寸,更通过精细的设计让不同设备下的用户都能获得一致而优质的体验。 |
| | | |
| | | ### 拒绝过度封装 |
| | | |
| | | 我坚持简洁和透明的原则,避免对系统进行过度封装。你可以轻松修改和扩展任意部分。 |
| | | |
| | | ## 技术栈 |
| | | |
| | | - **开发框架**:Vue3、JavaScript、Vite、Element-Plus、Scss |
| | | - **代码规范**:Eslint、Prettier、Stylelint、Husky、Lint-staged、cz-git |
| | | |
| | | ## 浏览器支持 |
| | | |
| | | Chrome、Edge、Firefox、Safari、Opera 等现代浏览器。 |
| | | |
| | | |
| New file |
| | |
| | | # 快速开始 | Art Design Pro |
| | | |
| | | 来源:https://www.artd.pro/docs/zh/guide/quick-start.html |
| | | |
| | | ## 准备工作 |
| | | |
| | | ### 环境要求 |
| | | |
| | | 确保 Node.js 满足以下要求: |
| | | - Node.js 20.19.0 及以上版本 |
| | | |
| | | ## 下载源码 |
| | | |
| | | **GitHub:** |
| | | ```bash |
| | | git clone https://github.com/Daymychen/art-design-pro |
| | | ``` |
| | | |
| | | **Gitee:** |
| | | ```bash |
| | | git clone https://gitee.com/lingchen163/art-design-pro |
| | | ``` |
| | | |
| | | ## 启动项目 |
| | | |
| | | 本项目使用 pnpm 工具安装依赖,推荐使用 pnpm |
| | | |
| | | ### 1. 安装 pnpm |
| | | |
| | | ```bash |
| | | npm install -g pnpm |
| | | # 或者 |
| | | yarn global add pnpm |
| | | ``` |
| | | |
| | | ### 2. 安装依赖 |
| | | |
| | | ```bash |
| | | pnpm install |
| | | ``` |
| | | |
| | | 如果 pnpm install 安装失败,尝试使用下面的命令: |
| | | ```bash |
| | | pnpm install --ignore-scripts |
| | | ``` |
| | | |
| | | ### 3. 运行项目 |
| | | |
| | | ```bash |
| | | pnpm dev |
| | | ``` |
| | | |
| | | 项目启动后会自动打开浏览器运行,默认访问地址:http://localhost:3006 |
| | | |
| | | |
| New file |
| | |
| | | # 开发必读文档 | Art Design Pro |
| | | |
| | | 来源:https://www.artd.pro/docs/zh/guide/must-read.html |
| | | |
| | | ## 接口对接 |
| | | |
| | | 默认返回以下格式,如需修改请到 `src/typings/http.d.ts` 文件修改: |
| | | |
| | | ```js |
| | | interface BaseResponse<T = unknown> { |
| | | code: number; // 状态码 |
| | | msg: string; // 消息 |
| | | data: T; // 数据 |
| | | } |
| | | ``` |
| | | |
| | | ## 网络请求 |
| | | |
| | | 默认返回 data 中的数据而不是整个响应体: |
| | | |
| | | ```js |
| | | try { |
| | | const { token, refreshToken } = await fetchLogin({ |
| | | userName: username, |
| | | password, |
| | | }); |
| | | } catch (error) { |
| | | if (error instanceof HttpError) { |
| | | // 这里可以根据状态码进行不同的处理 |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | ## 菜单数据(asyncRoutes.ts) |
| | | |
| | | - `RoutesAlias.Layout` 指向的是布局容器 |
| | | - 后端返回的菜单数据中,component 字段需要指向 `/index/index` |
| | | - `roles` 字段用于前端控制模式 |
| | | |
| | | **前端模式**:通过获取用户信息接口返回的 roles 跟菜单数据 asyncRoutes 中的 roles 进行对比实现菜单过滤 |
| | | |
| | | **后端模式**:直接通过接口返回对应角色的菜单即可,不需要返回 `roles` 字段 |
| | | |
| | | 示例: |
| | | ```js |
| | | { |
| | | name: 'Dashboard', |
| | | path: '/dashboard', |
| | | component: RoutesAlias.Layout, |
| | | meta: { |
| | | title: 'menus.dashboard.title', |
| | | icon: '', |
| | | roles: ['R_SUPER', 'R_ADMIN'] // 前端模式需要 |
| | | }, |
| | | children: [ |
| | | { |
| | | path: 'console', |
| | | name: 'Console', |
| | | component: RoutesAlias.Dashboard, |
| | | meta: { |
| | | title: 'menus.dashboard.console', |
| | | keepAlive: false, |
| | | fixedTab: true |
| | | } |
| | | } |
| | | ] |
| | | } |
| | | ``` |
| | | |
| | | ## 打包大小说明 |
| | | |
| | | - **完整版项目**:约 10MB |
| | | - **精简版项目**:约 5MB |
| | | |
| | | 项目默认开启 gzip 压缩,因此会额外生成 .gz 文件: |
| | | |
| | | 关闭 gzip 时,实际打包体积约 4.5MB |
| | | |
| | | 开启 gzip 后,产物体积更小(浏览器请求时会优先加载 .gz 文件) |
| | | |
| | | **进一步优化方案**: |
| | | |
| | | 若对体积有更高要求,可通过以下方式优化,可轻易降至 3.5MB 左右: |
| | | - 精简或替换图标库 |
| | | - 移除非必要图片资源 |
| | | - 减少第三方库依赖,或替换为更轻量的方案 |
| | | |
| | | |
| New file |
| | |
| | | # 规范 | Art Design Pro |
| | | |
| | | 来源:https://www.artd.pro/docs/zh/guide/project/standard.html |
| | | |
| | | ## 代码规范工具 |
| | | |
| | | - **Eslint**: JS 代码检查 |
| | | - **Prettier**: 代码格式化 |
| | | - **Stylelint**: CSS 代码检查 |
| | | - **Commitlint**: Git 提交信息检查 |
| | | - **Husky**: Git 钩子工具 |
| | | - **Lint-staged**: Git 提交前运行代码校验 |
| | | - **cz-git**: 可视化提交工具 |
| | | |
| | | ## 自动化 |
| | | |
| | | 代码提交会自动执行配置好的文件,自动完成代码校验和格式化。 |
| | | |
| | | `package.json` 配置: |
| | | |
| | | ```json |
| | | { |
| | | "lint-staged": { |
| | | "*.{js,ts}": [ |
| | | "eslint --fix", |
| | | "prettier --write" |
| | | ], |
| | | "*.{cjs,json}": [ |
| | | "prettier --write" |
| | | ], |
| | | "*.{vue,html}": [ |
| | | "eslint --fix", |
| | | "prettier --write", |
| | | "stylelint --fix" |
| | | ], |
| | | "*.{scss,css}": [ |
| | | "stylelint --fix", |
| | | "prettier --write" |
| | | ], |
| | | "*.md": [ |
| | | "prettier --write" |
| | | ] |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | ## 常用命令 |
| | | |
| | | ```bash |
| | | # 检查项目中的 js 语法 |
| | | pnpm lint |
| | | |
| | | # 修复项目中 js 语法错误 |
| | | pnpm fix |
| | | |
| | | # 使用 Prettier 格式化所有文件 |
| | | pnpm lint:prettier |
| | | |
| | | # 使用 Stylelint 检查和修复样式 |
| | | pnpm lint:stylelint |
| | | |
| | | # 运行 lint-staged 检查暂存文件 |
| | | pnpm lint:lint-staged |
| | | |
| | | # 设置 Husky Git 钩子 |
| | | pnpm prepare |
| | | |
| | | # 使用 Commitizen 规范化提交消息 |
| | | pnpm commit |
| | | ``` |
| | | |
| | | ## 提交规范 |
| | | |
| | | ### 提交类型 |
| | | |
| | | ```bash |
| | | feat // 新增功能 |
| | | fix // 修复缺陷 |
| | | docs // 文档变更 |
| | | style // 代码格式(不影响功能) |
| | | refactor // 代码重构(不包括 bug 修复、功能新增) |
| | | perf // 性能优化 |
| | | test // 添加疏漏测试或已有测试改动 |
| | | build // 构建流程、外部依赖变更 |
| | | ci // 修改 CI 配置、脚本 |
| | | revert // 回滚 commit |
| | | chore // 对构建过程或辅助工具的更改 |
| | | wip // 对构建过程或辅助工具的更改 |
| | | ``` |
| | | |
| | | ### 提交代码流程 |
| | | |
| | | ```bash |
| | | git add . |
| | | pnpm commit |
| | | # 选择提交类型,填写提交信息 |
| | | git push |
| | | ``` |
| | | |
| | | ## 注意事项 |
| | | |
| | | 1. **不要跳过 Husky 钩子**:使用 `pnpm commit` 而不是 `git commit` |
| | | 2. **提交前自动格式化**:Lint-staged 会自动格式化暂存的文件 |
| | | 3. **遵循提交规范**:使用 Commitizen 选择正确的提交类型 |
| | | |
| | | |
| New file |
| | |
| | | # 组件和图标基础 | Art Design Pro |
| | | |
| | | 本文档介绍 Art Design Pro 中组件库和图标的使用方法。 |
| | | |
| | | ## Element Plus 组件库 |
| | | |
| | | 项目使用 Element Plus 作为基础 UI 组件库,覆盖了 80% 的常用组件需求。 |
| | | |
| | | ### 自动导入配置 |
| | | |
| | | 项目通过 `unplugin-vue-components` 实现组件按需自动导入: |
| | | |
| | | ```js |
| | | // vite.config.js |
| | | Components({ |
| | | resolvers: [ |
| | | ElementPlusResolver(), |
| | | ], |
| | | dts: 'src/types/import/components.d.ts' |
| | | }) |
| | | ``` |
| | | |
| | | ### 可用组件 |
| | | |
| | | Element Plus 提供: |
| | | - **基础组件**:按钮、表单、表格等 |
| | | - **反馈组件**:弹窗、消息提示等 |
| | | - **布局组件**:容器、栅格系统等 |
| | | |
| | | **官方文档**:https://element-plus.org/ |
| | | |
| | | ### 样式定制 |
| | | |
| | | 样式文件路径:`src/assets/styles/el-ui.scss` |
| | | |
| | | ```scss |
| | | @forward 'element-plus/theme-chalk/src/common/var.scss' with ( |
| | | $colors: ( |
| | | 'primary': ( |
| | | 'base': #409EFF, |
| | | ), |
| | | ), |
| | | ); |
| | | ``` |
| | | |
| | | ## 系统内置组件 |
| | | |
| | | ### 图标相关 |
| | | - **图标选择器** - 可视化图标选择工具 |
| | | |
| | | ### 媒体处理 |
| | | - **图像裁剪** - 图片上传和裁剪组件 |
| | | - **视频播放器** - 视频播放组件 |
| | | |
| | | ### 数据处理 |
| | | - **Excel 导入导出** - 数据导入导出功能 |
| | | - **数字滚动** - 统计数字动画 |
| | | |
| | | ### 编辑器 |
| | | - **富文本编辑器** - 基于 wangEditor |
| | | |
| | | ### 特效组件 |
| | | - **水印** - 页面水印组件 |
| | | - **右键菜单** - 自定义右键菜单 |
| | | - **文字滚动** - 文字滚动效果 |
| | | - **礼花效果** - 节日特效 |
| | | |
| | | ### 其他 |
| | | - **二维码** - 二维码生成器 |
| | | - **拖拽** - 拖拽排序功能 |
| | | |
| | | ## 图标系统 |
| | | |
| | | 项目内置 600+ 图标,满足大部分图标需求。 |
| | | |
| | | ### 图标库目录 |
| | | |
| | | 图标资源目录:`src/assets/icons/system` |
| | | |
| | | ### 使用方式 |
| | | |
| | | #### Unicode 方式 |
| | | |
| | | ```html |
| | | <i class="iconfont-sys"></i> |
| | | ``` |
| | | |
| | | #### Font class 方式 |
| | | |
| | | ```html |
| | | <i class="iconfont-sys iconsys-gou"></i> |
| | | ``` |
| | | |
| | | ### 图标扩展 |
| | | |
| | | 如需添加自定义图标,可访问系统图标库进行扩展。 |
| | | |
| | | **注意**:系统图标使用 `iconfont-sys` 类名,而非 `iconfont`,方便用户扩展。 |
| | | |
| | | ## 系统模板 |
| | | |
| | | ### 卡片模板 |
| | | - 统计卡片 |
| | | - 数据展示卡片 |
| | | - 图表卡片 |
| | | |
| | | ### 横幅模板 |
| | | - 基础横幅 |
| | | - 卡片横幅 |
| | | |
| | | ### 图表模板 |
| | | - 柱状图 |
| | | - 折线图 |
| | | - 饼图 |
| | | - 雷达图 |
| | | - 地图图表 |
| | | |
| | | ### 其他模板 |
| | | - 地图页面 |
| | | - 聊天页面 |
| | | - 日历页面 |
| | | - 定价页面 |
| | | |
| | | ## 最佳实践 |
| | | |
| | | 1. **优先使用系统组件** - 系统组件已经过优化和测试 |
| | | 2. **参考官方文档** - Element Plus 官方文档提供完整 API |
| | | 3. **统一样式** - 保持整个项目的视觉一致性 |
| | | 4. **按需导入** - 使用自动导入减少打包体积 |
| | | |
| | | ## 相关文档 |
| | | |
| | | - [Element Plus 官方文档](https://element-plus.org/) |
| | | - [主题配置](./configuration-guide.md#主题配置) |
| | | - [环境变量](./configuration-guide.md#环境变量) |
| | | |
| | | |
| New file |
| | |
| | | # 系统配置指南 | Art Design Pro |
| | | |
| | | 本文档介绍 Art Design Pro 的各项系统配置,包括主题、环境变量和全局设置。 |
| | | |
| | | ## 环境变量配置 |
| | | |
| | | ### 环境文件说明 |
| | | |
| | | 项目根目录下的环境变量文件: |
| | | - `.env` - 适用于所有环境 |
| | | - `.env.development` - 仅适用于开发环境 |
| | | - `.env.production` - 仅适用于生产环境 |
| | | |
| | | ### 自定义环境变量 |
| | | |
| | | 自定义环境变量必须以 `VITE_` 开头。 |
| | | |
| | | **在代码中访问**: |
| | | ```js |
| | | console.log(import.meta.env.VITE_PORT); |
| | | ``` |
| | | |
| | | ### 环境配置示例 |
| | | |
| | | #### .env(通用配置) |
| | | |
| | | ```bash |
| | | # 版本号 |
| | | VITE_VERSION = 2.4.1.1 |
| | | |
| | | # 端口号 |
| | | VITE_PORT = 3006 |
| | | |
| | | # 网站地址前缀 |
| | | VITE_BASE_URL = /art-design-pro/ |
| | | |
| | | # API 地址前缀 |
| | | VITE_API_URL = https://m1.apifoxmock.com/m1/6400575-6097373-default |
| | | |
| | | # 权限模式(frontend | backend) |
| | | VITE_ACCESS_MODE = frontend |
| | | |
| | | # 跨域请求时是否携带 Cookie |
| | | VITE_WITH_CREDENTIALS = false |
| | | |
| | | # 是否打开路由信息 |
| | | VITE_OPEN_ROUTE_INFO = false |
| | | |
| | | # 锁屏加密密钥 |
| | | VITE_LOCK_ENCRYPT_KEY = jfsfjk1938jfj |
| | | ``` |
| | | |
| | | #### .env.development(开发环境) |
| | | |
| | | ```bash |
| | | # 网站地址前缀 |
| | | VITE_BASE_URL = / |
| | | |
| | | # API 请求基础路径(开发环境通常为代理前缀) |
| | | VITE_API_URL = /api |
| | | |
| | | # 本地开发代理的目标后端地址 |
| | | VITE_API_PROXY_URL = https://m1.apifoxmock.com/m1/6400575-6097373-default |
| | | |
| | | # Delete console |
| | | VITE_DROP_CONSOLE = false |
| | | ``` |
| | | |
| | | #### .env.production(生产环境) |
| | | |
| | | ```bash |
| | | # 网站地址前缀 |
| | | VITE_BASE_URL = /art-design-pro/ |
| | | |
| | | # API 地址前缀 |
| | | VITE_API_URL = https://m1.apifoxmock.com/m1/6400575-6097373-default |
| | | |
| | | # Delete console |
| | | VITE_DROP_CONSOLE = true |
| | | ``` |
| | | |
| | | ## 主题配置 |
| | | |
| | | ### CSS 主题变量 |
| | | |
| | | CSS 变量包括主题颜色、背景颜色、文字颜色、边框颜色、阴影等,能自适应 Light 和 Dark 模式。 |
| | | |
| | | **配置文件**:`src/assets/styles/variables.scss` |
| | | |
| | | ### 使用 CSS 变量 |
| | | |
| | | ```scss |
| | | // 文字颜色 |
| | | color: var(--art-gray-100); |
| | | color: var(--art-gray-900); |
| | | |
| | | // 边框 |
| | | border: 1px solid var(--art-border-color); |
| | | border: 1px solid var(--art-border-dashed-color); |
| | | |
| | | // 背景颜色 |
| | | background-color: var(--art-main-bg-color); |
| | | |
| | | // 阴影 |
| | | box-shadow: var(--art-box-shadow); |
| | | box-shadow: var(--art-box-shadow-xs); |
| | | |
| | | // 使用带透明度的颜色 |
| | | color: rgba(var(--art-gray-800-rgb), 0.6); |
| | | |
| | | // 主题色 |
| | | color: var(--main-color); |
| | | background-color: var(--el-color-primary-light-1); |
| | | ``` |
| | | |
| | | ### Light 主题变量 |
| | | |
| | | ```scss |
| | | :root { |
| | | // Theme color |
| | | --art-primary: 93, 135, 255; |
| | | --art-secondary: 73, 190, 255; |
| | | --art-error: 250, 137, 107; |
| | | --art-info: 83, 155, 255; |
| | | --art-success: 19, 222, 185; |
| | | --art-warning: 255, 174, 31; |
| | | --art-danger: 255, 77, 79; |
| | | |
| | | // Background color |
| | | --art-gray-100: #f9f9f9; |
| | | --art-gray-200: #f1f1f4; |
| | | --art-gray-300: #dbdfe9; |
| | | --art-gray-400: #c4cada; |
| | | --art-gray-500: #99a1b7; |
| | | --art-gray-600: #78829d; |
| | | --art-gray-700: #4b5675; |
| | | --art-gray-800: #252f4a; |
| | | --art-gray-900: #071437; |
| | | |
| | | // Border |
| | | --art-border-color: #eaebf1; |
| | | --art-border-dashed-color: #dbdfe9; |
| | | |
| | | // Shadow |
| | | --art-box-shadow-xs: 0 0.1rem 0.75rem 0.25rem rgba(0, 0, 0, 0.05); |
| | | --art-box-shadow-sm: 0 0.1rem 1rem 0.25rem rgba(0, 0, 0, 0.05); |
| | | --art-box-shadow: 0 0.5rem 1.5rem 0.5rem rgba(0, 0, 0, 0.075); |
| | | --art-box-shadow-lg: 0 1rem 2rem 1rem rgba(0, 0, 0, 0.1); |
| | | |
| | | // Background |
| | | --art-bg-color: #fafbfc; |
| | | --art-main-bg-color: #ffffff; |
| | | } |
| | | ``` |
| | | |
| | | ### Dark 主题变量 |
| | | |
| | | ```scss |
| | | html.dark { |
| | | // Theme color |
| | | --art-primary: 93, 135, 255; |
| | | |
| | | // Background color |
| | | --art-gray-100: #1b1c22; |
| | | --art-gray-200: #26272f; |
| | | --art-gray-300: #363843; |
| | | --art-gray-400: #464852; |
| | | --art-gray-500: #636674; |
| | | --art-gray-600: #808290; |
| | | --art-gray-700: #9a9cae; |
| | | --art-gray-800: #b5b7c8; |
| | | --art-gray-900: #f5f5f5; |
| | | |
| | | // Border |
| | | --art-border-color: #26272f; |
| | | --art-border-dashed-color: #363843; |
| | | |
| | | // Background |
| | | --art-bg-color: #070707; |
| | | --art-main-bg-color: #161618; |
| | | } |
| | | ``` |
| | | |
| | | ### 媒体查询(设备尺寸) |
| | | |
| | | ```scss |
| | | $device-notebook: 1600px; // notebook |
| | | $device-ipad-pro: 1180px; // ipad pro |
| | | $device-ipad: 800px; // ipad |
| | | $device-ipad-vertical: 900px; // ipad-竖屏 |
| | | $device-phone: 500px; // mobile |
| | | ``` |
| | | |
| | | ## 系统全局配置 |
| | | |
| | | ### 系统 Logo 配置 |
| | | |
| | | **配置文件**:`src/components/core/base/ArtLogo.vue` |
| | | |
| | | ```vue |
| | | <template> |
| | | <div class="art-logo"> |
| | | <img :style="logoStyle" src="@imgs/common/logo.png" alt="logo" /> |
| | | </div> |
| | | </template> |
| | | ``` |
| | | |
| | | 如需更换 Logo,只需修改图片资源路径即可。 |
| | | |
| | | ### 系统名称配置 |
| | | |
| | | **配置文件**:`src/config/index.ts` |
| | | |
| | | ```js |
| | | const appConfig: SystemConfig = { |
| | | systemInfo: { |
| | | name: "Art Design Pro", // 系统名称 |
| | | }, |
| | | }; |
| | | ``` |
| | | |
| | | ### 全局配置详细 |
| | | |
| | | **配置文件路径**:`src/config/setting.ts` |
| | | |
| | | ```js |
| | | const appConfig: SystemConfig = { |
| | | // 系统信息 |
| | | systemInfo: { |
| | | name: "Art Design Pro", |
| | | }, |
| | | |
| | | // 系统主题列表 |
| | | settingThemeList: [ |
| | | { |
| | | name: "Light", |
| | | theme: SystemThemeEnum.LIGHT, |
| | | color: ["#fff", "#fff"], |
| | | }, |
| | | { |
| | | name: "Dark", |
| | | theme: SystemThemeEnum.DARK, |
| | | color: ["#22252A"], |
| | | }, |
| | | { |
| | | name: "System", |
| | | theme: SystemThemeEnum.AUTO, |
| | | color: ["#fff", "#22252A"], |
| | | }, |
| | | ], |
| | | |
| | | // 菜单布局列表 |
| | | menuLayoutList: [ |
| | | { name: "Left", value: MenuTypeEnum.LEFT }, |
| | | { name: "Top", value: MenuTypeEnum.TOP }, |
| | | { name: "Mixed", value: MenuTypeEnum.TOP_LEFT }, |
| | | { name: "Dual Column", value: MenuTypeEnum.DUAL_MENU }, |
| | | ], |
| | | |
| | | // 系统主色 |
| | | systemMainColor: [ |
| | | "#5D87FF", // 主色 |
| | | "#B48DF3", // 紫色 |
| | | "#1D84FF", // 蓝色 |
| | | "#60C041", // 绿色 |
| | | "#38C0FC", // 青色 |
| | | "#F9901F", // 橙色 |
| | | "#FF80C8", // 粉色 |
| | | ] as const, |
| | | |
| | | // 系统其他项默认配置 |
| | | systemSetting: { |
| | | defaultMenuWidth: 240, // 菜单宽度 |
| | | defaultCustomRadius: "0.75", // 自定义圆角 |
| | | defaultTabStyle: "tab-default", // 标签样式 |
| | | }, |
| | | }; |
| | | ``` |
| | | |
| | | ## 相关文档 |
| | | |
| | | - [组件和图标基础](./components-basics.md) |
| | | - [项目结构](../core-concepts/project-structure.md) |
| | | - [集成指南](../../INTEGRATION_GUIDE.md) |
| | | |
| | | |
| New file |
| | |
| | | # 样式规范 | Art Design Pro |
| | | |
| | | **⚠️ 重要:严格执行本规范,确保 UI 统一和避免样式冲突** |
| | | |
| | | ## 📋 核心原则 |
| | | |
| | | 1. **优先使用 Tailwind CSS 工具类** |
| | | 2. **使用项目定义的 CSS 变量** |
| | | 3. **禁止编写自定义样式** |
| | | 4. **保持响应式设计** |
| | | |
| | | --- |
| | | |
| | | ## 🎨 Tailwind CSS 工具类 |
| | | |
| | | ### 布局 |
| | | |
| | | ```vue |
| | | <!-- ✅ 推荐 --> |
| | | <div class="flex flex-wrap gap-4"> |
| | | <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3"> |
| | | <div class="flex items-center justify-between"> |
| | | |
| | | <!-- ❌ 禁止 --> |
| | | <div class="my-layout" style="display: flex; gap: 16px;"> |
| | | ``` |
| | | |
| | | ### 间距 |
| | | |
| | | ```vue |
| | | <!-- ✅ 推荐 --> |
| | | <div class="p-4"> padding: 1rem (16px) </div> |
| | | <div class="px-4 py-2"> 水平和垂直 </div> |
| | | <div class="m-4"> margin: 1rem </div> |
| | | <div class="mt-2 mb-4"> 上下边距 </div> |
| | | <div class="gap-4"> flex/grid gap </div> |
| | | |
| | | <!-- Tailwind 间距表 --> |
| | | <!-- p-0: 0, p-1: 4px, p-2: 8px, p-3: 12px, p-4: 16px, p-5: 20px, p-6: 24px --> |
| | | <!-- px/x: 水平, py/y: 垂直, m: margin, p: padding --> |
| | | ``` |
| | | |
| | | ### 尺寸 |
| | | |
| | | ```vue |
| | | <!-- ✅ 推荐 --> |
| | | <div class="w-full"> width: 100% </div> |
| | | <div class="w-1/2"> width: 50% </div> |
| | | <div class="w-64"> width: 16rem (256px) </div> |
| | | <div class="h-screen"> height: 100vh </div> |
| | | |
| | | <!-- 响应式宽度 --> |
| | | <div class="w-full md:w-1/2 lg:w-1/3"> |
| | | ``` |
| | | |
| | | ### 文本 |
| | | |
| | | ```vue |
| | | <!-- ✅ 推荐 --> |
| | | <div class="text-sm"> 小字体 </div> |
| | | <div class="text-base"> 正常字体 </div> |
| | | <div class="text-lg"> 大字体 </div> |
| | | <div class="font-bold"> 粗体 </div> |
| | | <div class="text-center"> 居中对齐 </div> |
| | | ``` |
| | | |
| | | ### 颜色(使用 CSS 变量) |
| | | |
| | | ```vue |
| | | <!-- ✅ 推荐:使用 CSS 变量 --> |
| | | <div class="text-[var(--art-gray-800)]"> |
| | | <div style="color: var(--art-gray-800)"> |
| | | |
| | | <!-- ❌ 禁止:硬编码颜色 --> |
| | | <div class="text-[#333]"> |
| | | <div style="color: #333"> |
| | | ``` |
| | | |
| | | --- |
| | | |
| | | ## 🎨 CSS 变量 |
| | | |
| | | ### 颜色变量 |
| | | |
| | | ```scss |
| | | // 文字颜色 |
| | | color: var(--art-gray-100); // 浅色 |
| | | color: var(--art-gray-800); // 深色 |
| | | color: var(--art-primary); // 主色 |
| | | |
| | | // 背景颜色 |
| | | background-color: var(--art-bg-color); |
| | | background-color: var(--art-main-bg-color); |
| | | |
| | | // 边框颜色 |
| | | border: 1px solid var(--art-border-color); |
| | | border: 1px solid var(--art-border-dashed-color); |
| | | |
| | | // 阴影 |
| | | box-shadow: var(--art-box-shadow); |
| | | box-shadow: var(--art-box-shadow-sm); |
| | | ``` |
| | | |
| | | ### 主题颜色 |
| | | |
| | | ```scss |
| | | // 主题色 |
| | | color: var(--main-color); |
| | | background-color: var(--el-color-primary-light-1); // 最深 |
| | | background-color: var(--el-color-primary-light-9); // 最浅 |
| | | ``` |
| | | |
| | | --- |
| | | |
| | | ## 📱 响应式设计 |
| | | |
| | | ### 断点系统 |
| | | |
| | | ```vue |
| | | <!-- ✅ 推荐:使用 Tailwind 响应式前缀 --> |
| | | <div class="px-4 md:px-6 lg:px-8"> |
| | | <div class="w-full md:w-1/2 lg:w-1/3"> |
| | | |
| | | <!-- 移动端优先:默认样式是移动端 --> |
| | | <div class="flex-col md:flex-row"> |
| | | <!-- 移动端:垂直排列 --> |
| | | <!-- 中等及以上:水平排列 --> |
| | | </div> |
| | | ``` |
| | | |
| | | ### 断点表 |
| | | |
| | | | 前缀 | 屏幕 | 宽度 | 设备 | |
| | | |------|------|------|------| |
| | | | (无) | 默认 | 0px+ | 移动端 | |
| | | | `sm:` | 小屏 | 640px+ | 手机横屏 | |
| | | | `md:` | 中屏 | 768px+ | 平板竖屏 | |
| | | | `lg:` | 大屏 | 1024px+ | 平板横屏 | |
| | | | `xl:` | 超大屏 | 1280px+ | 笔记本 | |
| | | | `2xl:` | 超超大 | 1536px+ | 桌面 | |
| | | |
| | | ### 响应式示例 |
| | | |
| | | ```vue |
| | | <!-- 容器:移动端全宽,平板一半,桌面三分之一 --> |
| | | <div class="w-full md:w-1/2 lg:w-1/3"> |
| | | |
| | | <!-- 布局:移动端垂直,桌面水平 --> |
| | | <div class="flex flex-col md:flex-row gap-4"> |
| | | |
| | | <!-- 显示:移动端隐藏,桌面显示 --> |
| | | <div class="hidden md:block"> |
| | | |
| | | <!-- 间距:移动端小,桌面大 --> |
| | | <div class="p-2 md:p-4 lg:p-6"> |
| | | ``` |
| | | |
| | | --- |
| | | |
| | | ## ❌ 禁止行为 |
| | | |
| | | ### 绝对禁止 |
| | | |
| | | 1. **❌ 编写自定义 CSS 类** |
| | | ```vue |
| | | <!-- ❌ 禁止 --> |
| | | <style scoped> |
| | | .my-custom-class { |
| | | padding: 16px; |
| | | color: red; |
| | | } |
| | | </style> |
| | | ``` |
| | | |
| | | 2. **❌ 使用内联 style(除非动态绑定)** |
| | | ```vue |
| | | <!-- ❌ 禁止 --> |
| | | <div style="padding: 16px; color: red;"> |
| | | |
| | | <!-- ✅ 允许:动态绑定 --> |
| | | <div :style="{ color: dynamicColor }"> |
| | | ``` |
| | | |
| | | 3. **❌ 硬编码颜色值** |
| | | ```scss |
| | | /* ❌ 禁止 */ |
| | | color: #333; |
| | | background: #fff; |
| | | border: 1px solid #ddd; |
| | | |
| | | /* ✅ 推荐 */ |
| | | color: var(--art-gray-800); |
| | | background: var(--art-main-bg-color); |
| | | border: 1px solid var(--art-border-color); |
| | | ``` |
| | | |
| | | 4. **❌ 覆盖 Element Plus 默认样式** |
| | | ```vue |
| | | <!-- ❌ 禁止:覆盖组件样式 --> |
| | | <style> |
| | | .el-button { |
| | | background: red !important; |
| | | } |
| | | </style> |
| | | |
| | | <!-- ✅ 推荐:使用组件 props --> |
| | | <el-button type="danger">删除</el-button> |
| | | ``` |
| | | |
| | | 5. **❌ 引入外部样式库** |
| | | ```vue |
| | | <!-- ❌ 禁止:除 Tailwind 和 Element Plus 外 --> |
| | | <link href="bootstrap.css" rel="stylesheet"> |
| | | ``` |
| | | |
| | | 6. **❌ 编写 !important** |
| | | ```scss |
| | | /* ❌ 禁止(除非修复组件库问题) */ |
| | | .my-class { |
| | | color: red !important; |
| | | } |
| | | ``` |
| | | |
| | | --- |
| | | |
| | | ## ✅ 推荐做法 |
| | | |
| | | ### 1. 使用 Tailwind 工具类 |
| | | |
| | | ```vue |
| | | <template> |
| | | <div class="p-4 flex flex-wrap gap-4 border border-gray-200 rounded"> |
| | | <div class="flex-1 min-w-0"> |
| | | <div class="text-sm font-medium text-gray-700">标题</div> |
| | | <div class="mt-2 text-gray-600">内容</div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | ``` |
| | | |
| | | ### 2. 使用组件 props 控制样式 |
| | | |
| | | ```vue |
| | | <template> |
| | | <!-- ✅ 使用 Element Plus 的 props --> |
| | | <el-button type="primary" size="large" round> |
| | | 按钮 |
| | | </el-button> |
| | | |
| | | <!-- ✅ 使用 Art Design Pro 组件的 props --> |
| | | <art-table :border="true" :stripe="true"> |
| | | </art-table> |
| | | </template> |
| | | ``` |
| | | |
| | | ### 3. 动态样式绑定 |
| | | |
| | | ```vue |
| | | <template> |
| | | <!-- ✅ 动态样式使用 CSS 变量 --> |
| | | <div :style="{ color: statusColor }"> |
| | | </div> |
| | | |
| | | <!-- ✅ 动态 class --> |
| | | <div :class="['base-class', { 'active': isActive }]"> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | const statusColor = computed(() => { |
| | | return props.status === 'success' |
| | | ? 'var(--art-success)' |
| | | : 'var(--art-error)' |
| | | }) |
| | | </script> |
| | | ``` |
| | | |
| | | ### 4. 响应式布局 |
| | | |
| | | ```vue |
| | | <template> |
| | | <!-- ✅ 移动端优先 --> |
| | | <div class="flex flex-col md:flex-row gap-4 p-4"> |
| | | <div class="w-full md:w-1/2 lg:w-1/3"> |
| | | 移动端全宽,平板一半,桌面三分之一 |
| | | </div> |
| | | </div> |
| | | </template> |
| | | ``` |
| | | |
| | | --- |
| | | |
| | | ## 🎨 常用 Tailwind 类速查 |
| | | |
| | | ### 布局 |
| | | - `flex`, `flex-col`, `flex-wrap` |
| | | - `items-center`, `justify-between` |
| | | - `gap-2`, `gap-4`, `gap-6` |
| | | |
| | | ### 间距 |
| | | - `p-2` ~ `p-6`: padding |
| | | - `px-4`, `py-2`: 水平/垂直 padding |
| | | - `m-2` ~ `m-6`: margin |
| | | - `mt-2`, `mb-4`: 上下 margin |
| | | |
| | | ### 尺寸 |
| | | - `w-full`: width 100% |
| | | - `w-1/2`, `w-1/3`: 百分比宽度 |
| | | - `h-screen`: height 100vh |
| | | - `max-w-full`: 最大宽度 100% |
| | | |
| | | ### 文本 |
| | | - `text-sm`, `text-base`, `text-lg`: 字体大小 |
| | | - `font-bold`, `font-medium`: 字重 |
| | | - `text-center`, `text-right`: 对齐 |
| | | |
| | | ### 显示 |
| | | - `hidden`: 隐藏 |
| | | - `block`, `inline-block`: 显示类型 |
| | | - `md:block`: 中等及以上显示 |
| | | |
| | | --- |
| | | |
| | | ## 📝 代码审查清单 |
| | | |
| | | 在提交代码前,确认: |
| | | |
| | | - [ ] 所有样式使用 Tailwind CSS 工具类 |
| | | - [ ] 颜色使用 CSS 变量(`var(--art-*)`) |
| | | - [ ] 没有自定义 `<style scoped>` 块 |
| | | - [ ] 没有内联 `style` 属性(除非动态绑定) |
| | | - [ ] 响应式使用断点前缀(`md:`, `lg:` 等) |
| | | - [ ] 没有硬编码颜色值 |
| | | - [ ] 没有覆盖 Element Plus 样式 |
| | | - [ ] 使用组件 props 而非样式覆盖 |
| | | |
| | | --- |
| | | |
| | | **违反本规范可能导致**: |
| | | - ❌ UI 不统一 |
| | | - ❌ 样式冲突 |
| | | - ❌ 主题切换失效 |
| | | - ❌ 响应式布局错误 |
| | | - ❌ 代码审查不通过 |
| | | |
| | | --- |
| | | |
| | | **遵循本规范的好处**: |
| | | - ✅ 保持设计一致性 |
| | | - ✅ 减少样式冲突 |
| | | - ✅ 代码更易维护 |
| | | - ✅ 主题自动适配 |
| | | - ✅ 响应式开箱即用 |
| | | |
| | | |
| New file |
| | |
| | | # useTable Hook 文档 |
| | | |
| | | ## 概述 |
| | | |
| | | `useTable` 是 Art Design Pro 的核心 Hook,提供了表格数据管理、分页、搜索、刷新等完整功能。 |
| | | |
| | | ## 基础用法 |
| | | |
| | | ```js |
| | | import { useTable } from '@/hooks/core/useTable' |
| | | |
| | | const { |
| | | data, // 表格数据 |
| | | columns, // 列配置 |
| | | loading, // 加载状态 |
| | | pagination, // 分页信息 |
| | | getData, // 获取数据 |
| | | searchParams, // 搜索参数 |
| | | resetSearchParams, // 重置搜索参数 |
| | | handleSizeChange, // 每页数量变化 |
| | | handleCurrentChange, // 当前页变化 |
| | | refreshData // 刷新数据 |
| | | } = useTable({ |
| | | core: { |
| | | apiFn: fetchGetUserList, |
| | | apiParams: { |
| | | current: 1, |
| | | size: 20 |
| | | }, |
| | | columnsFactory: () => [...] |
| | | } |
| | | }) |
| | | ``` |
| | | |
| | | ## 配置参数 |
| | | |
| | | ### core 配置 |
| | | |
| | | | 参数 | 类型 | 必填 | 说明 | |
| | | |------|------|------|------| |
| | | | apiFn | Function | ✅ | API 请求函数 | |
| | | | apiParams | Object | ✅ | API 请求参数 | |
| | | | columnsFactory | Function | ✅ | 列配置工厂函数 | |
| | | | paginationKey | Object | ❌ | 分页字段映射 | |
| | | |
| | | ### transform 配置(数据转换) |
| | | |
| | | | 参数 | 类型 | 说明 | |
| | | |------|------|------| |
| | | | dataTransformer | Function | 数据转换器 | |
| | | | responseTransformer | Function | 响应转换器 | |
| | | |
| | | ## API 函数要求 |
| | | |
| | | `apiFn` 必须返回一个 Promise,接收分页参数: |
| | | |
| | | ```js |
| | | // API 函数示例 |
| | | export async function fetchGetUserList(params: { |
| | | current: number |
| | | size: number |
| | | [key: string]: any |
| | | }) { |
| | | return request.get<ApiResponseType<UserListItem[]>('/api/users', { |
| | | params |
| | | }) |
| | | } |
| | | |
| | | // 响应格式要求 |
| | | interface ApiResponseType<T> { |
| | | current: number // 当前页 |
| | | size: number // 每页数量 |
| | | total: number // 总记录数 |
| | | records: T[] // 数据列表 |
| | | } |
| | | ``` |
| | | |
| | | ## 列配置 |
| | | |
| | | ### 基础列配置 |
| | | |
| | | ```js |
| | | columnsFactory: () => [ |
| | | { |
| | | prop: 'id', // 数据字段名 |
| | | label: 'ID', // 列标题 |
| | | width: 100, // 列宽度(固定) |
| | | sortable: true // 是否可排序 |
| | | } |
| | | ] |
| | | ``` |
| | | |
| | | ### ⚠️ 列宽配置原则(重要) |
| | | |
| | | **原则**:避免所有列都使用固定 `width`,否则会导致表格总宽度固定,屏幕更宽时右侧出现空白。 |
| | | |
| | | | 列类型 | 推荐配置 | 说明 | 示例 | |
| | | |--------|----------|------|------| |
| | | | **固定内容列** | `width: 80-120` | 状态、角色、端口等 | `{ width: 80 }` | |
| | | | **操作列** | `width: 150-260` | 编辑、删除等操作按钮 | `{ width: 200, fixed: 'right' }` | |
| | | | **可变内容列** | `minWidth: 100-200` | 用户名、备注、域名等 | `{ minWidth: 120 }` | |
| | | | **时间日期列** | `minWidth: 150-180` | 创建时间、更新时间等 | `{ minWidth: 160 }` | |
| | | |
| | | **✅ 正确示例**: |
| | | ```js |
| | | columnsFactory: () => [ |
| | | { prop: 'username', label: '用户名', minWidth: 120 }, // 自动扩展 |
| | | { prop: 'remark', label: '备注', minWidth: 150 }, // 自动扩展 |
| | | { prop: 'status', label: '状态', width: 80 }, // 固定宽度 |
| | | { prop: 'created_at', label: '创建时间', minWidth: 160 }, // 自动扩展 |
| | | { prop: 'action', label: '操作', width: 200, fixed: 'right' } // 固定宽度 |
| | | ] |
| | | ``` |
| | | |
| | | **❌ 错误示例**: |
| | | ```js |
| | | // ❌ 所有列都使用固定 width - 会导致右侧空白 |
| | | columnsFactory: () => [ |
| | | { prop: 'username', label: '用户名', width: 150 }, |
| | | { prop: 'remark', label: '备注', width: 200 }, |
| | | { prop: 'status', label: '状态', width: 100 } |
| | | ] |
| | | ``` |
| | | |
| | | ### 自定义列渲染 |
| | | |
| | | 使用 `formatter` 函数自定义列内容: |
| | | |
| | | ```js |
| | | { |
| | | prop: 'status', |
| | | label: '状态', |
| | | formatter: (row: UserItem, column: any, value: any, index: number) => { |
| | | return h(ElTag, { type: 'success' }, () => '启用') |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | ### 复杂列渲染 |
| | | |
| | | 组合多个组件: |
| | | |
| | | ```js |
| | | { |
| | | prop: 'userInfo', |
| | | label: '用户信息', |
| | | width: 280, |
| | | formatter: (row) => { |
| | | return h('div', { class: 'user flex-c' }, [ |
| | | h(ElImage, { |
| | | class: 'size-9.5 rounded-md', |
| | | src: row.avatar, |
| | | previewSrcList: [row.avatar], |
| | | previewTeleported: true |
| | | }), |
| | | h('div', { class: 'ml-2' }, [ |
| | | h('p', { class: 'user-name' }, row.userName), |
| | | h('p', { class: 'email' }, row.userEmail) |
| | | ]) |
| | | ]) |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | ## 特殊列类型 |
| | | |
| | | ### 选择列 |
| | | |
| | | ```js |
| | | { type: 'selection' } |
| | | ``` |
| | | |
| | | ### 序号列 |
| | | |
| | | ```js |
| | | { |
| | | type: 'index', |
| | | width: 60, |
| | | label: '序号' |
| | | } |
| | | ``` |
| | | |
| | | ### 操作列 |
| | | |
| | | ```js |
| | | { |
| | | prop: 'operation', |
| | | label: '操作', |
| | | width: 120, |
| | | fixed: 'right', |
| | | formatter: (row) => |
| | | h('div', [ |
| | | h(ArtButtonTable, { |
| | | type: 'edit', |
| | | onClick: () => handleEdit(row) |
| | | }), |
| | | h(ArtButtonTable, { |
| | | type: 'delete', |
| | | onClick: () => handleDelete(row) |
| | | }) |
| | | ]) |
| | | } |
| | | ``` |
| | | |
| | | ## 分页配置 |
| | | |
| | | ### 默认分页字段 |
| | | |
| | | ```js |
| | | // 默认分页参数名 |
| | | apiParams: { |
| | | current: 1, // 当前页 |
| | | size: 20 // 每页数量 |
| | | } |
| | | |
| | | // 默认响应字段名 |
| | | { |
| | | current: number // 当前页 |
| | | size: number // 每页数量 |
| | | total: number // 总记录数 |
| | | } |
| | | ``` |
| | | |
| | | ### 自定义分页字段映射 |
| | | |
| | | 如果 API 字段名不同,可以自定义映射: |
| | | |
| | | ```js |
| | | useTable({ |
| | | core: { |
| | | apiFn: fetchList, |
| | | apiParams: { |
| | | current: 1, |
| | | size: 20 |
| | | }, |
| | | paginationKey: { |
| | | current: 'pageNum', // 请求参数中的当前页字段 |
| | | size: 'pageSize' // 请求参数中的每页数量字段 |
| | | } |
| | | } |
| | | }) |
| | | ``` |
| | | |
| | | ## 数据转换 |
| | | |
| | | ### 数据转换器 |
| | | |
| | | 转换 API 返回的数据: |
| | | |
| | | ```js |
| | | useTable({ |
| | | core: {...}, |
| | | transform: { |
| | | dataTransformer: (records) => { |
| | | return records.map(item => ({ |
| | | ...item, |
| | | avatar: item.avatar || '/default-avatar.png' |
| | | })) |
| | | } |
| | | } |
| | | }) |
| | | ``` |
| | | |
| | | ### 响应转换器 |
| | | |
| | | 转换整个响应: |
| | | |
| | | ```js |
| | | useTable({ |
| | | core: {...}, |
| | | transform: { |
| | | responseTransformer: (response) => { |
| | | return { |
| | | current: response.data.page, |
| | | size: response.data.pageSize, |
| | | total: response.data.total, |
| | | records: response.data.list |
| | | } |
| | | } |
| | | } |
| | | }) |
| | | ``` |
| | | |
| | | ## 搜索功能 |
| | | |
| | | ### 搜索参数 |
| | | |
| | | ```js |
| | | const searchForm = ref({ |
| | | username: '', |
| | | status: '1' |
| | | }) |
| | | |
| | | const { searchParams, getData } = useTable({ |
| | | core: { |
| | | apiFn: fetchGetUserList, |
| | | apiParams: { |
| | | current: 1, |
| | | size: 20, |
| | | ...searchForm.value |
| | | }, |
| | | columnsFactory: () => [...] |
| | | } |
| | | }) |
| | | |
| | | // 执行搜索 |
| | | const handleSearch = (params) => { |
| | | Object.assign(searchParams, params) |
| | | getData() |
| | | } |
| | | ``` |
| | | |
| | | ### 重置搜索 |
| | | |
| | | ```js |
| | | const resetSearchParams = () => { |
| | | searchParams.value = { |
| | | username: '', |
| | | status: '1' |
| | | } |
| | | getData() |
| | | } |
| | | ``` |
| | | |
| | | ## 刷新数据 |
| | | |
| | | ```js |
| | | const refreshData = () => { |
| | | getData() |
| | | } |
| | | ``` |
| | | |
| | | ## 完整示例 |
| | | |
| | | ### 基础表格 |
| | | |
| | | ```js |
| | | const { data, columns, loading, pagination } = useTable({ |
| | | core: { |
| | | apiFn: fetchGetUserList, |
| | | apiParams: { |
| | | current: 1, |
| | | size: 20 |
| | | }, |
| | | columnsFactory: () => [ |
| | | { type: 'selection' }, |
| | | { type: 'index', width: 60, label: '序号' }, |
| | | { prop: 'id', label: 'ID' }, |
| | | { prop: 'username', label: '用户名' }, |
| | | { prop: 'email', label: '邮箱' } |
| | | ] |
| | | } |
| | | }) |
| | | ``` |
| | | |
| | | ### 带搜索的表格 |
| | | |
| | | ```vue |
| | | <template> |
| | | <div class="user-page art-full-height"> |
| | | <ArtSearchBar v-model="searchForm" @search="handleSearch" /> |
| | | |
| | | <ElCard class="art-table-card"> |
| | | <ArtTableHeader v-model:columns="columns" :loading="loading" /> |
| | | <ArtTable |
| | | :loading="loading" |
| | | :data="data" |
| | | :columns="columns" |
| | | :pagination="pagination" |
| | | fit |
| | | @pagination:current-change="handleCurrentChange" |
| | | /> |
| | | </ElCard> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useTable } from '@/hooks/core/useTable' |
| | | |
| | | const searchForm = ref({ |
| | | username: '', |
| | | status: '1' |
| | | }) |
| | | |
| | | const { |
| | | data, |
| | | columns, |
| | | loading, |
| | | pagination, |
| | | searchParams, |
| | | getData, |
| | | handleCurrentChange |
| | | } = useTable({ |
| | | core: { |
| | | apiFn: fetchGetUserList, |
| | | apiParams: { |
| | | current: 1, |
| | | size: 20, |
| | | ...searchForm.value |
| | | }, |
| | | columnsFactory: () => [...] |
| | | } |
| | | }) |
| | | |
| | | const handleSearch = (params) => { |
| | | Object.assign(searchParams, params) |
| | | getData() |
| | | } |
| | | </script> |
| | | ``` |
| | | |
| | | ## API 参考 |
| | | |
| | | ### 返回值 |
| | | |
| | | | 属性 | 类型 | 说明 | |
| | | |------|------|------| |
| | | | data | Ref<T[]> | 表格数据 | |
| | | | columns | Ref<Column[]> | 列配置 | |
| | | | loading | Ref<boolean> | 加载状态 | |
| | | | pagination | Ref<Pagination> | 分页信息 | |
| | | | getData | Function | 获取数据方法 | |
| | | | searchParams | Ref<Object> | 搜索参数 | |
| | | | resetSearchParams | Function | 重置搜索参数 | |
| | | | handleSizeChange | Function | 每页数量变化处理 | |
| | | | handleCurrentChange | Function | 当前页变化处理 | |
| | | | refreshData | Function | 刷新数据 | |
| | | |
| | | ### Pagination 类型 |
| | | |
| | | ```js |
| | | interface Pagination { |
| | | current: number // 当前页 |
| | | size: number // 每页数量 |
| | | total: number // 总记录数 |
| | | } |
| | | ``` |
| | | |
| | | ## 最佳实践 |
| | | |
| | | ### 1. 使用 columnsFactory |
| | | |
| | | 使用工厂函数确保每次获取最新的列配置: |
| | | |
| | | ```js |
| | | // ✅ 推荐 |
| | | columnsFactory: () => [ |
| | | { prop: 'id', label: 'ID' } |
| | | ] |
| | | |
| | | // ❌ 不推荐 |
| | | columns: [ |
| | | { prop: 'id', label: 'ID' } |
| | | ] |
| | | ``` |
| | | |
| | | ### 2. 列配置提取 |
| | | |
| | | 将复杂的列配置提取到单独的函数: |
| | | |
| | | ```js |
| | | const getUserColumns = () => [ |
| | | { type: 'selection' }, |
| | | { type: 'index', width: 60, label: '序号' }, |
| | | // ... 其他列 |
| | | ] |
| | | |
| | | useTable({ |
| | | core: { |
| | | columnsFactory: getUserColumns |
| | | } |
| | | }) |
| | | ``` |
| | | |
| | | ### 3. 类型定义 |
| | | |
| | | 为表格数据定义类型: |
| | | |
| | | ```js |
| | | type UserListItem = Api.SystemManage.UserListItem |
| | | |
| | | const { data } = useTable<UserListItem>({...}) |
| | | ``` |
| | | |
| | | ### 4. 列宽配置规范 |
| | | |
| | | ⚠️ **重要**:遵循列宽配置原则,避免表格右侧出现空白: |
| | | |
| | | - 固定内容列使用 `width`:状态、角色、操作列 |
| | | - 可变内容列使用 `minWidth`:用户名、备注、域名、时间 |
| | | - 参考上文"列配置"部分的详细说明 |
| | | |
| | | ## 常见问题 |
| | | |
| | | ### Q: 如何实现自定义分页? |
| | | |
| | | A: 通过 `paginationKey` 配置自定义字段映射: |
| | | |
| | | ```js |
| | | paginationKey: { |
| | | current: 'page', |
| | | size: 'pageSize' |
| | | } |
| | | ``` |
| | | |
| | | ### Q: 如何处理不规则的数据结构? |
| | | |
| | | A: 使用 `dataTransformer` 或 `responseTransformer`: |
| | | |
| | | ```js |
| | | transform: { |
| | | responseTransformer: (res) => { |
| | | return { |
| | | current: res.page, |
| | | size: res.pageSize, |
| | | total: res.total, |
| | | records: res.data.list |
| | | } |
| | | } |
| | | } |
| | | ``` |
| | | |
| | | ### Q: 如何实现列的显示/隐藏? |
| | | |
| | | A: 将 `columns` 通过 `v-model:columns` 传递给 `ArtTableHeader`: |
| | | |
| | | ```vue |
| | | <ArtTableHeader v-model:columns="columns" /> |
| | | <ArtTable :columns="columns" /> |
| | | ``` |
| | | |
| | | **重要提示**: |
| | | - `ArtTableHeader` 和 `ArtTable` 必须使用同一个 `columns` 引用 |
| | | - `columns` 必须是从 `useTable` 返回的响应式数据 |
| | | - 不要在 `ArtTable` 上添加 `fit` 属性以外的其他布局相关属性 |
| | | |
| | | ## 相关文档 |
| | | |
| | | - [ArtTable 组件](./components/art-table.md) |
| | | - [基础表格示例](./examples/tables/basic-table.md) |
| | | - [CRUD 页面示例](./examples/templates/crud-page.md) |
| | | |
| | | ## 官方文档 |
| | | |
| | | - [useTable 官方文档](https://www.artd.pro/docs/zh/guide/hooks/use-table.html) |
| | | |
| | | --- |
| | | |
| | | **最后更新**:2026-03-03 |
| | | |
| | | |
| New file |
| | |
| | | #!/usr/bin/env python3 |
| | | """ |
| | | Art Design Pro 代码生成器 |
| | | |
| | | 自动生成标准的 Vue3 页面代码,包括: |
| | | - CRUD 列表页(带搜索、弹窗、操作) |
| | | - 基础表格页(简单数据展示) |
| | | - 仪表板页面(数据统计和图表) |
| | | |
| | | 使用示例: |
| | | # 生成 CRUD 列表页 |
| | | python generate.py crud --name "User" --path "system/user" --fields "username,email,status" |
| | | |
| | | # 生成基础表格页 |
| | | python generate.py table --name "Product" --path "product/list" --fields "name,price,stock" |
| | | |
| | | # 生成仪表板页面 |
| | | python generate.py dashboard --name "Analytics" --path "dashboard/analytics" --charts "line,bar" |
| | | """ |
| | | |
| | | import argparse |
| | | import sys |
| | | from typing import List, Dict |
| | | |
| | | if hasattr(sys.stdout, "reconfigure"): |
| | | sys.stdout.reconfigure(encoding="utf-8") |
| | | if hasattr(sys.stderr, "reconfigure"): |
| | | sys.stderr.reconfigure(encoding="utf-8") |
| | | |
| | | |
| | | # ==================================== |
| | | # 模板定义 |
| | | # ==================================== |
| | | |
| | | CRUD_MAIN_TEMPLATE = """<!-- {name_pascal}管理页面 --> |
| | | <!-- 使用 Art Design Pro 组件库快速构建 CRUD 页面 --> |
| | | <template> |
| | | <div class="{name_lower}-page art-full-height"> |
| | | <!-- 搜索栏 --> |
| | | <{name_pascal}Search v-model="searchForm" @search="handleSearch" @reset="resetSearchParams"></{name_pascal}Search> |
| | | |
| | | <ElCard class="art-table-card" shadow="never"> |
| | | <!-- 表格头部 --> |
| | | <ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData"> |
| | | <template #left> |
| | | <ElSpace wrap> |
| | | <ElButton type="primary" @click="showDialog('add')" v-ripple>新增{name_pascal}</ElButton> |
| | | </ElSpace> |
| | | </template> |
| | | </ArtTableHeader> |
| | | |
| | | <!-- 表格 --> |
| | | <ArtTable |
| | | :loading="loading" |
| | | :data="data" |
| | | :columns="columns" |
| | | :pagination="pagination" |
| | | @pagination:size-change="handleSizeChange" |
| | | @pagination:current-change="handleCurrentChange" |
| | | > |
| | | </ArtTable> |
| | | |
| | | <!-- {name_pascal}弹窗 --> |
| | | <{name_pascal}Dialog |
| | | v-model:visible="dialogVisible" |
| | | :type="dialogType" |
| | | :data="currentData" |
| | | @submit="handleDialogSubmit" |
| | | /> |
| | | </ElCard> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import {{ useTable }} from '@/hooks/core/useTable' |
| | | import ArtButtonTable from '@/components/core/forms/art-button-table/index.vue' |
| | | import {name_pascal}Search from './modules/{name_lower}-search.vue' |
| | | import {name_pascal}Dialog from './modules/{name_lower}-dialog.vue' |
| | | |
| | | defineOptions({{ name: '{name_pascal}' }}) |
| | | |
| | | // 弹窗相关 |
| | | const dialogType = ref('add') |
| | | const dialogVisible = ref(false) |
| | | const currentData = ref({{}}) |
| | | |
| | | // 搜索表单 |
| | | const searchForm = ref({{ |
| | | {search_fields} |
| | | }}) |
| | | |
| | | const {{ |
| | | columns, |
| | | columnChecks, |
| | | data, |
| | | loading, |
| | | pagination, |
| | | getData, |
| | | replaceSearchParams, |
| | | resetSearchParams, |
| | | handleSizeChange, |
| | | handleCurrentChange, |
| | | refreshData |
| | | }} = useTable({{ |
| | | core: {{ |
| | | apiFn: yourApiFunction, // TODO: 替换为实际的 API 函数 |
| | | apiParams: {{ |
| | | current: 1, |
| | | size: 20, |
| | | ...searchForm.value |
| | | }}, |
| | | columnsFactory: () => [ |
| | | {table_columns} |
| | | ] |
| | | }} |
| | | }}) |
| | | |
| | | /** |
| | | * 搜索处理 |
| | | */ |
| | | const handleSearch = (params) => {{ |
| | | replaceSearchParams(params) |
| | | getData() |
| | | }} |
| | | |
| | | /** |
| | | * 显示弹窗 |
| | | */ |
| | | const showDialog = (type, row) => {{ |
| | | dialogType.value = type |
| | | currentData.value = row || {{}} |
| | | nextTick(() => {{ |
| | | dialogVisible.value = true |
| | | }}) |
| | | }} |
| | | |
| | | const deleteItem = (row) => {{ |
| | | console.log('删除数据:', row) |
| | | }} |
| | | |
| | | /** |
| | | * 处理弹窗提交 |
| | | */ |
| | | const handleDialogSubmit = async () => {{ |
| | | try {{ |
| | | dialogVisible.value = false |
| | | currentData.value = {{}} |
| | | refreshData() |
| | | }} catch (error) {{ |
| | | console.error('提交失败:', error) |
| | | }} |
| | | }} |
| | | </script> |
| | | """ |
| | | |
| | | CRUD_SEARCH_TEMPLATE = """<!-- {name_pascal}搜索栏 --> |
| | | <template> |
| | | <ArtSearchBar |
| | | ref="searchBarRef" |
| | | v-model="formData" |
| | | :items="formItems" |
| | | :rules="rules" |
| | | @reset="handleReset" |
| | | @search="handleSearch" |
| | | > |
| | | </ArtSearchBar> |
| | | </template> |
| | | |
| | | <script setup> |
| | | defineOptions({{ name: '{name_pascal}Search' }}) |
| | | |
| | | const props = defineProps({{ |
| | | modelValue: {{ required: true }} |
| | | }}) |
| | | |
| | | const emit = defineEmits(['update:modelValue', 'search', 'reset']) |
| | | const searchBarRef = ref() |
| | | const rules = {{}} |
| | | const formData = computed({{ |
| | | get: () => props.modelValue, |
| | | set: (val) => emit('update:modelValue', val) |
| | | }}) |
| | | const formItems = computed(() => [ |
| | | {search_items_config} |
| | | ]) |
| | | |
| | | const handleReset = () => {{ |
| | | emit('reset') |
| | | }} |
| | | |
| | | const handleSearch = async (params) => {{ |
| | | await searchBarRef.value?.validate() |
| | | emit('search', params) |
| | | }} |
| | | </script> |
| | | """ |
| | | |
| | | CRUD_DIALOG_TEMPLATE = """<!-- {name_pascal}弹窗 --> |
| | | <template> |
| | | <ElDialog |
| | | v-model="dialogVisible" |
| | | :title="dialogTitle" |
| | | width="600px" |
| | | :before-close="handleClose" |
| | | > |
| | | <ArtForm |
| | | ref="formRef" |
| | | :items="formItems" |
| | | :model="formData" |
| | | :rules="rules" |
| | | :show-reset="false" |
| | | :show-submit="false" |
| | | label-width="120px" |
| | | /> |
| | | |
| | | <template #footer> |
| | | <ElButton @click="handleClose">取消</ElButton> |
| | | <ElButton type="primary" @click="handleSubmit" :loading="loading">确定</ElButton> |
| | | </template> |
| | | </ElDialog> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import ArtForm from '@/components/core/forms/art-form/index.vue' |
| | | |
| | | defineOptions({{ name: '{name_pascal}Dialog' }}) |
| | | |
| | | const props = defineProps({{ |
| | | visible: {{ required: true }}, |
| | | type: {{ required: true }}, |
| | | data: {{ required: false }} |
| | | }}) |
| | | |
| | | const emit = defineEmits(['update:visible', 'submit']) |
| | | |
| | | const formRef = ref() |
| | | const loading = ref(false) |
| | | |
| | | const dialogVisible = computed({{ |
| | | get: () => props.visible, |
| | | set: (val) => emit('update:visible', val) |
| | | }}) |
| | | |
| | | const dialogTitle = computed(() => {{ |
| | | return props.type === 'add' ? '新增{name_pascal}' : '编辑{name_pascal}' |
| | | }}) |
| | | |
| | | const formData = ref({{ |
| | | {dialog_field_defaults} |
| | | }}) |
| | | |
| | | const rules = {{ |
| | | // TODO: 添加校验规则 |
| | | }} |
| | | |
| | | // 表单项配置 |
| | | const formItems = [ |
| | | {form_items_config} |
| | | ] |
| | | |
| | | watch(() => props.data, (newData) => {{ |
| | | if (newData && Object.keys(newData).length > 0) {{ |
| | | Object.assign(formData.value, newData) |
| | | }} |
| | | }}, {{ immediate: true }}) |
| | | |
| | | const handleClose = () => {{ |
| | | formRef.value?.resetFields() |
| | | dialogVisible.value = false |
| | | }} |
| | | |
| | | const handleSubmit = async () => {{ |
| | | try {{ |
| | | await formRef.value?.validate() |
| | | loading.value = true |
| | | // TODO: 调用 API 提交数据 |
| | | emit('submit') |
| | | }} catch (error) {{ |
| | | console.error('验证失败:', error) |
| | | }} finally {{ |
| | | loading.value = false |
| | | }} |
| | | }} |
| | | </script> |
| | | """ |
| | | |
| | | TABLE_TEMPLATE = """<!-- {name_pascal}列表 --> |
| | | <template> |
| | | <div class="{name_lower}-page art-full-height"> |
| | | <ElCard class="art-table-card" shadow="never"> |
| | | <!-- 表格 --> |
| | | <ArtTable |
| | | :loading="loading" |
| | | :data="data" |
| | | :columns="columns" |
| | | :pagination="pagination" |
| | | @pagination:size-change="handleSizeChange" |
| | | @pagination:current-change="handleCurrentChange" |
| | | > |
| | | </ArtTable> |
| | | </ElCard> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import {{ useTable }} from '@/hooks/core/useTable' |
| | | |
| | | defineOptions({{ name: '{name_pascal}' }}) |
| | | |
| | | const {{ |
| | | data, |
| | | columns, |
| | | loading, |
| | | pagination, |
| | | handleSizeChange, |
| | | handleCurrentChange |
| | | }} = useTable({{ |
| | | core: {{ |
| | | apiFn: yourApiFunction, // TODO: 替换为实际的 API 函数 |
| | | apiParams: {{ |
| | | current: 1, |
| | | size: 20 |
| | | }}, |
| | | columnsFactory: () => [ |
| | | {table_columns} |
| | | ] |
| | | }} |
| | | }}) |
| | | </script> |
| | | """ |
| | | |
| | | DASHBOARD_TEMPLATE = """<!-- {name_pascal}仪表板 --> |
| | | <template> |
| | | <div class="art-full-height"> |
| | | <!-- 统计卡片 --> |
| | | <ElRow :gutter="20" class="mb-5"> |
| | | <ElCol :xs="24" :sm="12" :md="6" v-for="stat in stats" :key="stat.key"> |
| | | <ArtStatsCard |
| | | :title="stat.title" |
| | | :count="stat.value" |
| | | :description="stat.description" |
| | | :icon="stat.icon" |
| | | :iconStyle="stat.iconStyle" |
| | | /> |
| | | </ElCol> |
| | | </ElRow> |
| | | |
| | | <!-- 图表区域 --> |
| | | <ElRow :gutter="20"> |
| | | {chart_sections} |
| | | </ElRow> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import {{ ref, onMounted }} from 'vue' |
| | | import {{ ElRow, ElCol, ElCard }} from 'element-plus' |
| | | import ArtStatsCard from '@/components/core/cards/art-stats-card/index.vue' |
| | | |
| | | defineOptions({{ name: '{name_pascal}' }}) |
| | | |
| | | // 统计卡片数据 |
| | | const stats = ref([ |
| | | {{ |
| | | key: 'total', |
| | | title: '总用户数', |
| | | value: 1234, |
| | | description: '系统所有用户', |
| | | icon: 'ri:user-line', |
| | | iconStyle: 'background: linear-gradient(135deg, #667eea 0%, #764ba2 100%)' |
| | | }}, |
| | | {{ |
| | | key: 'active', |
| | | title: '活跃用户', |
| | | value: 567, |
| | | description: '当前在线用户', |
| | | icon: 'ri:user-follow-line', |
| | | iconStyle: 'background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' |
| | | }}, |
| | | {{ |
| | | key: 'new', |
| | | title: '新增用户', |
| | | value: 89, |
| | | description: '今日新增', |
| | | icon: 'ri:user-add-line', |
| | | iconStyle: 'background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' |
| | | }}, |
| | | {{ |
| | | key: 'rate', |
| | | title: '转化率', |
| | | value: 23, |
| | | description: '转化百分比', |
| | | icon: 'ri:percent-line', |
| | | iconStyle: 'background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)' |
| | | }} |
| | | ]) |
| | | |
| | | // 图表数据 |
| | | const chartData = ref([ |
| | | {{ name: '数据1', data: [120, 132, 101, 134, 90, 230, 210] }}, |
| | | {{ name: '数据2', data: [220, 182, 191, 234, 290, 330, 310] }} |
| | | ]) |
| | | |
| | | const xAxis = ref(['周一', '周二', '周三', '周四', '周五', '周六', '周日']) |
| | | |
| | | // TODO: 获取实际数据 |
| | | const fetchDashboardData = async () => {{ |
| | | // 调用 API 获取数据 |
| | | }} |
| | | |
| | | onMounted(() => {{ |
| | | fetchDashboardData() |
| | | }}) |
| | | </script> |
| | | """ |
| | | |
| | | |
| | | # ==================================== |
| | | # 生成器类 |
| | | # ==================================== |
| | | |
| | | class CodeGenerator: |
| | | """代码生成器基类""" |
| | | |
| | | def __init__(self, args): |
| | | self.args = args |
| | | self.name = args.name |
| | | self.path = args.path |
| | | self.fields = self._parse_fields(args.fields) if hasattr(args, 'fields') and args.fields else [] |
| | | |
| | | def _parse_fields(self, fields_str: str) -> List[Dict[str, str]]: |
| | | """解析字段参数""" |
| | | if not fields_str: |
| | | return [] |
| | | |
| | | fields = [] |
| | | for field in fields_str.split(','): |
| | | field = field.strip() |
| | | if ':' in field: |
| | | name, ftype = field.split(':', 1) |
| | | fields.append({'name': name.strip(), 'type': ftype.strip()}) |
| | | else: |
| | | fields.append({'name': field, 'type': 'string'}) |
| | | return fields |
| | | |
| | | def _to_camel_case(self, snake_str: str) -> str: |
| | | """转换为驼峰命名""" |
| | | components = snake_str.split('_') |
| | | return components[0] + ''.join(x.title() for x in components[1:]) |
| | | |
| | | def _to_pascal_case(self, snake_str: str) -> str: |
| | | """转换为帕斯卡命名(首字母大写)""" |
| | | components = snake_str.split('_') |
| | | return ''.join(x.title() for x in components) |
| | | |
| | | def _generate_field_label(self, field: Dict[str, str]) -> str: |
| | | """生成字段标签""" |
| | | name = field['name'] |
| | | labels = { |
| | | 'username': '用户名', |
| | | 'email': '邮箱', |
| | | 'phone': '手机号', |
| | | 'status': '状态', |
| | | 'name': '名称', |
| | | 'created_at': '创建时间', |
| | | 'updated_at': '更新时间', |
| | | 'price': '价格', |
| | | 'stock': '库存', |
| | | 'description': '描述' |
| | | } |
| | | return labels.get(name, self._to_pascal_case(name)) |
| | | |
| | | def generate(self): |
| | | """生成代码(子类实现)""" |
| | | raise NotImplementedError |
| | | |
| | | |
| | | class CrudGenerator(CodeGenerator): |
| | | """CRUD 列表页生成器""" |
| | | |
| | | def generate(self): |
| | | """生成 CRUD 列表页代码""" |
| | | name_lower = self.name.lower() |
| | | name_camel = self._to_camel_case(name_lower) |
| | | name_pascal = self._to_pascal_case(self.name) |
| | | |
| | | # 生成各部分代码 |
| | | search_fields = self._generate_search_fields() |
| | | table_columns = self._generate_table_columns() |
| | | |
| | | # 主页面 |
| | | main_code = CRUD_MAIN_TEMPLATE.format( |
| | | name_pascal=name_pascal, |
| | | name_lower=name_lower, |
| | | name_camel=name_camel, |
| | | search_fields=search_fields, |
| | | table_columns=table_columns |
| | | ) |
| | | |
| | | # 搜索栏组件 |
| | | search_items_config = self._generate_search_items_config() |
| | | |
| | | search_code = CRUD_SEARCH_TEMPLATE.format( |
| | | name_pascal=name_pascal, |
| | | search_items_config=search_items_config |
| | | ) |
| | | |
| | | # 弹窗组件 |
| | | dialog_field_defaults = self._generate_dialog_fields() |
| | | form_items_config = self._generate_form_items_config() |
| | | |
| | | dialog_code = CRUD_DIALOG_TEMPLATE.format( |
| | | name_pascal=name_pascal, |
| | | dialog_field_defaults=dialog_field_defaults, |
| | | form_items_config=form_items_config |
| | | ) |
| | | |
| | | return { |
| | | 'main': main_code, |
| | | 'search': search_code, |
| | | 'dialog': dialog_code |
| | | } |
| | | |
| | | def _generate_search_fields(self) -> str: |
| | | """生成搜索表单字段""" |
| | | if not self.fields: |
| | | return ' // 添加搜索字段' |
| | | |
| | | lines = [] |
| | | for field in self.fields[:5]: |
| | | name = field['name'] |
| | | lines.append(f" {name}: undefined,") |
| | | return '\n'.join(lines) |
| | | |
| | | def _generate_table_columns(self) -> str: |
| | | """生成表格列配置""" |
| | | lines = [] |
| | | lines.append(" { type: 'selection' },") |
| | | lines.append(" { type: 'index', width: 60, label: '序号' },") |
| | | |
| | | for field in self.fields[:8]: |
| | | name = field['name'] |
| | | label = self._generate_field_label(field) |
| | | lines.append(f" {{ prop: '{name}', label: '{label}' }},") |
| | | |
| | | # 操作列 |
| | | lines.append(""" { |
| | | prop: 'operation', |
| | | label: '操作', |
| | | width: 120, |
| | | fixed: 'right', |
| | | formatter: (row) => |
| | | h('div', [ |
| | | h(ArtButtonTable, { |
| | | type: 'edit', |
| | | onClick: () => showDialog('edit', row) |
| | | }), |
| | | h(ArtButtonTable, { |
| | | type: 'delete', |
| | | onClick: () => deleteItem(row) |
| | | }) |
| | | ]) |
| | | }""") |
| | | |
| | | return '\n'.join(lines) |
| | | |
| | | def _generate_form_items(self) -> str: |
| | | """生成表单项(已废弃,保留用于兼容)""" |
| | | return '' |
| | | |
| | | def _generate_search_items_config(self) -> str: |
| | | """生成 ArtSearchBar 的 items 配置""" |
| | | if not self.fields: |
| | | return ' // TODO: 添加搜索字段配置' |
| | | |
| | | lines = [] |
| | | for field in self.fields[:5]: |
| | | name = field['name'] |
| | | label = self._generate_field_label(field) |
| | | ftype = field.get('type', 'string') |
| | | |
| | | # 根据类型生成不同的配置 |
| | | if ftype == 'boolean': |
| | | lines.append(f' {{ key: "{name}", label: "{label}", type: "select", props: {{ options: [{{ label: "是", value: "true" }}, {{ label: "否", value: "false" }}] }} }},') |
| | | elif name in ['status', 'type']: |
| | | lines.append(f' {{ key: "{name}", label: "{label}", type: "select", props: {{ options: [{{ label: "启用", value: "1" }}, {{ label: "禁用", value: "0" }}] }} }},') |
| | | else: |
| | | lines.append(f' {{ key: "{name}", label: "{label}", type: "input", props: {{ placeholder: "请输入{label}", clearable: true }} }},') |
| | | |
| | | return '\n'.join(lines) |
| | | |
| | | def _generate_form_items_config(self) -> str: |
| | | """生成 ArtForm 的 items 配置""" |
| | | if not self.fields: |
| | | return ' // TODO: 添加表单字段配置' |
| | | |
| | | lines = [] |
| | | for field in self.fields: |
| | | name = field['name'] |
| | | label = self._generate_field_label(field) |
| | | ftype = field.get('type', 'string') |
| | | |
| | | # 根据类型生成不同的配置(弹窗表单使用 span: 24) |
| | | if ftype == 'number': |
| | | lines.append(f' {{ key: "{name}", label: "{label}", type: "number", span: 24, props: {{ placeholder: "请输入{label}" }} }},') |
| | | elif ftype == 'boolean': |
| | | lines.append(f' {{ key: "{name}", label: "{label}", type: "switch", span: 24 }},') |
| | | elif ftype == 'date': |
| | | lines.append(f' {{ key: "{name}", label: "{label}", type: "date", span: 24, props: {{ valueFormat: "YYYY-MM-DD" }} }},') |
| | | elif name in ['status', 'type']: |
| | | lines.append(f' {{ key: "{name}", label: "{label}", type: "select", span: 24, props: {{ options: [{{ label: "启用", value: "1" }}, {{ label: "禁用", value: "0" }}] }} }},') |
| | | else: |
| | | lines.append(f' {{ key: "{name}", label: "{label}", type: "input", span: 24, props: {{ placeholder: "请输入{label}" }} }},') |
| | | |
| | | return '\n'.join(lines) |
| | | |
| | | def _generate_interface_fields(self) -> str: |
| | | """生成接口字段""" |
| | | if not self.fields: |
| | | return ' // 定义字段类型' |
| | | |
| | | lines = [] |
| | | for field in self.fields[:5]: |
| | | name = field['name'] |
| | | ftype = field.get('type', 'string') |
| | | lines.append(f" {name}?: {ftype}") |
| | | return '\n'.join(lines) |
| | | |
| | | def _generate_dialog_fields(self) -> str: |
| | | """生成弹窗表单字段""" |
| | | if not self.fields: |
| | | return ' // 定义字段' |
| | | |
| | | lines = [] |
| | | for field in self.fields: |
| | | name = field['name'] |
| | | lines.append(f" {name}: '',") |
| | | return '\n'.join(lines) |
| | | |
| | | |
| | | class TableGenerator(CodeGenerator): |
| | | """基础表格页生成器""" |
| | | |
| | | def generate(self): |
| | | """生成基础表格页代码""" |
| | | name_lower = self.name.lower() |
| | | name_pascal = self._to_pascal_case(self.name) |
| | | |
| | | table_columns = self._generate_columns() |
| | | |
| | | code = TABLE_TEMPLATE.format( |
| | | name_pascal=name_pascal, |
| | | name_lower=name_lower, |
| | | table_columns=table_columns |
| | | ) |
| | | |
| | | return {'main': code} |
| | | |
| | | def _generate_columns(self) -> str: |
| | | """生成表格列""" |
| | | if not self.fields: |
| | | return " { prop: 'id', label: 'ID' }\n // 添加更多列" |
| | | |
| | | lines = [] |
| | | for field in self.fields[:10]: |
| | | name = field['name'] |
| | | label = self._generate_field_label(field) |
| | | lines.append(f" {{ prop: '{name}', label: '{label}' }},") |
| | | |
| | | return '\n'.join(lines) |
| | | |
| | | |
| | | class DashboardGenerator(CodeGenerator): |
| | | """仪表板页面生成器""" |
| | | |
| | | def generate(self): |
| | | """生成仪表板页代码""" |
| | | name_lower = self.name.lower() |
| | | name_pascal = self._to_pascal_case(self.name) |
| | | |
| | | charts = self.args.charts.split(',') if hasattr(self.args, 'charts') and self.args.charts else ['line'] |
| | | chart_sections = self._generate_chart_sections(charts) |
| | | |
| | | code = DASHBOARD_TEMPLATE.format( |
| | | name_pascal=name_pascal, |
| | | name_lower=name_lower, |
| | | chart_sections=chart_sections |
| | | ) |
| | | |
| | | return {'main': code} |
| | | |
| | | def _generate_chart_sections(self, charts: List[str]) -> str: |
| | | """生成图表区块""" |
| | | sections = [] |
| | | |
| | | chart_types = { |
| | | 'line': ('ArtLineChart', '流量趋势'), |
| | | 'bar': ('ArtBarChart', '数据对比'), |
| | | 'pie': ('ArtRingChart', '占比分析'), |
| | | 'radar': ('ArtRadarChart', '多维分析') |
| | | } |
| | | |
| | | for chart in charts[:4]: |
| | | chart = chart.strip() |
| | | if chart in chart_types: |
| | | component, title = chart_types[chart] |
| | | sections.append(f' <ElCol :xs="24" :md="12" :lg="12">') |
| | | sections.append(' <ElCard class="art-table-card" shadow="never">') |
| | | sections.append(' <template #header>') |
| | | sections.append(' <div class="art-card-header">') |
| | | sections.append(' <div class="title">') |
| | | sections.append(f' <h4>{title}</h4>') |
| | | sections.append(' <p>数据概览</p>') |
| | | sections.append(' </div>') |
| | | sections.append(' </div>') |
| | | sections.append(' </template>') |
| | | sections.append(f' <{component} :data="chartData" :xAxisData="xAxis" height="300px" :showLegend="true" />') |
| | | sections.append(' </ElCard>') |
| | | sections.append(' </ElCol>') |
| | | |
| | | return '\n'.join(sections) if sections else ' <!-- 添加图表 -->' |
| | | |
| | | |
| | | # ==================================== |
| | | # 主函数 |
| | | # ==================================== |
| | | |
| | | def main(): |
| | | """主函数""" |
| | | parser = argparse.ArgumentParser( |
| | | description='Art Design Pro 代码生成器', |
| | | formatter_class=argparse.RawDescriptionHelpFormatter, |
| | | epilog=''' |
| | | 使用示例: |
| | | # 生成 CRUD 列表页 |
| | | python generate.py crud --name "User" --path "system/user" --fields "username,email,status" |
| | | |
| | | # 生成基础表格页 |
| | | python generate.py table --name "Product" --path "product/list" --fields "name,price,stock" |
| | | |
| | | # 生成仪表板页 |
| | | python generate.py dashboard --name "Analytics" --path "dashboard/analytics" --charts "line,bar" |
| | | ''' |
| | | ) |
| | | |
| | | subparsers = parser.add_subparsers(dest='type', help='页面类型') |
| | | |
| | | # CRUD 页面生成器 |
| | | crud_parser = subparsers.add_parser('crud', help='生成 CRUD 列表页') |
| | | crud_parser.add_argument('--name', required=True, help='实体名称(如 User、Product)') |
| | | crud_parser.add_argument('--path', required=True, help='页面路径(如 system/user)') |
| | | crud_parser.add_argument('--fields', required=True, help='字段列表(逗号分隔,如 username,email,status)') |
| | | |
| | | # 表格页面生成器 |
| | | table_parser = subparsers.add_parser('table', help='生成基础表格页') |
| | | table_parser.add_argument('--name', required=True, help='实体名称') |
| | | table_parser.add_argument('--path', required=True, help='页面路径') |
| | | table_parser.add_argument('--fields', required=True, help='字段列表') |
| | | |
| | | # 仪表板页面生成器 |
| | | dashboard_parser = subparsers.add_parser('dashboard', help='生成仪表板页') |
| | | dashboard_parser.add_argument('--name', required=True, help='仪表板名称') |
| | | dashboard_parser.add_argument('--path', required=True, help='页面路径') |
| | | dashboard_parser.add_argument('--charts', help='图表类型(逗号分隔:line,bar,pie,radar)') |
| | | |
| | | args = parser.parse_args() |
| | | |
| | | if not args.type: |
| | | parser.print_help() |
| | | sys.exit(1) |
| | | |
| | | # 创建生成器 |
| | | generators = { |
| | | 'crud': CrudGenerator, |
| | | 'table': TableGenerator, |
| | | 'dashboard': DashboardGenerator |
| | | } |
| | | |
| | | generator_class = generators.get(args.type) |
| | | if not generator_class: |
| | | print(f'❌ 错误:不支持的页面类型 "{args.type}"') |
| | | sys.exit(1) |
| | | |
| | | generator = generator_class(args) |
| | | result = generator.generate() |
| | | |
| | | # 输出生成的代码 |
| | | if args.type == 'crud': |
| | | print('=' * 80) |
| | | print('📄 主页面 (index.vue):') |
| | | print('=' * 80) |
| | | print(result['main']) |
| | | print('\n') |
| | | print('=' * 80) |
| | | print('📄 搜索栏组件 (modules/{}-search.vue):'.format(generator.name.lower())) |
| | | print('=' * 80) |
| | | print(result['search']) |
| | | print('\n') |
| | | print('=' * 80) |
| | | print('📄 弹窗组件 (modules/{}-dialog.vue):'.format(generator.name.lower())) |
| | | print('=' * 80) |
| | | print(result['dialog']) |
| | | else: |
| | | print('=' * 80) |
| | | print(f'📄 生成的 {args.type} 页面:') |
| | | print('=' * 80) |
| | | print(result['main']) |
| | | |
| | | print('\n✅ 代码生成完成!') |
| | | print(f'💡 提示:请将生成的代码复制到对应目录,并根据实际需求调整') |
| | | print(f'📂 目标目录: src/views/{args.path}/') |
| | | |
| | | |
| | | if __name__ == '__main__': |
| | | main() |
| | | |
| New file |
| | |
| | | #!/usr/bin/env python3 |
| | | """ |
| | | Art Design Pro Skill 初始化配置 |
| | | |
| | | 运行此脚本以适配当前项目配置: |
| | | python init.py |
| | | |
| | | 功能: |
| | | - 自动检测项目类型(完整 ADP / 部分集成 / 未集成) |
| | | - 自动检测项目配置 |
| | | - 生成 project-config.json |
| | | """ |
| | | |
| | | import json |
| | | import sys |
| | | from pathlib import Path |
| | | |
| | | if hasattr(sys.stdout, "reconfigure"): |
| | | sys.stdout.reconfigure(encoding="utf-8") |
| | | if hasattr(sys.stderr, "reconfigure"): |
| | | sys.stderr.reconfigure(encoding="utf-8") |
| | | |
| | | |
| | | class ProjectDetector: |
| | | """项目类型检测器""" |
| | | |
| | | def __init__(self, project_root): |
| | | self.project_root = Path(project_root) |
| | | |
| | | def detect_project_type(self): |
| | | """检测项目类型""" |
| | | print("🔍 检测项目类型...") |
| | | |
| | | # 检查 1: 是否是完整 Art Design Pro 项目 |
| | | if self._is_full_adp_project(): |
| | | return "full_adp" |
| | | |
| | | # 检查 2: 是否部分集成 |
| | | if self._is_partial_integration(): |
| | | return "partial" |
| | | |
| | | # 检查 3: 未集成 |
| | | return "none" |
| | | |
| | | def _is_full_adp_project(self): |
| | | """检测是否是完整的 Art Design Pro 项目""" |
| | | checks = [] |
| | | |
| | | # 检查关键目录 |
| | | checks.append((self.project_root / "src" / "components" / "core").exists()) |
| | | checks.append((self.project_root / "src" / "hooks" / "core").exists()) |
| | | checks.append((self.project_root / "src" / "views" / "examples").exists()) |
| | | checks.append((self.project_root / "src" / "views" / "system").exists()) |
| | | |
| | | # 检查 README 或 package.json |
| | | readme = self.project_root / "README.md" |
| | | if readme.exists(): |
| | | with open(readme, encoding="utf-8", errors="ignore") as f: |
| | | content = f.read() |
| | | checks.append("Art Design Pro" in content or "art-design-pro" in content) |
| | | |
| | | # 至少 5 项检查通过才认为是完整项目 |
| | | return sum(checks) >= 5 |
| | | |
| | | def _is_partial_integration(self): |
| | | """检测是否部分集成""" |
| | | # 至少有组件目录 |
| | | components_dir = self.project_root / "src" / "components" / "core" |
| | | if not components_dir.exists(): |
| | | return False |
| | | |
| | | # 检查是否有足够多的组件(递归查找所有子目录) |
| | | components = list(components_dir.glob("**/index.vue")) |
| | | return len(components) >= 30 |
| | | |
| | | def check_dependencies(self): |
| | | """检查依赖安装""" |
| | | print("\n📦 检查依赖...") |
| | | |
| | | package_json = self.project_root / "package.json" |
| | | if not package_json.exists(): |
| | | return {"installed": False, "missing": ["package.json 不存在"]} |
| | | |
| | | with open(package_json, encoding="utf-8", errors="ignore") as f: |
| | | try: |
| | | deps = json.load(f) |
| | | except json.JSONDecodeError: |
| | | return {"installed": False, "missing": ["package.json 格式错误"]} |
| | | |
| | | required = [ |
| | | "vue", "vue-router", "pinia", "element-plus", "axios", "echarts" |
| | | ] |
| | | |
| | | missing = [] |
| | | for dep in required: |
| | | if dep not in deps.get("dependencies", {}): |
| | | missing.append(dep) |
| | | |
| | | if missing: |
| | | return {"installed": False, "missing": missing} |
| | | else: |
| | | return {"installed": True, "missing": []} |
| | | |
| | | def check_build_config(self): |
| | | """检查构建配置""" |
| | | print("\n⚙️ 检查构建配置...") |
| | | |
| | | vite_config = None |
| | | for candidate in ["vite.config.js", "vite.config.mjs", "vite.config.cjs", "vite.config.js"]: |
| | | config_path = self.project_root / candidate |
| | | if config_path.exists(): |
| | | vite_config = config_path |
| | | break |
| | | |
| | | if not vite_config: |
| | | return {"configured": False, "issues": ["vite.config.js 不存在"]} |
| | | |
| | | with open(vite_config, encoding="utf-8", errors="ignore") as f: |
| | | content = f.read() |
| | | |
| | | issues = [] |
| | | |
| | | # 检查必需的插件 |
| | | if "unplugin-auto-import" not in content: |
| | | issues.append("缺少 unplugin-auto-import") |
| | | if "unplugin-vue-components" not in content: |
| | | issues.append("缺少 unplugin-vue-components") |
| | | if "ElementPlusResolver" not in content: |
| | | issues.append("缺少 ElementPlusResolver") |
| | | if "'@/'" not in content and '"@/"' not in content: |
| | | issues.append("缺少路径别名配置") |
| | | |
| | | if issues: |
| | | return {"configured": False, "issues": issues} |
| | | else: |
| | | return {"configured": True, "issues": []} |
| | | |
| | | def check_components(self): |
| | | """检查组件安装""" |
| | | print("\n🧩 检查组件...") |
| | | |
| | | components_dir = self.project_root / "src" / "components" / "core" |
| | | if not components_dir.exists(): |
| | | return {"installed": False, "count": 0, "status": "目录不存在"} |
| | | |
| | | components = list(components_dir.glob("**/index.vue")) |
| | | count = len(components) |
| | | |
| | | if count == 0: |
| | | return {"installed": False, "count": 0, "status": "无组件"} |
| | | elif count < 50: |
| | | return {"installed": False, "count": count, "status": "不完整"} |
| | | else: |
| | | return {"installed": True, "count": count, "status": "完整"} |
| | | |
| | | def generate_integration_report(self): |
| | | """生成集成报告""" |
| | | print("\n" + "="*60) |
| | | print("📊 Art Design Pro 集成状态报告") |
| | | print("="*60) |
| | | |
| | | project_type = self.detect_project_type() |
| | | |
| | | if project_type == "full_adp": |
| | | print("\n✅ 检测结果:完整的 Art Design Pro 项目") |
| | | print("\n🎉 所有组件已就绪!你可以:") |
| | | print(" 1. 使用 search.py 查找组件") |
| | | print(" python skill/art-design-pro/scripts/search.py table") |
| | | print(" 2. 使用 generate.py 生成代码") |
| | | print(" python skill/art-design-pro/scripts/generate.py --help") |
| | | print(" 3. 查看 INTEGRATION_GUIDE.md 了解集成详情") |
| | | print(" 4. 查看 docs/ 目录中的文档") |
| | | |
| | | return True |
| | | |
| | | elif project_type == "partial": |
| | | print("\n⚠️ 检测结果:部分集成 Art Design Pro") |
| | | |
| | | # 详细检查 |
| | | deps = self.check_dependencies() |
| | | config = self.check_build_config() |
| | | components = self.check_components() |
| | | |
| | | print("\n发现的问题:") |
| | | |
| | | if not deps["installed"]: |
| | | print(f"\n❌ 依赖问题:") |
| | | for dep in deps["missing"][:3]: |
| | | print(f" - {dep}") |
| | | print(" 修复: pnpm add " + " ".join(deps["missing"])) |
| | | |
| | | if not config["configured"]: |
| | | print(f"\n❌ 配置问题:") |
| | | for issue in config["issues"]: |
| | | print(f" - {issue}") |
| | | print(" 修复: 参考 templates/vite.config.js.template") |
| | | |
| | | if not components["installed"]: |
| | | print(f"\n⚠️ 组件问题: {components['status']}({components['count']} 个)") |
| | | print(" 建议: 检查组件目录是否完整") |
| | | |
| | | print("\n💡 下一步操作:") |
| | | print(" 1. 修复上述问题") |
| | | print(" 2. 运行验证脚本: python skill/art-design-pro/scripts/verify.py") |
| | | print(" 3. 查看集成指南: INTEGRATION_GUIDE.md") |
| | | |
| | | return False |
| | | |
| | | else: # project_type == "none" |
| | | print("\n❌ 检测结果:未集成 Art Design Pro") |
| | | |
| | | print("\n💡 你需要先集成 Art Design Pro:") |
| | | print("\n 推荐方式:克隆完整项目") |
| | | print(" git clone https://github.com/Daymychen/art-design-pro.git your-project") |
| | | print(" cd your-project") |
| | | print(" pnpm install") |
| | | print(" pnpm dev") |
| | | |
| | | print("\n 或查看集成指南:") |
| | | print(" 📄 INTEGRATION_GUIDE.md - 完整的集成步骤") |
| | | |
| | | return False |
| | | |
| | | |
| | | # ==================== 原有功能保持不变 ==================== |
| | | |
| | | def get_config_path(): |
| | | """获取配置文件路径""" |
| | | return Path(__file__).parent.parent / "project-config.json" |
| | | |
| | | |
| | | def load_config(): |
| | | """加载当前配置""" |
| | | config_path = get_config_path() |
| | | if config_path.exists(): |
| | | with open(config_path, 'r', encoding='utf-8') as f: |
| | | return json.load(f) |
| | | return None |
| | | |
| | | |
| | | def save_config(config): |
| | | """保存配置""" |
| | | config_path = get_config_path() |
| | | with open(config_path, 'w', encoding='utf-8') as f: |
| | | json.dump(config, f, indent=2, ensure_ascii=False) |
| | | print(f"✅ 配置已保存到: {config_path}") |
| | | |
| | | |
| | | def auto_detect_project(): |
| | | """自动检测项目配置""" |
| | | skill_path = Path(__file__).parent.parent |
| | | project_root = skill_path.parent.parent |
| | | |
| | | config = { |
| | | "project": { |
| | | "name": project_root.name, |
| | | "root": str(project_root) |
| | | }, |
| | | "paths": {}, |
| | | "artDesignPro": {}, |
| | | "generation": {} |
| | | } |
| | | |
| | | # 检测前端目录 |
| | | possible_src = [ |
| | | project_root / "frontend" / "src", |
| | | project_root / "src", |
| | | project_root / "client" / "src", |
| | | ] |
| | | |
| | | src_dir = None |
| | | for path in possible_src: |
| | | if path.exists(): |
| | | src_dir = path |
| | | break |
| | | |
| | | if src_dir: |
| | | config["paths"]["src"] = str(src_dir.relative_to(project_root)) |
| | | config["paths"]["views"] = f"{src_dir.relative_to(project_root)}/views" |
| | | config["paths"]["components"] = f"{src_dir.relative_to(project_root)}/components" |
| | | config["paths"]["router"] = f"{src_dir.relative_to(project_root)}/router" |
| | | config["paths"]["api"] = f"{src_dir.relative_to(project_root)}/api" |
| | | |
| | | # 检测 Art Design Pro 组件 |
| | | core_components = src_dir / "components" / "core" |
| | | if core_components.exists(): |
| | | config["artDesignPro"]["componentsPath"] = f"{src_dir.relative_to(project_root)}/components/core" |
| | | |
| | | # 检测示例目录 |
| | | examples = src_dir / "views" / "examples" |
| | | if examples.exists(): |
| | | config["artDesignPro"]["examplesPath"] = f"{src_dir.relative_to(project_root)}/views/examples" |
| | | |
| | | # 检测系统目录 |
| | | system = src_dir / "views" / "system" |
| | | if system.exists(): |
| | | config["artDesignPro"]["systemPath"] = f"{src_dir.relative_to(project_root)}/views/system" |
| | | |
| | | config["generation"]["outputPath"] = f"{src_dir.relative_to(project_root)}/views" |
| | | config["generation"]["useJavaScript"] = False |
| | | config["generation"]["useJavaScript"] = True |
| | | config["generation"]["useCompositionAPI"] = True |
| | | |
| | | return config |
| | | |
| | | |
| | | def interactive_config(): |
| | | """交互式配置""" |
| | | print("🚀 Art Design Pro Skill 初始化配置\n") |
| | | print("请回答以下问题以配置 skill(直接回车使用默认值):\n") |
| | | |
| | | config = { |
| | | "project": { |
| | | "name": input("项目名称: ").strip() or "your-project" |
| | | }, |
| | | "paths": {}, |
| | | "artDesignPro": {}, |
| | | "generation": { |
| | | "useJavaScript": False, |
| | | "useJavaScript": True, |
| | | "useCompositionAPI": True |
| | | } |
| | | } |
| | | |
| | | # 路径配置 |
| | | src = input(f"源代码目录 [src]: ").strip() or "src" |
| | | config["paths"]["src"] = src |
| | | config["paths"]["views"] = f"{src}/views" |
| | | config["paths"]["components"] = f"{src}/components" |
| | | config["paths"]["router"] = f"{src}/router" |
| | | config["paths"]["api"] = f"{src}/api" |
| | | |
| | | # Art Design Pro 配置 |
| | | comp_path = input(f"Art Design Pro 组件路径 [src/components/core]: ").strip() or "src/components/core" |
| | | config["artDesignPro"]["componentsPath"] = comp_path |
| | | |
| | | examples_path = input(f"示例页面路径 [src/views/examples]: ").strip() or "src/views/examples" |
| | | config["artDesignPro"]["examplesPath"] = examples_path |
| | | |
| | | # 代码生成配置 |
| | | output_path = input(f"生成代码输出路径 [src/views]: ").strip() or "src/views" |
| | | config["generation"]["outputPath"] = output_path |
| | | |
| | | return config |
| | | |
| | | |
| | | def main(): |
| | | """主函数""" |
| | | import argparse |
| | | |
| | | parser = argparse.ArgumentParser(description='Art Design Pro Skill 初始化配置') |
| | | parser.add_argument('--check', '-c', action='store_true', help='检查项目集成状态') |
| | | parser.add_argument('--auto', '-a', action='store_true', help='自动检测并生成配置') |
| | | args = parser.parse_args() |
| | | |
| | | # 如果是检查模式 |
| | | if args.check: |
| | | skill_path = Path(__file__).parent.parent |
| | | project_root = skill_path.parent.parent |
| | | |
| | | detector = ProjectDetector(project_root) |
| | | detector.generate_integration_report() |
| | | return |
| | | |
| | | print("=" * 60) |
| | | print("Art Design Pro Skill - 初始化配置") |
| | | print("=" * 60) |
| | | print() |
| | | |
| | | # 检查是否已有配置 |
| | | existing_config = load_config() |
| | | if existing_config and not args.auto: |
| | | print(f"📋 当前配置:") |
| | | print(json.dumps(existing_config, indent=2, ensure_ascii=False)) |
| | | print() |
| | | choice = input("是否重新配置? (y/N): ").strip().lower() |
| | | if choice != 'y': |
| | | print("✅ 保持现有配置") |
| | | print() |
| | | print("💡 提示: 使用 --check 选项检查集成状态") |
| | | return |
| | | |
| | | # 选择配置方式 |
| | | if args.auto: |
| | | print("\n🔍 使用自动检测模式...") |
| | | choice = "1" |
| | | else: |
| | | print("\n配置方式:") |
| | | print("1. 自动检测(推荐)") |
| | | print("2. 手动配置") |
| | | choice = input("\n请选择 (1/2): ").strip() or "1" |
| | | |
| | | if choice == "1": |
| | | print("\n🔍 正在自动检测项目配置...") |
| | | config = auto_detect_project() |
| | | print("✅ 自动检测完成") |
| | | else: |
| | | config = interactive_config() |
| | | |
| | | # 保存配置 |
| | | save_config(config) |
| | | |
| | | # 显示配置摘要 |
| | | print("\n" + "=" * 60) |
| | | print("配置摘要:") |
| | | print("=" * 60) |
| | | print(f"项目名称: {config['project']['name']}") |
| | | print(f"源代码目录: {config['paths'].get('src', 'src')}") |
| | | print(f"组件路径: {config['artDesignPro'].get('componentsPath', 'src/components/core')}") |
| | | print(f"生成输出: {config['generation'].get('outputPath', 'src/views')}") |
| | | print() |
| | | print("✅ 初始化完成!现在可以使用 skill 了。") |
| | | print() |
| | | print("下一步:") |
| | | print(" 1. 测试组件搜索: python scripts/search.py table") |
| | | print(" 2. 生成代码: python scripts/generate.py crud --name 'User' --path 'system/user' --fields 'username,email'") |
| | | print(" 3. 检查集成状态: python scripts/verify.py") |
| | | print() |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | main() |
| | | |
| New file |
| | |
| | | #!/usr/bin/env python3 |
| | | """ |
| | | Art Design Pro 组件列表工具 |
| | | |
| | | 列出所有可用的 Art Design Pro 组件 |
| | | """ |
| | | |
| | | import csv |
| | | import sys |
| | | from pathlib import Path |
| | | |
| | | if hasattr(sys.stdout, "reconfigure"): |
| | | sys.stdout.reconfigure(encoding="utf-8") |
| | | if hasattr(sys.stderr, "reconfigure"): |
| | | sys.stderr.reconfigure(encoding="utf-8") |
| | | |
| | | COMPONENTS_DB = Path(__file__).parent.parent / "data" / "components.csv" |
| | | |
| | | |
| | | def main(): |
| | | """列出所有组件""" |
| | | |
| | | with open(COMPONENTS_DB, "r", encoding="utf-8") as f: |
| | | reader = csv.DictReader(f) |
| | | |
| | | # 按分类组织 |
| | | categories = {} |
| | | for row in reader: |
| | | cat = row["category"] |
| | | if cat not in categories: |
| | | categories[cat] = [] |
| | | categories[cat].append(row) |
| | | |
| | | print("\n" + "=" * 70) |
| | | print("📦 Art Design Pro 组件库完整列表".center(70)) |
| | | print("=" * 70) |
| | | |
| | | # 定义分类显示顺序和中文标题 |
| | | category_titles = { |
| | | "tables": ("📊 表格与数据展示", "tables"), |
| | | "forms": ("📝 表单与输入", "forms"), |
| | | "cards": ("🎴 卡片组件", "cards"), |
| | | "charts": ("📈 图表组件", "charts"), |
| | | "layouts": ("🎨 布局与导航", "layouts"), |
| | | "media": ("🎬 媒体组件", "media"), |
| | | "banners": ("🎪 横幅组件", "banners"), |
| | | "text-effect": ("✨ 文本特效", "text-effect"), |
| | | "base": ("🔧 基础组件", "base"), |
| | | "widget": ("🎯 小部件", "widget"), |
| | | "others": ("🔌 其他组件", "others"), |
| | | } |
| | | |
| | | # 按定义顺序输出 |
| | | for cat_key, (title, _) in category_titles.items(): |
| | | if cat_key not in categories: |
| | | continue |
| | | |
| | | print(f"\n{title}") |
| | | print("-" * 70) |
| | | |
| | | for comp in categories[cat_key]: |
| | | print( |
| | | f" • {comp['component']:<30} {comp['name_cn']:<20} {comp['name_en']}" |
| | | ) |
| | | |
| | | print("\n" + "=" * 70) |
| | | print(f"📊 共计 {sum(len(comps) for comps in categories.values())} 个组件") |
| | | print("=" * 70 + "\n") |
| | | |
| | | print("💡 使用方法:") |
| | | print(" python scripts/search.py \"关键词\" # 搜索组件") |
| | | print(" python scripts/search.py --list # 查看所有分类\n") |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | main() |
| | | |
| New file |
| | |
| | | #!/usr/bin/env python3 |
| | | """ |
| | | Art Design Pro 组件搜索工具 |
| | | |
| | | 用法: |
| | | python search.py "表格" # 搜索中文关键词 |
| | | python search.py "table" # 搜索英文关键词 |
| | | python search.py "form" --category forms # 按分类搜索 |
| | | """ |
| | | |
| | | import csv |
| | | import sys |
| | | import argparse |
| | | from pathlib import Path |
| | | |
| | | if hasattr(sys.stdout, "reconfigure"): |
| | | sys.stdout.reconfigure(encoding="utf-8") |
| | | if hasattr(sys.stderr, "reconfigure"): |
| | | sys.stderr.reconfigure(encoding="utf-8") |
| | | |
| | | # 组件数据库路径 |
| | | COMPONENTS_DB = Path(__file__).parent.parent / "data" / "components.csv" |
| | | |
| | | |
| | | def load_components(): |
| | | """加载所有组件""" |
| | | components = [] |
| | | with open(COMPONENTS_DB, "r", encoding="utf-8") as f: |
| | | reader = csv.DictReader(f) |
| | | for row in reader: |
| | | components.append(row) |
| | | return components |
| | | |
| | | |
| | | def search_components(keyword: str, category: str = None): |
| | | """搜索组件""" |
| | | components = load_components() |
| | | keyword = keyword.lower() |
| | | |
| | | results = [] |
| | | |
| | | for comp in components: |
| | | # 如果指定了分类,先过滤 |
| | | if category and comp["category"] != category: |
| | | continue |
| | | |
| | | # 搜索范围:中文名称、英文名称、描述、常见用法 |
| | | search_fields = [ |
| | | comp.get("name_cn") or "", |
| | | comp.get("name_en") or "", |
| | | comp.get("description") or "", |
| | | comp.get("component") or "", |
| | | comp.get("common_usage") or "", |
| | | ] |
| | | |
| | | # 任何字段匹配即命中 |
| | | if any(keyword in field.lower() for field in search_fields): |
| | | results.append(comp) |
| | | |
| | | return results |
| | | |
| | | |
| | | def format_component(comp: dict): |
| | | """格式化单个组件信息""" |
| | | return f""" |
| | | ┌─ {comp['component']} ({comp['name_cn']}) |
| | | │ |
| | | │ 📝 {comp['description']} |
| | | │ |
| | | │ 📦 导入: {comp['import_path']} |
| | | │ |
| | | │ 🔧 Props: {comp['props']} |
| | | │ 🎯 Slots: {comp['slots']} |
| | | │ |
| | | │ 💡 常见场景: {comp['common_usage']} |
| | | └──────────────────────────────────────────────""" |
| | | |
| | | |
| | | def print_results(results: list): |
| | | """打印搜索结果""" |
| | | if not results: |
| | | print("❌ 未找到匹配的组件") |
| | | print("\n💡 提示:尝试使用更通用的关键词") |
| | | print(" 例如:'表格'、'表单'、'图表'、'卡片'、'布局'") |
| | | return |
| | | |
| | | print(f"\n✅ 找到 {len(results)} 个组件:\n") |
| | | |
| | | for i, comp in enumerate(results, 1): |
| | | print(f"{i}. {comp['component']} - {comp['name_cn']}") |
| | | print(f" {comp['description']}") |
| | | print(f" 用途: {comp['common_usage']}") |
| | | print() |
| | | |
| | | # 如果结果超过5个,询问是否查看详情 |
| | | if len(results) > 5: |
| | | print("💡 使用 --detail 参数查看组件详细信息\n") |
| | | else: |
| | | print("\n详细信息:") |
| | | print("=" * 60) |
| | | for comp in results: |
| | | print(format_component(comp)) |
| | | print() |
| | | |
| | | |
| | | def print_detailed_results(results: list): |
| | | """打印详细搜索结果""" |
| | | if not results: |
| | | print("❌ 未找到匹配的组件") |
| | | return |
| | | |
| | | print(f"\n✅ 找到 {len(results)} 个组件:\n") |
| | | print("=" * 60) |
| | | |
| | | for i, comp in enumerate(results, 1): |
| | | print(f"\n【{i}】{comp['component']} - {comp['name_cn']}") |
| | | print(format_component(comp)) |
| | | |
| | | |
| | | def list_all_categories(): |
| | | """列出所有分类""" |
| | | components = load_components() |
| | | categories = sorted(set(c["category"] for c in components)) |
| | | |
| | | print("\n📁 组件分类:\n") |
| | | for cat in categories: |
| | | count = sum(1 for c in components if c["category"] == cat) |
| | | print(f" • {cat}: {count} 个组件") |
| | | |
| | | print("\n💡 使用 --category <分类名> 搜索特定分类的组件") |
| | | |
| | | |
| | | def main(): |
| | | parser = argparse.ArgumentParser( |
| | | description="Art Design Pro 组件搜索工具", |
| | | formatter_class=argparse.RawDescriptionHelpFormatter, |
| | | epilog=""" |
| | | 示例: |
| | | python search.py "表格" # 搜索中文关键词 |
| | | python search.py "table" # 搜索英文关键词 |
| | | python search.py "form" --category forms # 按分类搜索 |
| | | python search.py --list # 列出所有分类 |
| | | python search.py "chart" --detail # 查看详细信息 |
| | | """ |
| | | ) |
| | | |
| | | parser.add_argument("keyword", nargs="?", help="搜索关键词") |
| | | parser.add_argument("--category", "-c", help="按分类过滤 (tables, forms, cards, charts, layouts, etc.)") |
| | | parser.add_argument("--detail", "-d", action="store_true", help="显示详细信息") |
| | | parser.add_argument("--list", "-l", action="store_true", help="列出所有分类") |
| | | |
| | | args = parser.parse_args() |
| | | |
| | | # 列出所有分类 |
| | | if args.list: |
| | | list_all_categories() |
| | | return |
| | | |
| | | # 必须提供关键词 |
| | | if not args.keyword: |
| | | parser.print_help() |
| | | return |
| | | |
| | | # 搜索组件 |
| | | results = search_components(args.keyword, args.category) |
| | | |
| | | # 打印结果 |
| | | if args.detail: |
| | | print_detailed_results(results) |
| | | else: |
| | | print_results(results) |
| | | |
| | | |
| | | if __name__ == "__main__": |
| | | main() |
| | | |
| New file |
| | |
| | | #!/usr/bin/env python3 |
| | | """ |
| | | Art Design Pro 集成验证工具 |
| | | |
| | | 用法: |
| | | python scripts/verify.py |
| | | python scripts/verify.py --verbose |
| | | |
| | | 功能: |
| | | 检查项目的 Art Design Pro 集成状态,包括依赖、配置、组件等 |
| | | """ |
| | | |
| | | import os |
| | | import sys |
| | | import json |
| | | from pathlib import Path |
| | | |
| | | if hasattr(sys.stdout, "reconfigure"): |
| | | sys.stdout.reconfigure(encoding="utf-8") |
| | | if hasattr(sys.stderr, "reconfigure"): |
| | | sys.stderr.reconfigure(encoding="utf-8") |
| | | |
| | | |
| | | class IntegrationVerifier: |
| | | def __init__(self, project_root=None, verbose=False): |
| | | # 如果未指定项目根目录,使用当前目录 |
| | | self.project_root = Path(project_root) if project_root else Path.cwd() |
| | | self.verbose = verbose |
| | | self.issues = [] |
| | | self.warnings = [] |
| | | self.success = [] |
| | | |
| | | def log(self, message): |
| | | """打印日志""" |
| | | print(message) |
| | | |
| | | def check_dependencies(self): |
| | | """检查依赖安装情况""" |
| | | self.log("\n📦 检查依赖...") |
| | | |
| | | package_json = self.project_root / 'package.json' |
| | | if not package_json.exists(): |
| | | self.issues.append("❌ package.json 不存在") |
| | | self.log(" ❌ package.json 不存在") |
| | | return False |
| | | |
| | | with open(package_json, encoding='utf-8', errors='ignore') as f: |
| | | try: |
| | | deps = json.load(f) |
| | | except json.JSONDecodeError: |
| | | self.issues.append("❌ package.json 格式错误") |
| | | self.log(" ❌ package.json 格式错误") |
| | | return False |
| | | |
| | | # 必需的生产依赖 |
| | | required_deps = [ |
| | | 'vue', 'vue-router', 'pinia', |
| | | 'element-plus', 'axios', 'echarts' |
| | | ] |
| | | |
| | | # 必需的开发依赖 |
| | | required_dev_deps = [ |
| | | 'vite', |
| | | 'unplugin-auto-import', |
| | | 'unplugin-vue-components', |
| | | 'unplugin-element-plus' |
| | | ] |
| | | |
| | | missing = [] |
| | | |
| | | # 检查生产依赖 |
| | | for dep in required_deps: |
| | | if dep not in deps.get('dependencies', {}): |
| | | missing.append(f"{dep} (dependencies)") |
| | | |
| | | # 检查开发依赖 |
| | | for dep in required_dev_deps: |
| | | if dep not in deps.get('devDependencies', {}): |
| | | missing.append(f"{dep} (devDependencies)") |
| | | |
| | | if missing: |
| | | issue = f"❌ 缺少依赖: {', '.join(missing[:3])}" |
| | | if len(missing) > 3: |
| | | issue += f" 等 {len(missing)} 个" |
| | | self.issues.append(issue) |
| | | self.log(f" {issue}") |
| | | return False |
| | | else: |
| | | self.log(" ✅ dependencies: 所有必需依赖已安装") |
| | | self.success.append("dependencies") |
| | | return True |
| | | |
| | | def check_vite_config(self): |
| | | """检查 Vite 配置""" |
| | | self.log("\n⚙️ 检查 Vite 配置...") |
| | | |
| | | vite_config = None |
| | | for candidate in ['vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.js']: |
| | | config_path = self.project_root / candidate |
| | | if config_path.exists(): |
| | | vite_config = config_path |
| | | break |
| | | |
| | | if not vite_config: |
| | | self.issues.append("❌ vite.config.js 不存在") |
| | | self.log(" ❌ vite.config.js 不存在") |
| | | return False |
| | | |
| | | with open(vite_config, encoding='utf-8', errors='ignore') as f: |
| | | content = f.read() |
| | | |
| | | # 检查关键配置项 |
| | | checks = { |
| | | 'unplugin-auto-import': 'unplugin-auto-import' in content, |
| | | 'unplugin-vue-components': 'unplugin-vue-components' in content, |
| | | 'ElementPlusResolver': 'ElementPlusResolver' in content, |
| | | 'path alias': ('@/' in content or "'@':" in content or '"@":' in content), |
| | | 'tailwindcss': 'tailwindcss' in content.lower() |
| | | } |
| | | |
| | | issues = [name for name, passed in checks.items() if not passed] |
| | | if issues: |
| | | issue = f"❌ build_config: 缺少或配置错误: {', '.join(issues)}" |
| | | self.issues.append(issue) |
| | | self.log(f" {issue}") |
| | | |
| | | if self.verbose: |
| | | self.log(" 详细说明:") |
| | | if 'unplugin-auto-import' in issues: |
| | | self.log(" - 缺少 unplugin-auto-import 配置") |
| | | if 'path alias' in issues: |
| | | self.log(" - 缺少路径别名配置(@/)") |
| | | self.log(" 参考: templates/vite.config.js.template") |
| | | return False |
| | | else: |
| | | self.log(" ✅ build_config: Vite 配置正确") |
| | | self.success.append("build_config") |
| | | return True |
| | | |
| | | def check_components(self): |
| | | """检查组件安装""" |
| | | self.log("\n🧩 检查组件...") |
| | | |
| | | components_dir = self.project_root / 'src' / 'components' / 'core' |
| | | if not components_dir.exists(): |
| | | self.issues.append("❌ components: src/components/core 目录不存在") |
| | | self.log(" ❌ components: src/components/core 目录不存在") |
| | | self.log(" 建议: 先运行 python scripts/init.py") |
| | | return False |
| | | |
| | | # 统计组件数量(递归查找所有子目录) |
| | | components = list(components_dir.glob('**/index.vue')) |
| | | component_count = len(components) |
| | | |
| | | if component_count < 50: |
| | | warning = f"⚠️ components: 只找到 {component_count} 个组件(预期 56+)" |
| | | self.warnings.append(warning) |
| | | self.log(f" {warning}") |
| | | |
| | | if self.verbose: |
| | | self.log(" 组件可能不完整,建议重新复制组件目录") |
| | | return False |
| | | else: |
| | | self.log(f" ✅ components: 找到 {component_count} 个组件") |
| | | self.success.append("components") |
| | | return True |
| | | |
| | | def check_js_runtime_config(self): |
| | | """检查 JS 版自动导入配置""" |
| | | self.log("\n📝 检查 JS 运行时配置...") |
| | | |
| | | auto_import_config = self.project_root / '.auto-import.json' |
| | | if auto_import_config.exists(): |
| | | self.log(" ✅ auto-import: ESLint 自动导入配置存在") |
| | | self.success.append("auto-import") |
| | | return True |
| | | |
| | | warning = "⚠️ auto-import: 未找到 .auto-import.json,可能尚未运行过构建或 dev" |
| | | self.warnings.append(warning) |
| | | self.log(f" {warning}") |
| | | return False |
| | | |
| | | def run_all_checks(self): |
| | | """运行所有检查""" |
| | | print("="*60) |
| | | print("🔍 Art Design Pro 集成验证") |
| | | print("="*60) |
| | | print() |
| | | |
| | | # 检查项目根目录 |
| | | self.log(f"📁 项目目录: {self.project_root}") |
| | | |
| | | # 执行各项检查 |
| | | deps_ok = self.check_dependencies() |
| | | vite_ok = self.check_vite_config() |
| | | components_ok = self.check_components() |
| | | auto_import_ok = self.check_js_runtime_config() |
| | | |
| | | print() |
| | | print("="*60) |
| | | print("📊 验证完成") |
| | | print("="*60) |
| | | |
| | | # 输出结果 |
| | | all_good = not self.issues and not self.warnings |
| | | |
| | | if self.success: |
| | | print(f"\n✅ 通过的检查 ({len(self.success)}):") |
| | | for item in self.success: |
| | | print(f" ✅ {item}") |
| | | |
| | | if self.warnings: |
| | | print(f"\n⚠️ 警告 ({len(self.warnings)}):") |
| | | for warning in self.warnings: |
| | | print(f" {warning}") |
| | | |
| | | if self.issues: |
| | | print(f"\n❌ 发现问题 ({len(self.issues)}):") |
| | | for issue in self.issues: |
| | | print(f" {issue}") |
| | | |
| | | # 给出修复建议 |
| | | if self.issues: |
| | | print("\n💡 修复建议:") |
| | | if "package.json" in " ".join(self.issues): |
| | | print(" 1. 安装缺失依赖") |
| | | print(" pnpm add <缺失的依赖>") |
| | | if "vite.config.js" in " ".join(self.issues): |
| | | print(" 2. 更新 Vite 配置") |
| | | print(" 参考: templates/vite.config.js.template") |
| | | if "components/core" in " ".join(self.issues): |
| | | print(" 3. 复制 Art Design Pro 组件") |
| | | print(" 详见: INTEGRATION_GUIDE.md") |
| | | print(" 4. 运行: python scripts/init.py") |
| | | |
| | | # 返回退出码 |
| | | if all_good: |
| | | print("\n🎉 所有检查通过!你的项目已正确集成 Art Design Pro") |
| | | return 0 |
| | | elif not self.issues: |
| | | print("\n✨ 集成基本完成,有一些小警告但不影响使用") |
| | | return 0 |
| | | else: |
| | | print("\n❌ 集成存在问题,请按照提示修复") |
| | | return 1 |
| | | |
| | | |
| | | def main(): |
| | | import argparse |
| | | |
| | | parser = argparse.ArgumentParser(description='Art Design Pro 集成验证工具') |
| | | parser.add_argument('--verbose', '-v', action='store_true', help='显示详细输出') |
| | | parser.add_argument('--project-root', '-p', type=str, help='项目根目录(默认当前目录)') |
| | | |
| | | args = parser.parse_args() |
| | | |
| | | verifier = IntegrationVerifier( |
| | | project_root=args.project_root, |
| | | verbose=args.verbose |
| | | ) |
| | | |
| | | sys.exit(verifier.run_all_checks()) |
| | | |
| | | |
| | | if __name__ == '__main__': |
| | | main() |
| | | |
| New file |
| | |
| | | # 应用基础路径 |
| | | VITE_BASE_URL=/ |
| | | |
| | | # API 地址 |
| | | VITE_API_URL=/ |
| | | VITE_API_PROXY_URL=http://localhost:8080 |
| | | |
| | | # 开发服务器端口 |
| | | VITE_PORT=3000 |
| | | |
| | | # 应用标题 |
| | | VITE_APP_TITLE=Your Application |
| | | |
| | | # 版本号 |
| | | VITE_VERSION=1.0.0 |
| | | |
| New file |
| | |
| | | { |
| | | "name": "your-project", |
| | | "version": "1.0.0", |
| | | "type": "module", |
| | | "description": "Your project description", |
| | | "author": "", |
| | | "license": "MIT", |
| | | "scripts": { |
| | | "dev": "vite --open", |
| | | "build": "vite build", |
| | | "preview": "vite preview", |
| | | "lint": "eslint . --ext .js,.mjs,.cjs,.vue", |
| | | "lint:fix": "eslint . --ext .js,.mjs,.cjs,.vue --fix", |
| | | "format": "prettier --write \"**/*.{js,mjs,cjs,json,css,scss,vue,html,md}\"" |
| | | }, |
| | | "dependencies": { |
| | | "vue": "^3.5.21", |
| | | "vue-router": "^4.5.1", |
| | | "pinia": "^3.0.3", |
| | | "pinia-plugin-persistedstate": "^4.3.0", |
| | | "element-plus": "^2.11.2", |
| | | "@element-plus/icons-vue": "^2.3.2", |
| | | "@iconify/vue": "^5.0.0", |
| | | "@vueuse/core": "^13.9.0", |
| | | "axios": "^1.12.2", |
| | | "echarts": "^6.0.0", |
| | | "crypto-js": "^4.2.0", |
| | | "nprogress": "^0.2.0", |
| | | "mitt": "^3.0.1", |
| | | "file-saver": "^2.0.5", |
| | | "highlight.js": "^11.10.0", |
| | | "qrcode.vue": "^3.6.0", |
| | | "vue-draggable-plus": "^0.6.0", |
| | | "xlsx": "^0.18.5" |
| | | }, |
| | | "devDependencies": { |
| | | "@vitejs/plugin-vue": "^6.0.1", |
| | | "vite": "^7.1.5", |
| | | "unplugin-auto-import": "^20.2.0", |
| | | "unplugin-vue-components": "^29.1.0", |
| | | "unplugin-element-plus": "^0.10.0", |
| | | "tailwindcss": "^4.1.14", |
| | | "@tailwindcss/vite": "^4.1.14", |
| | | "sass": "^1.77.0", |
| | | "@types/node": "^20.11.0" |
| | | }, |
| | | "engines": { |
| | | "node": ">=20.19.0", |
| | | "pnpm": ">=8.8.0" |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | /** @type {import('tailwindcss').Config} */ |
| | | export default { |
| | | // Tailwind CSS 配置 |
| | | // 指定包含 Tailwind 样式的文件路径 |
| | | content: [ |
| | | "./index.html", |
| | | "./src/**/*.{vue,js,ts,jsx,tsx}", |
| | | ], |
| | | |
| | | // 主题扩展 |
| | | theme: { |
| | | extend: { |
| | | // 颜色扩展 - 使用 Element Plus 的 CSS 变量 |
| | | colors: { |
| | | primary: 'var(--el-color-primary)', |
| | | success: 'var(--el-color-success)', |
| | | warning: 'var(--el-color-warning)', |
| | | danger: 'var(--el-color-danger)', |
| | | error: 'var(--el-color-error)', |
| | | info: 'var(--el-color-info)', |
| | | }, |
| | | |
| | | // 尺寸扩展 |
| | | spacing: { |
| | | '72': '18rem', |
| | | '84': '21rem', |
| | | '96': '24rem', |
| | | '128': '32rem', |
| | | }, |
| | | |
| | | // Z-index 层级 |
| | | zIndex: { |
| | | '0': '0', |
| | | '10': '10', |
| | | '20': '20', |
| | | '30': '30', |
| | | '40': '40', |
| | | '50': '50', |
| | | '100': '100', |
| | | '1000': '1000', |
| | | '2000': '2000', |
| | | '3000': '3000', |
| | | } |
| | | }, |
| | | }, |
| | | |
| | | // 插件 |
| | | plugins: [], |
| | | } |
| | | |
| New file |
| | |
| | | import path from 'path' |
| | | import { fileURLToPath } from 'url' |
| | | import { defineConfig, loadEnv } from 'vite' |
| | | import vue from '@vitejs/plugin-vue' |
| | | import tailwindcss from '@tailwindcss/vite' |
| | | import AutoImport from 'unplugin-auto-import/vite' |
| | | import ElementPlus from 'unplugin-element-plus/vite' |
| | | import Components from 'unplugin-vue-components/vite' |
| | | import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' |
| | | |
| | | export default ({ mode }) => { |
| | | const root = process.cwd() |
| | | const env = loadEnv(mode, root) |
| | | const { VITE_PORT = '3000', VITE_BASE_URL = '/', VITE_API_PROXY_URL = 'http://localhost:8080' } = |
| | | env |
| | | |
| | | return defineConfig({ |
| | | base: VITE_BASE_URL, |
| | | server: { |
| | | port: Number(VITE_PORT), |
| | | host: true, |
| | | proxy: { |
| | | '/api': { |
| | | target: VITE_API_PROXY_URL, |
| | | changeOrigin: true |
| | | } |
| | | } |
| | | }, |
| | | resolve: { |
| | | alias: { |
| | | '@': fileURLToPath(new URL('./src', import.meta.url)), |
| | | '@views': resolvePath('src/views'), |
| | | '@imgs': resolvePath('src/assets/images'), |
| | | '@icons': resolvePath('src/assets/icons'), |
| | | '@utils': resolvePath('src/utils'), |
| | | '@stores': resolvePath('src/store'), |
| | | '@styles': resolvePath('src/assets/styles') |
| | | } |
| | | }, |
| | | plugins: [ |
| | | vue(), |
| | | tailwindcss(), |
| | | AutoImport({ |
| | | imports: ['vue', 'vue-router', 'pinia', '@vueuse/core'], |
| | | dts: false, |
| | | resolvers: [ElementPlusResolver()], |
| | | eslintrc: { |
| | | enabled: true, |
| | | filepath: './.auto-import.json', |
| | | globalsPropValue: true |
| | | } |
| | | }), |
| | | Components({ |
| | | dts: false, |
| | | resolvers: [ElementPlusResolver()] |
| | | }), |
| | | ElementPlus({ |
| | | useSource: true |
| | | }) |
| | | ], |
| | | build: { |
| | | target: 'es2015', |
| | | outDir: 'dist' |
| | | } |
| | | }) |
| | | } |
| | | |
| | | function resolvePath(targetPath) { |
| | | return path.resolve(path.dirname(fileURLToPath(import.meta.url)), targetPath) |
| | | } |
| | | |
| New file |
| | |
| | | <template> |
| | | <ElConfigProvider |
| | | size="default" |
| | | :locale="locales[language]" |
| | | :z-index="3000" |
| | | :card="{ |
| | | shadow: 'never' |
| | | }" |
| | | > |
| | | <RouterView></RouterView> |
| | | </ElConfigProvider> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useUserStore } from './store/modules/user' |
| | | import zh from 'element-plus/es/locale/lang/zh-cn' |
| | | import en from 'element-plus/es/locale/lang/en' |
| | | import { systemUpgrade } from './utils/sys' |
| | | import { toggleTransition } from './utils/ui/animation' |
| | | import { checkStorageCompatibility } from './utils/storage' |
| | | import { initializeTheme } from './hooks/core/useTheme' |
| | | const userStore = useUserStore() |
| | | const { language } = storeToRefs(userStore) |
| | | const locales = { |
| | | zh, |
| | | en |
| | | } |
| | | onBeforeMount(() => { |
| | | toggleTransition(true) |
| | | initializeTheme() |
| | | }) |
| | | onMounted(() => { |
| | | checkStorageCompatibility() |
| | | toggleTransition(false) |
| | | systemUpgrade() |
| | | }) |
| | | </script> |
| New file |
| | |
| | | import request from '@/utils/http' |
| | | function fetchLogin(params) { |
| | | return request.post({ |
| | | url: '/api/auth/login', |
| | | params |
| | | // showSuccessMessage: true // 显示成功消息 |
| | | // showErrorMessage: false // 不显示错误消息 |
| | | }) |
| | | } |
| | | function fetchGetUserInfo() { |
| | | return request.get({ |
| | | url: '/api/user/info' |
| | | // 自定义请求头 |
| | | // headers: { |
| | | // 'X-Custom-Header': 'your-custom-value' |
| | | // } |
| | | }) |
| | | } |
| | | export { fetchGetUserInfo, fetchLogin } |
| New file |
| | |
| | | import request from '@/utils/http' |
| | | function fetchGetUserList(params) { |
| | | return request.get({ |
| | | url: '/api/user/list', |
| | | params |
| | | }) |
| | | } |
| | | function fetchGetRoleList(params) { |
| | | return request.get({ |
| | | url: '/api/role/list', |
| | | params |
| | | }) |
| | | } |
| | | function fetchGetMenuList() { |
| | | return request.get({ |
| | | url: '/api/v3/system/menus/simple' |
| | | }) |
| | | } |
| | | export { fetchGetMenuList, fetchGetRoleList, fetchGetUserList } |
| New file |
| | |
| | | <svg viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg"><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="94" y="34" width="212" height="233"><path d="M306 34H94v233h212V34Z" fill="#fff"/></mask><g mask="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M234.427 155.64h38.36V69.6h-38.36v86.04ZM113.326 155.64h121.1V69.6h-121.1v86.04Z" fill="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M130.126 155.354h104.2v-72.95h-104.2v72.95ZM236.369 71.05s0 3.3 1.65 5.05c2.33 2.52 7.38-.2 7.38-.2s-1.75 5.15-1.55 10.19c.29 8.24 6.99 9.51 10 4.75 4.56 4.85 8.94-.29 9.52-2.62 4.27 4.76 9.32-.87 9.32-.87v-6.3l-23.99-12.13-12.33 2.13Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M234.429 155.641h-121.1l-15.93 32.11h121.1l15.93-32.11Z" fill="#fff"/><path d="M234.427 69.6h38.46v86.04M113.326 146.52V69.6h121.1M234.429 155.641l-15.93 32.11h-121.1l15.93-32.11h111.39" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M226.37 159.715H116.82l-12.04 23.86H215l11.37-23.86Z" fill="#006EFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="m288.807 187.751-15.92-32.11h-38.46l16.02 32.11h38.36Z" fill="#fff"/><path d="m238.607 163.981 11.84 23.77h38.36l-15.92-32.11h-38.46" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M207.336 223.734c-3.69-13.77-15.44-23.86-29.33-23.86h-8.65s-27.09 14.94-27.09 33.27c0 18.34 25.44 33.18 25.44 33.18h10.4c13.79-.1 25.44-10.19 29.13-23.87 1.75-12.51 0-18.62.1-18.72Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M243.459 240.421c3.98 0 7.28-3.3 7.28-7.27 0-3.98-3.3-7.28-7.28-7.28h-31.08c-3.98 0-7.28 3.3-7.28 7.28 0 3.97 3.3 7.27 7.28 7.27h31.08Z" fill="#C7DEFF"/><path d="M210.342 223.737c-4.08-13.87-16.9-23.96-32.05-23.96H168.972s-29.62 14.94-29.62 33.37 27.87 33.37 27.87 33.37h11.27c15.05-.1 27.77-10.19 31.75-23.96" stroke="#071F4D"/><path d="M212.379 240.421c-3.98 0-7.28-3.3-7.28-7.27m0 0c0-3.98 3.3-7.28 7.28-7.28" stroke="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M168.781 199.777c-18.45 0-33.41 14.94-33.41 33.37s14.96 33.37 33.41 33.37c18.45 0 33.4-14.94 33.4-33.37s-14.95-33.37-33.4-33.37Z" fill="#006EFF"/><path d="M168.781 199.777c-18.45 0-33.41 14.94-33.41 33.37s14.96 33.37 33.41 33.37c18.45 0 33.4-14.94 33.4-33.37s-14.95-33.37-33.4-33.37Z" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M168.775 209.38c-13.14 0-23.79 10.64-23.79 23.77 0 13.12 10.65 23.76 23.79 23.76 13.14 0 23.8-10.64 23.8-23.76 0-13.13-10.66-23.77-23.8-23.77Z" fill="#00E4E5"/><path d="M162.174 223.736a17.48 17.48 0 0 1 14.76-8.05M159.455 231.982c.1-1.36.29-2.62.68-3.88" stroke="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M173.535 209.87c-1.55-.3-3.11-.49-4.76-.49-13.11 0-23.79 10.67-23.79 23.77 0 13.09 10.68 23.76 23.79 23.76 1.65 0 3.21-.19 4.76-.48-10.88-2.23-19.03-11.84-19.03-23.28 0-11.45 8.15-21.05 19.03-23.28Z" fill="#071F4D"/><path d="M219.957 225.774h23.6c4.08 0 7.38 3.3 7.38 7.37m0 0c0 4.08-3.3 7.37-7.38 7.37h-20.1M212.091 225.774h3.3" stroke="#071F4D"/><path d="m248.894 34.485-.19 18.24c0 4.07-.39 5.23-2.14 6.79-8.15 6.88-10.97 9.02-9.22 12.9 1.45 3.2 6.79 2.23 9.61-1.55-.39 4.56-5.24 15.32-.58 18.04 4.37 2.52 6.89-3.49 6.89-3.49s.49 3.49 4.47 3.49c3.69 0 5.24-4.75 5.24-4.75s2.14 3.49 6.22 1.35c3.11-1.55 5.44-7.08 5.44-26.67v-24.35" fill="#fff"/><path d="m248.894 34.485-.19 18.24c0 4.07-.39 5.23-2.14 6.79-8.15 6.88-10.97 9.02-9.22 12.9 1.45 3.2 6.79 2.23 9.61-1.55-.39 4.56-5.24 15.32-.58 18.04 4.37 2.52 6.89-3.49 6.89-3.49s.49 3.49 4.47 3.49c3.69 0 5.24-4.75 5.24-4.75s2.14 3.49 6.22 1.35c3.11-1.55 5.44-7.08 5.44-26.67v-24.35" stroke="#071F4D"/><path d="M255.307 75.71s-.39 5.43-2.04 9.6l2.04-9.6Z" fill="#fff"/><path d="M255.307 75.71s-.39 5.43-2.04 9.6" stroke="#071F4D"/><path d="M264.921 75.323s-.68 5.24-2.04 8.63l2.04-8.63Z" fill="#fff"/><path d="M264.921 75.323s-.68 5.24-2.04 8.63M147.801 34.485v34.92M121.775 34.485v34.92M102.546 204.724v13.97M102.546 222.379v.87M102.546 197.934v3.49M115.268 206.955v26.29M115.268 239.451v5.34M244.43 197.643v11.93M244.43 213.939v3.49M270.359 201.232v33.76M115.369 47.774h-13.6M94.486 47.774h3.4M241.516 47.774h-84.1M280.168 47.774h25.35" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m282.497 183.575-12.04-23.86h-27.29l11.36 23.86h27.97Z" fill="#00E4E5"/><path d="M234.427 134.88V69.6M234.427 140.412v7.66" stroke="#071F4D"/><path d="M220.831 228.684h16.99M240.934 228.684h2.43" stroke="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="m223.842 187.462 21.46-.2-10.97-20.66-10.49 20.86Z" fill="#071F4D"/></g></svg> |
| New file |
| | |
| | | <svg viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M290.7 38.4h-50.3v63.5h50.3V38.4Zm-16 47.5V54.4h-18.3v31.5h18.3ZM199 71.3V38.7h-16v48.6h32v14.6h16V38.7h-16v32.6h-16ZM331.7 87.3v14.6h16V38.7h-16v32.6h-16V38.7h-16v48.6h32Z" fill="#DEEBFC"/><path d="M324.3 119.5h24.1v-17.4h-24.1" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M218.5 120H69.4v11.5h130.5l18.6-11.5ZM231.5 131.5h117.2V120H231.5v11.5Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M348.4 119.5v-17.4h-24.1v17.4h24.1Z" fill="#071F4D"/><path d="M159.6 119.5h164.9V102H169.2M114.8 102H57.9v17.5h39.6" stroke="#071F4D"/><path d="M242.5 223.2V98.7c0-3.6 2.9-6.6 6.6-6.6m0 0c3.6 0 6.6 2.9 6.6 6.6v2.8M243.1 153.9h53.1M243.1 193.9h53.1M296.5 219.1V98.8c0-3.6 2.9-6.6 6.6-6.6 3.6 0 6.6 2.9 6.6 6.6v2.8" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m140.5 96.4 15.9 12.9 6.6-9.4-12.2-11.2-8.2 5.8-2.1 1.9Z" fill="#C7DEFF"/><path d="m170.2 93.5-4.2 4-3-2.4" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M150.2 87.1s3.7-5.3 8.1-3.9c2.7.8 1.7-1.6 1.7-1.6l-9.2-5.6-3.9 8.5 3.3 2.6Z" fill="#C7DEFF"/><path d="m148 85.5 13 12.1" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M179.202 87.001c.9-.1 1.7-.6 2.3-1.4.6-.6.9-1.4.8-1.9-.2-1.1-38.4-36-39.6-37.1-.7-.6-2-.7-3.3 0-.7.3-1.3 1-1.8 1.9 0 0-3 .4 1.3 11.1 4.3 10.6 6.6 19.1 6.6 19.1s-1.8 4.1-2.8 6.6c-.9 1.7 5.8 2.1 8.4-4.4 4.4.8 13.6 3.4 19 12.6v.1c1.1 1.9 1.9 1.2 2.4.9 3.2-2.4 7.2-5.8 6.7-7.5Z" fill="#00E4E5"/><path fill-rule="evenodd" clip-rule="evenodd" d="M182.3 83.802c-.2-1.1-38.4-36-39.6-37.1-.7-.6-2-.7-3.3 0l42.2 39c.5-.7.8-1.4.7-1.9Z" fill="#071F4D"/><path d="M137.8 48.4 179.2 87M140.4 63.2l5.2-3.9M143.6 72.4s3.2-2.1 6.8-2.3M144.8 75.903c.6-.2 5.4-1.8 7.7-1.5M142.3 91.6l4.2-3.8M159.9 103.9l5-6.5" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m206.1 129.6 11.7 16.7 9-7.2-8.7-14.1-9.5 3.3-2.5 1.3Z" fill="#C7DEFF"/><path d="m235.4 134.9-5.2 2.7-2.2-3.2" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M217.9 123.3s4.9-4.1 8.8-1.6c2.4 1.5 2.1-1.1 2.1-1.1l-7.3-7.9-6.1 7.2 2.5 3.4Z" fill="#C7DEFF"/><path d="m216.2 121.1 9.2 15.2" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M237.397 136.399c3.7-1.4 8.5-3.7 8.5-5.4.8.2 1.8-.1 2.6-.7.7-.4 1.3-1 1.3-1.6.1-1.2-27.2-45.1-28.1-46.5-.5-.7-1.8-1.1-3.2-.8-.8.2-1.6.6-2.3 1.4 0 0-3-.4-1.8 11 1.3 11.4 1.2 20.2 1.2 20.2s-2.8 3.5-4.4 5.6c-1.3 1.4 5 3.5 9.3-2 4.1 1.9 12.2 6.9 14.9 17.2v.1c.5 2.1 1.5 1.7 2 1.5Z" fill="#00E4E5"/><path fill-rule="evenodd" clip-rule="evenodd" d="M249.8 128.699c.1-1.2-27.2-45.1-28.1-46.5-.5-.7-1.8-1.1-3.2-.8l30 49c.6-.5 1.2-1.1 1.3-1.7Z" fill="#071F4D"/><path d="m216.5 82.6 29.4 48.4M214.9 97.6l6.1-2.3M215.5 107.395s3.7-1.2 7.2-.4M215.7 111.098c.6 0 5.7-.3 7.8.7M209.1 125.5l5-2.5M222.7 142.1l6.5-4.9" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M208.2 122c-2.5 1-29.9 15.6-47.1 28.8-17.2 13.2-27 23.3-30.3 30.3-1.8 3.6-2.4 4.4-2.4 4.4-1.3-3.4-3.6-10.1-3.9-12.4-.4-4.1 0-9 3.3-14.3 3.3-5.3 27.4-43.9 32.4-51.9-2.4-1.9-8.2-6.8-19.8-16.9-3.4 2.8-35.6 29.9-44.6 38.5-9 8.6-10.8 15.7-10.8 29.3v63.6s25.2 3.9 44.3 2.1c19.1-1.8 44.1-4.1 44.1-4.1s-2.6-29.6 2.9-36.1c5.4-6.5 41.6-34.2 45.5-37.5-3.8-7.5-9-15.8-13.6-23.8Z" fill="#006EFF"/><path d="M154.103 116.7c-9 14.4-23.8 38.1-26.3 42.1-3.3 5.3-3.7 10.2-3.3 14.3M85.1 215.7v-57.9c0-13.6 1.8-20.8 10.8-29.3 1.8-1.7 4.5-4.2 7.8-7.1M197.402 165.1c-9.5 7.6-18.6 15.2-21.1 18.2-5.4 6.5-2.9 36.1-2.9 36.1M123.4 218.3s4.2-30.3 7.5-37.2c3.3-6.9 13-17.1 30.3-30.3 10.5-8.1 24.9-16.6 35-22.3" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M173.1 256.4v-8h-68V264s14.2-5.5 31.1-3.8c29.1 2.9 36.8-3.8 36.8-3.8h.1ZM271.5 255.1h-31.1v9.2s6.5-5.5 14.2-3.8c13.4 2.9 16.9-3.8 16.9-3.8v-1.6Z" fill="#C7DEFF"/><path d="M105.2 245h51.9M240.7 251.7h30.6M165.6 245h7.2M56.7 219.6c10.4 0 17.4.5 23.2 1.1 11.4 1.3 18.4 3.1 39 3.1 31.1 0 31.1-4.3 62.2-4.3s31.2 4.3 51.5 4.3 27.7-5.8 51.5-5.8c15.1 0 20.4 5.8 51.5 5.8" stroke="#071F4D"/><path d="M284.1 218c16.2 0 21.5 5.8 51.5 5.8" stroke="#071F4D"/><path d="M159.5 203.5v6.2M202.4 146.7l-31.6 25.9c-7.1 5.9-11.3 14.7-11.3 23.9M206.7 143.2l2.7-2.3M138.098 118.6l-19.2 24c-7.5 9.4-11.3 21.3-10.6 33.3l2.4 38.5M143.6 111.7l3.2-4" stroke="#DEEBFC"/><path d="m141.8 178.4 6-1.1M94.5 140.6l5.4-4" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M135.2 236.099c7.395-.581 13.632-1.202 19.262-1.762 19.569-1.948 31.803-3.166 59.838.562 24.429 3.262 31.821 1.804 43.661-.53 3.766-.743 7.982-1.574 13.339-2.37 14.976-2.226 25.902.28 35.11 2.393 4.442 1.019 8.485 1.946 12.39 2.207 12 .7 16.8-.3 16.8-.3v-9.6c-.1.04-4.95.99-16.8.3-3.972-.232-8.097-1.176-12.641-2.215-9.183-2.101-20.074-4.593-34.859-2.385-5.305.792-9.493 1.619-13.239 2.358-11.858 2.342-19.291 3.809-43.761.542-28.035-3.728-40.269-2.51-59.838-.562-5.63.56-11.867 1.181-19.262 1.762-15.1 1.2-36.9 1.3-60-2.2-10.8-1.6-18.7-1.1-18.7-1.1v9.6s7.9-.5 18.7 1.1c23.1 3.5 44.9 3.4 60 2.2Z" fill="#C7DEFF"/></svg> |
| New file |
| | |
| | | <svg |
| | | viewBox="0 0 400 300" |
| | | fill="none" |
| | | xmlns="http://www.w3.org/2000/svg" |
| | | ><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="47" y="38" width="307" height="224"><path d="M353.3 38H47.5v223.8h305.8V38Z" fill="#fff"/></mask><g mask="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M299.2 200.6H61.6v5.1h240.3l-2.7-5.1Z" fill="#C7DEFF"/><path d="m308.9 185.8-6.5 20H183.7M332.3 127.6h10.6l-5 16.7-14.8-.1-7.2 21.1M328.8 127.4l13.6-39.6M307.6 166 337 84.7H180.6l-9.8 26.9h-10.5M296.6 196l4.3-11.8M157.2 149.2l6.4-17.7" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M324.8 93.1H188.5l-34.8 95.8h136.4l34.7-95.8ZM169.9 166.2l5-13.6-5 13.6Z" fill="#fff"/><path d="m169.9 166.2 5-13.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M324.8 93.1H188.5l-4 11.7h135.8l4.5-11.7Z" fill="#006EFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M102.6 159.5h38.3l2.7 36.6h-38.4c-10.1 0-20.9-8.2-20.9-18.3 0-10.1 8.2-18.3 18.3-18.3Z" fill="#DEEBFC"/><path fill-rule="evenodd" clip-rule="evenodd" d="M84.3 174.102c2.5 3.4 10 5 17.9 2.8 16.6-6.5 23.8-3.9 23.8-3.9s.5-3.4 1.3-5c-5.8-3-15.4.3-26.1 3.1-10.7 2.8-15.8-2.5-15.8-2.5-.4 0-1.1 2.8-1.1 5.5Z" fill="#fff"/><path d="M96.5 194.2c-7.2-3.3-12.2-10.5-12.2-19m0 0c0-11.5 9.3-20.8 20.8-20.8h29.4" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M140.3 195.1c-8.4-2.7-14.5-10.6-14.5-19.8l14.5 19.8Zm-14.5-19.8c0-11.5 9.3-20.8 20.8-20.8l-20.8 20.8Zm20.8-20.8c11.5 0 20.8 9.3 20.8 20.8l-20.8-20.8Zm20.8 20.8c0 8.4-5 15.6-12.1 18.9l12.1-18.9Z" fill="#fff"/><path d="M140.3 195.1c-8.4-2.7-14.5-10.6-14.5-19.8m0 0c0-11.5 9.3-20.8 20.8-20.8m0 0c11.5 0 20.8 9.3 20.8 20.8m0 0c0 8.4-5 15.6-12.1 18.9" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M161.5 177.2c0-7.7-6.3-14-14-14s-14 6.3-14 14c0 5.8 3.5 10.8 8.6 12.9.1 0 5.8 1.6 10.7 0 5.3-1.7 8.7-7.1 8.7-12.9Z" fill="#00E4E5"/><path d="M140.5 190.1c-5.8-2.4-9.9-8.2-9.9-14.9 0-8.9 7.2-16.1 16.1-16.1 8.9 0 16.1 7.2 16.1 16.1 0 6.8-4.2 12.5-10.1 14.9M88.4 170.604c2.9 1.3 7.7 2.6 13.6.3 14.7-5.7 22.3-4.3 24.6-3.5M84.5 174.599s5.9 6.5 19 1.7c9.2-3.4 15.3-3.9 18.8-3.8" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M340.6 112.3h-55.2l-2.7 6.2H338l2.6-6.2Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M236.8 117.9c-16.13 0-29.2 13.07-29.2 29.2s13.07 29.2 29.2 29.2 29.2-13.07 29.2-29.2-13.07-29.2-29.2-29.2Z" fill="#00E4E5"/><path d="M265 123.3c13.1 13.1 13.1 34.4 0 47.6M306 205.9h19.2M61.7 205.9h32.9M181.2 196.2h115.2M47.5 205.9h10v-9.7h73.8" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M146.7 179.2c-2.49 0-4.5 2.01-4.5 4.5s2.01 4.5 4.5 4.5 4.5-2.01 4.5-4.5-2.01-4.5-4.5-4.5Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M169.5 196.2c3.9 0 7.1 3.2 7.1 7.1 0 3.9-3.2 7.1-7.1 7.1H144c-2.1 0-3.9 1.7-3.9 3.9v1c0 2.1 1.7 3.9 3.9 3.9h48c5.1 0 9.2 4.1 9.2 9.2s-4.1 9.3-9.2 9.2h-33.8c-2.3 0-4.1 1.8-4.1 4.1s1.8 4.1 4.1 4.1h4.2c4.4 0 8 3.6 8 8s-3.6 8-8 8H111c-3.7 0-6.8-3-6.8-6.8 0-3.7 3-6.8 6.8-6.8h.3c2.3 0 4.1-1.8 4.1-4.1s-1.8-4.1-4.1-4.1H79c-4.5 0-8.1-3.6-8.1-8.1s3.6-8.1 8.1-8.1h37.7c2.1 0 3.9-1.7 3.9-3.9 0-2.1-1.7-3.9-3.9-3.9h-7.9c-4.4 0-7.9-3.5-7.9-7.9s3.5-7.9 7.9-7.9h30.4c2.2 0 3.9-1.8 3.9-3.9V187c0-1.9 1.6-3.5 3.5-3.5s3.5 1.6 3.5 3.5v5.3c0 2.2 1.8 3.9 3.9 3.9h15.5Z" fill="#006EFF"/><path d="m227.8 138.5 18.7 18.7M227.8 157.2l18.7-18.7" stroke="#fff" stroke-width="6"/><path fill-rule="evenodd" clip-rule="evenodd" d="M194.8 96.9c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8ZM202.9 96.9c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8Z" fill="#fff"/><path d="m291.7 184.3-1.6 4.6h-121M298.1 166.7l22.5-61.9" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m193 134.1 2.2-5.1h-19.4l-2.3 5.1H193ZM313.2 123.5l2.2-5.1h-24.5l-2.3 5.1h24.6Z" fill="#DEEBFC"/><path d="m164.5 159.2 19.8-54.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M199.6 119.8h-53.2l-4.4 9.3h53.2l4.4-9.3Z" fill="#00E4E5"/><path d="M151.3 129.1H142l4.4-9.3h16.9" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M353.3 169.4h-67.4l-4.8 12.2h67.3l4.9-12.2Z" fill="#006EFF"/><path d="M332.4 169.4h20.9l-4.9 12.2h-39.7M242.7 235.5v-4.8c0-3.8 3.1-7 7-7h20.2c3.8 0 7 3.1 7 7" stroke="#071F4D"/><path d="M261.1 235.5v-4.8c0-3.8 3.1-7 7-7h13.7c3.8 0 7 3.1 7 7v4.8M242.6 230.7h13.7M235.2 237.7h63.3M224 237.7h6.7" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M324.1 141.3H335l3.3-10.7h-10.2l-4 10.7Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M288.3 230.4c0-3.6-2.9-6.5-6.5-6.5h-14.2c-3.6 0-6.5 2.9-6.5 6.5v5.3h27.2v-5.3Z" fill="#071F4D"/><path d="M80.4 228.5H83M87.7 228.5h19.2M146.3 195.8v2c0 3.6-2.9 6.6-6.6 6.6H138M133.4 204.3h1.5M154 249.9h9.4" stroke="#DEEBFC"/><path d="m299.4 141.9 5.1-13.9" stroke="#071F4D"/></g></svg> |
| New file |
| | |
| | | <svg viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg"><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="44" y="42" width="312" height="217"><path d="M355.3 42H44v216.9h311.3V42Z" fill="#fff"/></mask><g mask="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M288.2 248.4h25.1v-30h-25.1v30Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M304.498 238.199c-1.5-3.9-5.9-15.4-4-21.6-2.9.8-3.3.1-5-.1-1.7-.1 0 10.7 2.2 16.4 1.7 4.5 2.1 11.1 2.1 13.6h5.4c.2-1.9.3-5.5-.7-8.3Z" fill="#fff"/><path d="M311.5 214.7v-1.6c0-.7-.6-1.3-1.3-1.3h-22.8c-.7 0-1.3.6-1.3 1.3v1.6" fill="#fff"/><path d="M311.5 214.7v-1.6c0-.7-.6-1.3-1.3-1.3h-22.8c-.7 0-1.3.6-1.3 1.3v1.6M290.2 214.7h21.4c1 0 1.8.8 1.8 1.8v29" stroke="#071F4D" stroke-width="1.096"/><path d="M284.3 245.6v-29c0-1 .8-1.8 1.8-1.8h1.6" fill="#fff"/><path d="M284.3 245.6v-29c0-1 .8-1.8 1.8-1.8h1.6" stroke="#071F4D" stroke-width="1.096"/><path d="M295.402 216.5c-.9 4.2-.4 9.7 2.8 17.5 2.4 5.9 1.9 10.2 1.8 12.3M300.502 216.5c-.9 4.2-.4 9.7 2.8 17.5 2.4 5.9 1.9 10.2 1.8 12.3" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m331 258.4-.3-5.2H88.5l-1.2 5.2H331Z" fill="#C7DEFF"/><path d="M252.9 248.7H331M216.6 258.4H331M47.1 139.3l-2.6 1.5 42.7 117.6h129.2v-6.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m247.2 248.6-40.4-111.3H50.5l40.3 111.3h156.4Z" fill="#fff"/><path d="m247.2 248.6-40.4-111.3H50.5l40.3 111.3h156.4Z" stroke="#071F4D"/><path d="m203.2 153.2 32.2 88.7H97.8l-32.3-88.7" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M72.2 146.9c-.77 0-1.4.63-1.4 1.4 0 .77.63 1.4 1.4 1.4.77 0 1.4-.63 1.4-1.4 0-.77-.63-1.4-1.4-1.4ZM79.3 146.9c-.77 0-1.4.63-1.4 1.4 0 .77.63 1.4 1.4 1.4.77 0 1.4-.63 1.4-1.4 0-.77-.63-1.4-1.4-1.4Z" fill="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M263.5 171.2h80.3v-63.7h-80.3v63.7Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M290 143.9h-45.6l12.5 51.3H290v-51.3Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M286 117.4h-29.3v77.8h92.9v-67.6l-55.9.6-7.7-10.8Z" fill="#00E4E5"/><path d="m332.6 127.6-38.9.6-7.7-10.8h-11.7M308.9 195.2h45.9M250.3 195.2h28.5M287.3 195.2h12.3" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M130.5 211.4H186v-44h-55.5v44Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M148.7 192.5h-31.6l8.7 35.5h22.9v-35.5Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M145.9 174.2h-20.2V228h64.1v-46.7l-38.6.4-5.3-7.5Z" fill="#006EFF"/><path d="m179 181.3-27.8.4-5.3-7.5h-7.7M176.2 201.7h19.2M163.2 210.7H195M172.1 228h-54.2M184.8 228h8.1M174.9 228h5.4" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m293.2 155.7-6.4 6.3 15.3 15.3 22.7-22.6-6.4-6.4-16.3 16.3-8.9-8.9Z" fill="#fff"/><path d="M57.2 258.4h283.6M345.9 258.4h8.1M55.4 258.4h220.5M160.1 118.8l-1.2 2.7M156.7 127c-.3.8-.7 1.8-1.1 2.8M222 68.5c-1 .2-1.9.5-2.9.8M214.1 70.7c-5.8 1.9-11.3 4.4-16.5 7.4M195.4 79.5c-.9.5-1.7 1.1-2.5 1.6M314.2 98.5c-.6-.8-1.3-1.5-2-2.3M308.9 92.8c-4-4-8.3-7.6-13-10.8M293.9 80.7c-.8-.5-1.7-1.1-2.5-1.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M251.296 71.203c-3.6-1.5-18.5-2.9-21.8-1.9-1 5.8 4.9 13.5 4.9 13.5s6-9.9 16.9-11.6Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M251.3 42.704c-6.5 6.7-7.8 13-8.8 19.3 24.4-1.1 36.3 13 42.8 20 3.2-9.1 7.8-23 7.2-29-7.1-6.4-20-11.7-41.2-10.3Z" fill="#C7DEFF"/><path d="M230 69.3c36.2-3.8 52 21.1 52 21.1s11.4-28.2 10.5-37.4c-7.3-6.5-23.3-12-45.6-10.1-9 6.3-15.6 18.7-16.9 26.4Z" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M161.604 70.7c-6 8.4-9.9 21.9-8.8 33.8 8.4 5.3 32.3 10.5 43.6 11.5 6.1-7.9 15.9-26 15.9-26s-32-4.8-50.7-19.3Z" fill="#C7DEFF"/><path d="M193.103 119.5c4.8-2.7 19.2-29.5 19.2-29.5s-35.8-5.4-53.7-21.8c-9.3 6.1-16.4 24.3-15 40.1 10.6 6.7 45.8 13.3 49.5 11.2Z" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M189.5 111.6c-3 5.2-5.7 7.2-9.8 6.6 12.2 2.6 13.5 1.2 15.6-1.1 2.2-2.4 4.2-6.6 4.2-6.6s-3.1 2.5-10 1.1Z" fill="#071F4D"/><path d="M331 251.8v6.6M77 165.4l-2.7-6.7h7.8M222.8 228.9l2.8 6.6h-7.9" stroke="#071F4D"/></g></svg> |
| New file |
| | |
| | | // 全局样式 |
| | | // 顶部进度条颜色 |
| | | #nprogress .bar { |
| | | z-index: 2400; |
| | | background-color: color-mix(in srgb, var(--theme-color) 70%, white); |
| | | } |
| | | |
| | | #nprogress .peg { |
| | | box-shadow: |
| | | 0 0 10px var(--theme-color), |
| | | 0 0 5px var(--theme-color) !important; |
| | | } |
| | | |
| | | #nprogress .spinner-icon { |
| | | border-top-color: var(--theme-color) !important; |
| | | border-left-color: var(--theme-color) !important; |
| | | } |
| | | |
| | | // 处理移动端组件兼容性 |
| | | @media screen and (max-width: 640px) { |
| | | * { |
| | | cursor: default !important; |
| | | } |
| | | } |
| | | |
| | | // 背景滤镜 |
| | | *, |
| | | ::before, |
| | | ::after { |
| | | --tw-backdrop-blur: ; |
| | | --tw-backdrop-brightness: ; |
| | | --tw-backdrop-contrast: ; |
| | | --tw-backdrop-grayscale: ; |
| | | --tw-backdrop-hue-rotate: ; |
| | | --tw-backdrop-invert: ; |
| | | --tw-backdrop-opacity: ; |
| | | --tw-backdrop-saturate: ; |
| | | --tw-backdrop-sepia: ; |
| | | } |
| | | |
| | | // 色弱模式 |
| | | .color-weak { |
| | | filter: invert(80%); |
| | | -webkit-filter: invert(80%); |
| | | } |
| | | |
| | | #noop { |
| | | display: none; |
| | | } |
| | | |
| | | // 语言切换选中样式 |
| | | .langDropDownStyle { |
| | | // 选中项背景颜色 |
| | | .is-selected { |
| | | background-color: var(--art-el-active-color) !important; |
| | | } |
| | | |
| | | // 语言切换按钮菜单样式优化 |
| | | .lang-btn-item { |
| | | .el-dropdown-menu__item { |
| | | padding-left: 13px !important; |
| | | padding-right: 6px !important; |
| | | margin-bottom: 3px !important; |
| | | } |
| | | |
| | | &:last-child { |
| | | .el-dropdown-menu__item { |
| | | margin-bottom: 0 !important; |
| | | } |
| | | } |
| | | |
| | | .menu-txt { |
| | | min-width: 60px; |
| | | display: block; |
| | | } |
| | | |
| | | i { |
| | | font-size: 10px; |
| | | margin-left: 10px; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 盒子默认边框 |
| | | .page-content { |
| | | border: 1px solid var(--art-card-border) !important; |
| | | } |
| | | |
| | | @mixin art-card-base($border-color, $shadow: none, $radius-diff: 4px) { |
| | | background: var(--default-box-color); |
| | | border: 1px solid #{$border-color} !important; |
| | | border-radius: calc(var(--custom-radius) + #{$radius-diff}) !important; |
| | | box-shadow: #{$shadow} !important; |
| | | |
| | | --el-card-border-color: var(--default-border) !important; |
| | | } |
| | | |
| | | .art-card, |
| | | .art-card-sm, |
| | | .art-card-xs { |
| | | border: 1px solid var(--art-card-border); |
| | | } |
| | | |
| | | // 盒子边框 |
| | | [data-box-mode='border-mode'] { |
| | | .page-content, |
| | | .art-table-card { |
| | | border: 1px solid var(--art-card-border) !important; |
| | | } |
| | | |
| | | .art-card { |
| | | @include art-card-base(var(--art-card-border), none, 4px); |
| | | } |
| | | |
| | | .art-card-sm { |
| | | @include art-card-base(var(--art-card-border), none, 0px); |
| | | } |
| | | |
| | | .art-card-xs { |
| | | @include art-card-base(var(--art-card-border), none, -4px); |
| | | } |
| | | } |
| | | |
| | | // 盒子阴影 |
| | | [data-box-mode='shadow-mode'] { |
| | | .page-content, |
| | | .art-table-card { |
| | | box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.04) !important; |
| | | border: 1px solid var(--art-gray-200) !important; |
| | | } |
| | | |
| | | .layout-sidebar { |
| | | border-right: 1px solid var(--art-card-border) !important; |
| | | } |
| | | |
| | | .art-card { |
| | | @include art-card-base( |
| | | var(--art-gray-200), |
| | | (0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)), |
| | | 4px |
| | | ); |
| | | } |
| | | |
| | | .art-card-sm { |
| | | @include art-card-base( |
| | | var(--art-gray-200), |
| | | (0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)), |
| | | 2px |
| | | ); |
| | | } |
| | | |
| | | .art-card-xs { |
| | | @include art-card-base( |
| | | var(--art-gray-200), |
| | | (0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 1px -1px rgba(0, 0, 0, 0.08)), |
| | | -4px |
| | | ); |
| | | } |
| | | } |
| | | |
| | | // 元素全屏 |
| | | .el-full-screen { |
| | | position: fixed; |
| | | top: 0; |
| | | left: 0; |
| | | right: 0; |
| | | width: 100vw !important; |
| | | height: 100% !important; |
| | | z-index: 2300; |
| | | margin-top: 0; |
| | | padding: 15px; |
| | | box-sizing: border-box; |
| | | background-color: var(--default-box-color); |
| | | display: flex; |
| | | flex-direction: column; |
| | | } |
| | | |
| | | // 表格卡片 |
| | | .art-table-card { |
| | | flex: 1; |
| | | display: flex; |
| | | flex-direction: column; |
| | | margin-top: 12px; |
| | | border-radius: calc(var(--custom-radius) / 2 + 2px) !important; |
| | | |
| | | .el-card__body { |
| | | height: 100%; |
| | | overflow: hidden; |
| | | } |
| | | } |
| | | |
| | | // 容器全高 |
| | | .art-full-height { |
| | | height: var(--art-full-height); |
| | | display: flex; |
| | | flex-direction: column; |
| | | |
| | | @media (max-width: 640px) { |
| | | height: auto; |
| | | } |
| | | } |
| | | |
| | | // 徽章样式 |
| | | .art-badge { |
| | | position: absolute; |
| | | top: 0; |
| | | right: 20px; |
| | | bottom: 0; |
| | | width: 6px; |
| | | height: 6px; |
| | | margin: auto; |
| | | background: #ff3860; |
| | | border-radius: 50%; |
| | | animation: breathe 1.5s ease-in-out infinite; |
| | | |
| | | &.art-badge-horizontal { |
| | | right: 0; |
| | | } |
| | | |
| | | &.art-badge-mixed { |
| | | right: 0; |
| | | } |
| | | |
| | | &.art-badge-dual { |
| | | right: 5px; |
| | | top: 5px; |
| | | bottom: auto; |
| | | } |
| | | } |
| | | |
| | | // 文字徽章样式 |
| | | .art-text-badge { |
| | | position: absolute; |
| | | top: 0; |
| | | right: 12px; |
| | | bottom: 0; |
| | | min-width: 20px; |
| | | height: 18px; |
| | | line-height: 17px; |
| | | padding: 0 5px; |
| | | margin: auto; |
| | | font-size: 10px; |
| | | color: #fff; |
| | | text-align: center; |
| | | background: #fd4e4e; |
| | | border-radius: 4px; |
| | | } |
| | | |
| | | @keyframes breathe { |
| | | 0% { |
| | | opacity: 0.7; |
| | | transform: scale(1); |
| | | } |
| | | |
| | | 50% { |
| | | opacity: 1; |
| | | transform: scale(1.1); |
| | | } |
| | | |
| | | 100% { |
| | | opacity: 0.7; |
| | | transform: scale(1); |
| | | } |
| | | } |
| | | |
| | | // 修复老机型 loading 定位问题 |
| | | .art-loading-fix { |
| | | position: fixed !important; |
| | | top: 0 !important; |
| | | left: 0 !important; |
| | | right: 0 !important; |
| | | bottom: 0 !important; |
| | | width: 100vw !important; |
| | | height: 100vh !important; |
| | | display: flex !important; |
| | | align-items: center !important; |
| | | justify-content: center !important; |
| | | } |
| | | |
| | | .art-loading-fix .el-loading-spinner { |
| | | position: static !important; |
| | | top: auto !important; |
| | | left: auto !important; |
| | | transform: none !important; |
| | | } |
| | | |
| | | // 去除移动端点击背景色 |
| | | @media screen and (max-width: 1180px) { |
| | | * { |
| | | -webkit-tap-highlight-color: transparent; |
| | | } |
| | | } |
| New file |
| | |
| | | /* |
| | | * 深色主题 |
| | | * 单页面移除深色主题 document.getElementsByTagName("html")[0].removeAttribute('class') |
| | | */ |
| | | |
| | | $font-color: rgba(#ffffff, 0.85); |
| | | |
| | | /* 覆盖element-plus默认深色背景色 */ |
| | | html.dark { |
| | | // element-plus |
| | | --el-bg-color: var(--default-box-color); |
| | | --el-text-color-regular: #{$font-color}; |
| | | |
| | | // 富文本编辑器 |
| | | // 工具栏背景颜色 |
| | | --w-e-toolbar-bg-color: #18191c; |
| | | // 输入区域背景颜色 |
| | | --w-e-textarea-bg-color: #090909; |
| | | // 工具栏文字颜色 |
| | | --w-e-toolbar-color: var(--art-gray-600); |
| | | // 选中菜单颜色 |
| | | --w-e-toolbar-active-bg-color: #25262b; |
| | | // 弹窗边框颜色 |
| | | --w-e-toolbar-border-color: var(--default-border-dashed); |
| | | // 分割线颜色 |
| | | --w-e-textarea-border-color: var(--default-border-dashed); |
| | | // 链接输入框边框颜色 |
| | | --w-e-modal-button-border-color: var(--default-border-dashed); |
| | | // 表格头颜色 |
| | | --w-e-textarea-slight-bg-color: #090909; |
| | | // 按钮背景颜色 |
| | | --w-e-modal-button-bg-color: #090909; |
| | | // hover toolbar 背景颜色 |
| | | --w-e-toolbar-active-color: var(--art-gray-800); |
| | | } |
| | | |
| | | .dark { |
| | | .page-content .article-list .item .left .outer > div { |
| | | border-right-color: var(--dark-border-color) !important; |
| | | } |
| | | |
| | | // 富文本编辑器 |
| | | .editor-wrapper { |
| | | *:not(pre code *) { |
| | | color: inherit !important; |
| | | } |
| | | } |
| | | // 分隔线 |
| | | .w-e-bar-divider { |
| | | background-color: var(--art-gray-300) !important; |
| | | } |
| | | |
| | | .w-e-select-list, |
| | | .w-e-drop-panel, |
| | | .w-e-bar-item-group .w-e-bar-item-menus-container, |
| | | .w-e-text-container [data-slate-editor] pre > code { |
| | | border: 1px solid var(--default-border) !important; |
| | | } |
| | | |
| | | // 下拉选择框 |
| | | .w-e-select-list { |
| | | background-color: var(--default-box-color) !important; |
| | | } |
| | | |
| | | /* 下拉选择框 hover 样式调整 */ |
| | | .w-e-select-list ul li:hover, |
| | | /* 工具栏 hover 按钮背景颜色 */ |
| | | .w-e-bar-item button:hover { |
| | | background-color: #090909 !important; |
| | | } |
| | | |
| | | /* 代码块 */ |
| | | .w-e-text-container [data-slate-editor] pre > code { |
| | | background-color: #25262b !important; |
| | | text-shadow: none !important; |
| | | } |
| | | |
| | | /* 引用 */ |
| | | .w-e-text-container [data-slate-editor] blockquote { |
| | | border-left: 4px solid var(--default-border-dashed) !important; |
| | | background-color: var(--art-color); |
| | | } |
| | | |
| | | .editor-wrapper { |
| | | .w-e-text-container [data-slate-editor] .table-container th:last-of-type { |
| | | border-right: 1px solid var(--default-border-dashed) !important; |
| | | } |
| | | |
| | | .w-e-modal { |
| | | background-color: var(--art-color); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | // 导入暗黑主题 |
| | | @use 'element-plus/theme-chalk/src/dark/css-vars.scss' as *; |
| New file |
| | |
| | | // https://github.com/element-plus/element-plus/blob/dev/packages/theme-chalk/src/common/var.scss |
| | | // 自定义Element 亮色主题 |
| | | |
| | | @forward 'element-plus/theme-chalk/src/common/var.scss' with ( |
| | | $colors: ( |
| | | 'white': #ffffff, |
| | | 'black': #000000, |
| | | 'success': ( |
| | | 'base': #13deb9 |
| | | ), |
| | | 'warning': ( |
| | | 'base': #ffae1f |
| | | ), |
| | | 'danger': ( |
| | | 'base': #ff4d4f |
| | | ), |
| | | 'error': ( |
| | | 'base': #fa896b |
| | | ) |
| | | ), |
| | | $button: ( |
| | | 'hover-bg-color': var(--el-color-primary-light-9), |
| | | 'hover-border-color': var(--el-color-primary), |
| | | 'border-color': var(--el-color-primary), |
| | | 'text-color': var(--el-color-primary) |
| | | ), |
| | | $messagebox: ( |
| | | 'border-radius': '12px' |
| | | ), |
| | | $popover: ( |
| | | 'padding': '14px', |
| | | 'border-radius': '10px' |
| | | ) |
| | | ); |
| New file |
| | |
| | | // 优化 Element Plus 组件库默认样式 |
| | | |
| | | :root { |
| | | // 系统主色 |
| | | --main-color: var(--el-color-primary); |
| | | --el-color-white: white !important; |
| | | --el-color-black: white !important; |
| | | // 输入框边框颜色 |
| | | // --el-border-color: #E4E4E7 !important; // DCDFE6 |
| | | // 按钮粗度 |
| | | --el-font-weight-primary: 400 !important; |
| | | |
| | | --el-component-custom-height: 36px !important; |
| | | |
| | | --el-component-size: var(--el-component-custom-height) !important; |
| | | |
| | | // 边框、按钮圆角... |
| | | --el-border-radius-base: calc(var(--custom-radius) / 3 + 2px) !important; |
| | | |
| | | --el-border-radius-small: calc(var(--custom-radius) / 3 + 4px) !important; |
| | | --el-messagebox-border-radius: calc(var(--custom-radius) / 3 + 4px) !important; |
| | | --el-popover-border-radius: calc(var(--custom-radius) / 3 + 4px) !important; |
| | | |
| | | .region .el-radio-button__original-radio:checked + .el-radio-button__inner { |
| | | color: var(--theme-color); |
| | | } |
| | | } |
| | | |
| | | // 优化 el-form-item 标签高度 |
| | | .el-form-item__label { |
| | | height: var(--el-component-custom-height) !important; |
| | | line-height: var(--el-component-custom-height) !important; |
| | | } |
| | | |
| | | // 日期选择器 |
| | | .el-date-range-picker { |
| | | --el-datepicker-inrange-bg-color: var(--art-gray-200) !important; |
| | | } |
| | | |
| | | // el-card 背景色跟系统背景色保持一致 |
| | | html.dark .el-card { |
| | | --el-card-bg-color: var(--default-box-color) !important; |
| | | } |
| | | |
| | | // 修改 el-pagination 大小 |
| | | .el-pagination--default { |
| | | & { |
| | | --el-pagination-button-width: 32px !important; |
| | | --el-pagination-button-height: var(--el-pagination-button-width) !important; |
| | | } |
| | | |
| | | @media (max-width: 1180px) { |
| | | & { |
| | | --el-pagination-button-width: 28px !important; |
| | | } |
| | | } |
| | | |
| | | .el-select--default .el-select__wrapper { |
| | | min-height: var(--el-pagination-button-width) !important; |
| | | } |
| | | |
| | | .el-pagination__jump .el-input { |
| | | height: var(--el-pagination-button-width) !important; |
| | | } |
| | | } |
| | | |
| | | .el-pager li { |
| | | padding: 0 10px !important; |
| | | // border: 1px solid red !important; |
| | | } |
| | | |
| | | // 优化菜单折叠展开动画(提升动画流畅度) |
| | | .el-menu.el-menu--inline { |
| | | transition: max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1) !important; |
| | | } |
| | | |
| | | // 优化菜单 item hover 动画(提升鼠标跟手感) |
| | | .el-sub-menu__title, |
| | | .el-menu-item { |
| | | transition: background-color 0s !important; |
| | | } |
| | | |
| | | // -------------------------------- 修改 el-size=default 组件默认高度 start -------------------------------- |
| | | // 修改 el-button 高度 |
| | | .el-button--default { |
| | | height: var(--el-component-custom-height) !important; |
| | | } |
| | | |
| | | // circle 按钮宽度优化 |
| | | .el-button--default.is-circle { |
| | | width: var(--el-component-custom-height) !important; |
| | | } |
| | | |
| | | // 修改 el-select 高度 |
| | | .el-select--default { |
| | | .el-select__wrapper { |
| | | min-height: var(--el-component-custom-height) !important; |
| | | } |
| | | } |
| | | |
| | | // 修改 el-checkbox-button 高度 |
| | | .el-checkbox-button--default .el-checkbox-button__inner, |
| | | // 修改 el-radio-button 高度 |
| | | .el-radio-button--default .el-radio-button__inner { |
| | | padding: 10px 15px !important; |
| | | } |
| | | // -------------------------------- 修改 el-size=default 组件默认高度 end -------------------------------- |
| | | |
| | | .el-pagination.is-background .btn-next, |
| | | .el-pagination.is-background .btn-prev, |
| | | .el-pagination.is-background .el-pager li { |
| | | border-radius: 6px; |
| | | } |
| | | |
| | | .el-popover { |
| | | min-width: 80px; |
| | | border-radius: var(--el-border-radius-small) !important; |
| | | } |
| | | |
| | | .el-dialog { |
| | | border-radius: 100px !important; |
| | | border-radius: calc(var(--custom-radius) / 1.2 + 2px) !important; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | .el-dialog__header { |
| | | .el-dialog__title { |
| | | font-size: 16px; |
| | | } |
| | | } |
| | | |
| | | .el-dialog__body { |
| | | padding: 25px 0 !important; |
| | | position: relative; // 为了兼容 el-pagination 样式,需要设置 relative,不然会影响 el-pagination 的样式,比如 el-pagination__jump--small 会被影响,导致 el-pagination__jump--small 按钮无法点击,详见 URL_ADDRESS.com/element-plus/element-plus/issues/5684#issuecomment-1176299275; |
| | | } |
| | | |
| | | .el-dialog.el-dialog-border { |
| | | .el-dialog__body { |
| | | // 上边框 |
| | | &::before, |
| | | // 下边框 |
| | | &::after { |
| | | content: ''; |
| | | position: absolute; |
| | | left: -16px; |
| | | width: calc(100% + 32px); |
| | | height: 1px; |
| | | background-color: var(--art-gray-300); |
| | | } |
| | | |
| | | &::before { |
| | | top: 0; |
| | | } |
| | | |
| | | &::after { |
| | | bottom: 0; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // el-message 样式优化 |
| | | .el-message { |
| | | background-color: var(--default-box-color) !important; |
| | | border: 0 !important; |
| | | box-shadow: |
| | | 0 6px 16px 0 rgba(0, 0, 0, 0.08), |
| | | 0 3px 6px -4px rgba(0, 0, 0, 0.12), |
| | | 0 9px 28px 8px rgba(0, 0, 0, 0.05) !important; |
| | | |
| | | p { |
| | | font-size: 13px; |
| | | } |
| | | } |
| | | |
| | | // 修改 el-dropdown 样式 |
| | | .el-dropdown-menu { |
| | | padding: 6px !important; |
| | | border-radius: 10px !important; |
| | | border: none !important; |
| | | |
| | | .el-dropdown-menu__item { |
| | | padding: 6px 16px !important; |
| | | border-radius: 6px !important; |
| | | |
| | | &:hover:not(.is-disabled) { |
| | | color: var(--art-gray-900) !important; |
| | | background-color: var(--art-el-active-color) !important; |
| | | } |
| | | |
| | | &:focus:not(.is-disabled) { |
| | | color: var(--art-gray-900) !important; |
| | | background-color: var(--art-gray-200) !important; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 隐藏 select、dropdown 的三角 |
| | | .el-select__popper, |
| | | .el-dropdown__popper { |
| | | margin-top: -6px !important; |
| | | |
| | | .el-popper__arrow { |
| | | display: none; |
| | | } |
| | | } |
| | | |
| | | .el-dropdown-selfdefine:focus { |
| | | outline: none !important; |
| | | } |
| | | |
| | | // 处理移动端组件兼容性 |
| | | @media screen and (max-width: 640px) { |
| | | .el-message-box, |
| | | .el-dialog { |
| | | width: calc(100% - 24px) !important; |
| | | } |
| | | |
| | | .el-date-picker.has-sidebar.has-time { |
| | | width: calc(100% - 24px); |
| | | left: 12px !important; |
| | | } |
| | | |
| | | .el-picker-panel *[slot='sidebar'], |
| | | .el-picker-panel__sidebar { |
| | | display: none; |
| | | } |
| | | |
| | | .el-picker-panel *[slot='sidebar'] + .el-picker-panel__body, |
| | | .el-picker-panel__sidebar + .el-picker-panel__body { |
| | | margin-left: 0; |
| | | } |
| | | } |
| | | |
| | | // 修改el-button样式 |
| | | .el-button { |
| | | &.el-button--text { |
| | | background-color: transparent !important; |
| | | padding: 0 !important; |
| | | |
| | | span { |
| | | margin-left: 0 !important; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 修改el-tag样式 |
| | | .el-tag { |
| | | font-weight: 500; |
| | | transition: all 0s !important; |
| | | |
| | | &.el-tag--default { |
| | | height: 26px !important; |
| | | } |
| | | } |
| | | |
| | | .el-checkbox-group { |
| | | &.el-table-filter__checkbox-group label.el-checkbox { |
| | | height: 17px !important; |
| | | |
| | | .el-checkbox__label { |
| | | font-weight: 400 !important; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .el-radio--default { |
| | | // 优化单选按钮大小 |
| | | .el-radio__input { |
| | | .el-radio__inner { |
| | | width: 16px; |
| | | height: 16px; |
| | | |
| | | &::after { |
| | | width: 6px; |
| | | height: 6px; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | .el-checkbox { |
| | | .el-checkbox__inner { |
| | | border-radius: 2px !important; |
| | | } |
| | | } |
| | | |
| | | // 优化复选框样式 |
| | | .el-checkbox--default { |
| | | .el-checkbox__inner { |
| | | width: 16px !important; |
| | | height: 16px !important; |
| | | border-radius: 4px !important; |
| | | |
| | | &::before { |
| | | content: ''; |
| | | height: 4px !important; |
| | | top: 5px !important; |
| | | background-color: #fff !important; |
| | | transform: scale(0.6) !important; |
| | | } |
| | | } |
| | | |
| | | .is-checked { |
| | | .el-checkbox__inner { |
| | | &::after { |
| | | width: 3px; |
| | | height: 8px; |
| | | margin: auto; |
| | | border: 2px solid var(--el-checkbox-checked-icon-color); |
| | | border-left: 0; |
| | | border-top: 0; |
| | | transform: translate(-45%, -60%) rotate(45deg) scale(0.86) !important; |
| | | transform-origin: center; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | .el-notification .el-notification__icon { |
| | | font-size: 22px !important; |
| | | } |
| | | |
| | | // 修改 el-message-box 样式 |
| | | .el-message-box__headerbtn .el-message-box__close, |
| | | .el-dialog__headerbtn .el-dialog__close { |
| | | top: 7px; |
| | | right: 7px; |
| | | width: 30px; |
| | | height: 30px; |
| | | border-radius: 5px; |
| | | transition: all 0.3s; |
| | | |
| | | &:hover { |
| | | background-color: var(--art-hover-color) !important; |
| | | color: var(--art-gray-900) !important; |
| | | } |
| | | } |
| | | |
| | | .el-message-box { |
| | | padding: 25px 20px !important; |
| | | } |
| | | |
| | | .el-message-box__title { |
| | | font-weight: 500 !important; |
| | | } |
| | | |
| | | .el-table__column-filter-trigger i { |
| | | color: var(--theme-color) !important; |
| | | margin: -3px 0 0 2px; |
| | | } |
| | | |
| | | // 去除 el-dropdown 鼠标放上去出现的边框 |
| | | .el-tooltip__trigger:focus-visible { |
| | | outline: unset; |
| | | } |
| | | |
| | | // ipad 表单右侧按钮优化 |
| | | @media screen and (max-width: 1180px) { |
| | | .el-table-fixed-column--right { |
| | | padding-right: 0 !important; |
| | | } |
| | | } |
| | | |
| | | .login-out-dialog { |
| | | padding: 30px 20px !important; |
| | | border-radius: 10px !important; |
| | | } |
| | | |
| | | // 修改 dialog 动画 |
| | | .dialog-fade-enter-active { |
| | | .el-dialog:not(.is-draggable) { |
| | | animation: dialog-open 0.3s cubic-bezier(0.32, 0.14, 0.15, 0.86); |
| | | |
| | | // 修复 el-dialog 动画后宽度不自适应问题 |
| | | .el-select__selected-item { |
| | | display: inline-block; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .dialog-fade-leave-active { |
| | | animation: fade-out 0.2s linear; |
| | | |
| | | .el-dialog:not(.is-draggable) { |
| | | animation: dialog-close 0.5s; |
| | | } |
| | | } |
| | | |
| | | @keyframes dialog-open { |
| | | 0% { |
| | | opacity: 0; |
| | | transform: scale(0.2); |
| | | } |
| | | |
| | | 100% { |
| | | opacity: 1; |
| | | transform: scale(1); |
| | | } |
| | | } |
| | | |
| | | @keyframes dialog-close { |
| | | 0% { |
| | | opacity: 1; |
| | | transform: scale(1); |
| | | } |
| | | |
| | | 100% { |
| | | opacity: 0; |
| | | transform: scale(0.2); |
| | | } |
| | | } |
| | | |
| | | // 遮罩层动画 |
| | | @keyframes fade-out { |
| | | 0% { |
| | | opacity: 1; |
| | | } |
| | | |
| | | 100% { |
| | | opacity: 0; |
| | | } |
| | | } |
| | | |
| | | // 修改 el-select 样式 |
| | | .el-select__popper:not(.el-tree-select__popper) { |
| | | .el-select-dropdown__list { |
| | | padding: 5px !important; |
| | | |
| | | .el-select-dropdown__item { |
| | | height: 34px !important; |
| | | line-height: 34px !important; |
| | | border-radius: 6px !important; |
| | | |
| | | &.is-selected { |
| | | color: var(--art-gray-900) !important; |
| | | font-weight: 400 !important; |
| | | background-color: var(--art-el-active-color) !important; |
| | | margin-bottom: 4px !important; |
| | | } |
| | | |
| | | &:hover { |
| | | background-color: var(--art-hover-color) !important; |
| | | } |
| | | } |
| | | |
| | | .el-select-dropdown__item:hover ~ .is-selected, |
| | | .el-select-dropdown__item.is-selected:has(~ .el-select-dropdown__item:hover) { |
| | | background-color: transparent !important; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 修改 el-tree-select 样式 |
| | | .el-tree-select__popper { |
| | | .el-select-dropdown__list { |
| | | padding: 5px !important; |
| | | |
| | | .el-tree-node { |
| | | .el-tree-node__content { |
| | | height: 36px !important; |
| | | border-radius: 6px !important; |
| | | |
| | | &:hover { |
| | | background-color: var(--art-gray-200) !important; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 实现水波纹在文字下面效果 |
| | | .el-button > span { |
| | | position: relative; |
| | | z-index: 10; |
| | | } |
| | | |
| | | // 优化颜色选择器圆角 |
| | | .el-color-picker__color { |
| | | border-radius: 2px !important; |
| | | } |
| | | |
| | | // 优化日期时间选择器底部圆角 |
| | | .el-picker-panel { |
| | | .el-picker-panel__footer { |
| | | border-radius: 0 0 var(--el-border-radius-base) var(--el-border-radius-base); |
| | | } |
| | | } |
| | | |
| | | // 优化树型菜单样式 |
| | | .el-tree-node__content { |
| | | border-radius: 4px; |
| | | margin-bottom: 4px; |
| | | padding: 1px 0; |
| | | |
| | | &:hover { |
| | | background-color: var(--art-hover-color) !important; |
| | | } |
| | | } |
| | | |
| | | .dark { |
| | | .el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content { |
| | | background-color: var(--art-gray-300) !important; |
| | | } |
| | | } |
| | | |
| | | // 隐藏折叠菜单弹窗 hover 出现的边框 |
| | | .menu-left-popper:focus-within, |
| | | .horizontal-menu-popper:focus-within { |
| | | box-shadow: none !important; |
| | | outline: none !important; |
| | | } |
| | | |
| | | // 数字输入组件右侧按钮高度跟随自定义组件高度 |
| | | .el-input-number--default.is-controls-right { |
| | | .el-input-number__decrease, |
| | | .el-input-number__increase { |
| | | height: calc((var(--el-component-size) / 2)) !important; |
| | | } |
| | | } |
| New file |
| | |
| | | /* 文章标题设置(h1-h6)*/ |
| | | /* ------------------------------------------------ */ |
| | | $font-color: #24292e; |
| | | |
| | | .markdown-body h1, |
| | | .markdown-body h2, |
| | | .markdown-body h3, |
| | | .markdown-body h4, |
| | | .markdown-body h5, |
| | | .markdown-body h6 { |
| | | color: var(--art-gray-800) !important; |
| | | margin: 30px 0 10px 0; |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .markdown-body h1 { |
| | | font-size: 30px; |
| | | } |
| | | |
| | | @media only screen and (max-width: 550px) { |
| | | .markdown-body h1 { |
| | | font-size: 26px; |
| | | } |
| | | |
| | | .markdown-body h2 { |
| | | font-size: 22px; |
| | | } |
| | | |
| | | .markdown-body h3 { |
| | | font-size: 18px; |
| | | } |
| | | } |
| | | |
| | | /* 块引用 */ |
| | | /* ------------------------------------------------ */ |
| | | .markdown-body blockquote { |
| | | color: rgba(60, 60, 67, 0.7); |
| | | font-size: 15px !important; |
| | | border-left: 0.18em solid #e7e7e8; |
| | | background: #f8f8f8; |
| | | padding: 15px 1em; |
| | | font-weight: 400 !important; |
| | | } |
| | | |
| | | /* 详情页文章字体颜色 */ |
| | | /* ------------------------------------------------ */ |
| | | .markdown-body p { |
| | | line-height: 28px; |
| | | margin-bottom: 10px; |
| | | } |
| | | |
| | | .markdown-body li, |
| | | .markdown-body p { |
| | | color: var(--art-gray-800) !important; |
| | | font-size: 16px !important; |
| | | } |
| | | |
| | | .dark .markdown-body li span { |
| | | color: var(--art-gray-800) !important; |
| | | background-color: transparent !important; |
| | | } |
| | | |
| | | .dark .markdown-body p span { |
| | | color: var(--art-gray-800) !important; |
| | | background-color: transparent !important; |
| | | } |
| | | |
| | | .line-numbers-mode { |
| | | background-color: var(--art-code-bg); |
| | | border-radius: 8px; |
| | | position: relative; |
| | | padding-left: 32px; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .line-numbers-mode pre { |
| | | flex: 1; |
| | | border-radius: 0 8px 8px 0; |
| | | background-color: var(--art-code-bg); |
| | | } |
| | | |
| | | .line-numbers-mode .line-numbers-wrapper { |
| | | width: 32px; |
| | | height: 100%; |
| | | text-align: center; |
| | | padding: 16px 0; |
| | | box-sizing: border-box; |
| | | border-right: 1px solid #000000; |
| | | position: absolute; |
| | | left: 0; |
| | | top: 0; |
| | | } |
| | | |
| | | .line-numbers-mode .line-numbers-wrapper span { |
| | | height: 23.6px; |
| | | line-height: 23.6px; |
| | | display: block; |
| | | color: #72747b; |
| | | font-size: 13px; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .line-numbers-mode .copy-btn { |
| | | display: inline-block; |
| | | display: flex; |
| | | position: absolute; |
| | | right: 10px; |
| | | top: 10px; |
| | | cursor: pointer; |
| | | opacity: 0; |
| | | background-color: #000; |
| | | border-radius: 5px; |
| | | text-align: center; |
| | | color: rgba(255, 255, 255, 0.6); |
| | | transition: opacity 0.3s; |
| | | } |
| | | |
| | | .line-numbers-mode .copy-btn div { |
| | | width: 34px; |
| | | height: 34px; |
| | | line-height: 34px; |
| | | cursor: pointer; |
| | | text-align: center; |
| | | font-size: 20px; |
| | | } |
| | | |
| | | .line-numbers-mode:hover .copy-btn { |
| | | opacity: 1; |
| | | } |
| | | |
| | | .line-numbers-mode .copy-btn span { |
| | | height: 34px; |
| | | line-height: 34px; |
| | | font-size: 13px; |
| | | padding-left: 10px; |
| | | display: none; |
| | | } |
| | | |
| | | .line-numbers-mode .copy-btn .show-copy { |
| | | opacity: 1; |
| | | display: block; |
| | | } |
| | | |
| | | .line-numbers-mode ::-webkit-scrollbar-track { |
| | | background-color: #292b30 !important; |
| | | } |
| | | |
| | | .markdown-body .anchor { |
| | | float: left; |
| | | line-height: 1; |
| | | margin-left: -20px; |
| | | padding-right: 4px; |
| | | } |
| | | |
| | | .markdown-body .anchor:focus { |
| | | outline: none; |
| | | } |
| | | |
| | | .markdown-body h1 .octicon-link, |
| | | .markdown-body h2 .octicon-link, |
| | | .markdown-body h3 .octicon-link, |
| | | .markdown-body h4 .octicon-link, |
| | | .markdown-body h5 .octicon-link, |
| | | .markdown-body h6 .octicon-link { |
| | | color: #1b1f23; |
| | | vertical-align: middle; |
| | | visibility: hidden; |
| | | } |
| | | |
| | | .markdown-body h1:hover .anchor, |
| | | .markdown-body h2:hover .anchor, |
| | | .markdown-body h3:hover .anchor, |
| | | .markdown-body h4:hover .anchor, |
| | | .markdown-body h5:hover .anchor, |
| | | .markdown-body h6:hover .anchor { |
| | | text-decoration: none; |
| | | } |
| | | |
| | | .markdown-body h1:hover .anchor .octicon-link, |
| | | .markdown-body h2:hover .anchor .octicon-link, |
| | | .markdown-body h3:hover .anchor .octicon-link, |
| | | .markdown-body h4:hover .anchor .octicon-link, |
| | | .markdown-body h5:hover .anchor .octicon-link, |
| | | .markdown-body h6:hover .anchor .octicon-link { |
| | | visibility: visible; |
| | | } |
| | | |
| | | .markdown-body h1:hover .anchor .octicon-link:before, |
| | | .markdown-body h2:hover .anchor .octicon-link:before, |
| | | .markdown-body h3:hover .anchor .octicon-link:before, |
| | | .markdown-body h4:hover .anchor .octicon-link:before, |
| | | .markdown-body h5:hover .anchor .octicon-link:before, |
| | | .markdown-body h6:hover .anchor .octicon-link:before { |
| | | width: 16px; |
| | | height: 16px; |
| | | content: ' '; |
| | | display: inline-block; |
| | | } |
| | | |
| | | .markdown-body { |
| | | -ms-text-size-adjust: 100%; |
| | | -webkit-text-size-adjust: 100%; |
| | | line-height: 1.5; |
| | | color: $font-color; |
| | | font-size: 16px; |
| | | line-height: 1.5; |
| | | word-wrap: break-word; |
| | | } |
| | | |
| | | .markdown-body details { |
| | | display: block; |
| | | } |
| | | |
| | | .markdown-body summary { |
| | | display: list-item; |
| | | } |
| | | |
| | | .markdown-body a { |
| | | background-color: initial; |
| | | } |
| | | |
| | | .markdown-body a:active, |
| | | .markdown-body a:hover { |
| | | outline-width: 0; |
| | | } |
| | | |
| | | .markdown-body strong { |
| | | font-weight: inherit; |
| | | font-weight: bolder; |
| | | } |
| | | |
| | | .markdown-body p br { |
| | | display: inline; |
| | | line-height: 11px; |
| | | } |
| | | |
| | | .markdown-body img { |
| | | border-style: none; |
| | | } |
| | | |
| | | .markdown-body hr { |
| | | box-sizing: initial; |
| | | height: 0; |
| | | overflow: visible; |
| | | } |
| | | |
| | | .markdown-body input { |
| | | font: inherit; |
| | | margin: 0; |
| | | } |
| | | |
| | | .markdown-body input { |
| | | overflow: visible; |
| | | } |
| | | |
| | | .markdown-body [type='checkbox'] { |
| | | box-sizing: border-box; |
| | | padding: 0; |
| | | } |
| | | |
| | | .markdown-body * { |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .markdown-body input { |
| | | font-size: inherit; |
| | | line-height: inherit; |
| | | } |
| | | |
| | | .markdown-body a { |
| | | color: #0366d6; |
| | | text-decoration: none; |
| | | } |
| | | |
| | | .markdown-body a:hover { |
| | | text-decoration: underline; |
| | | } |
| | | |
| | | .markdown-body strong { |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .markdown-body hr { |
| | | height: 0; |
| | | margin: 15px 0; |
| | | overflow: hidden; |
| | | background: transparent; |
| | | border: 0; |
| | | border-bottom: 1px solid #dfe2e5; |
| | | } |
| | | |
| | | .markdown-body hr:after, |
| | | .markdown-body hr:before { |
| | | display: table; |
| | | content: ''; |
| | | } |
| | | |
| | | .markdown-body hr:after { |
| | | clear: both; |
| | | } |
| | | |
| | | .markdown-body table { |
| | | border-spacing: 0; |
| | | border-collapse: collapse; |
| | | } |
| | | |
| | | .markdown-body td, |
| | | .markdown-body th { |
| | | padding: 0; |
| | | } |
| | | |
| | | .markdown-body details summary { |
| | | cursor: pointer; |
| | | } |
| | | |
| | | .markdown-body kbd { |
| | | display: inline-block; |
| | | padding: 3px 5px; |
| | | font: |
| | | 11px SFMono-Regular, |
| | | Consolas, |
| | | Liberation Mono, |
| | | Menlo, |
| | | monospace; |
| | | line-height: 10px; |
| | | color: #444d56; |
| | | vertical-align: middle; |
| | | background-color: #fafbfc; |
| | | border: 1px solid #d1d5da; |
| | | border-radius: 3px; |
| | | box-shadow: inset 0 -1px 0 #d1d5da; |
| | | } |
| | | |
| | | .markdown-body blockquote { |
| | | margin: 0; |
| | | } |
| | | |
| | | .markdown-body ol, |
| | | .markdown-body ul { |
| | | padding-left: 0; |
| | | margin-top: 0; |
| | | margin-bottom: 0; |
| | | } |
| | | |
| | | .markdown-body ol ol, |
| | | .markdown-body ul ol { |
| | | list-style-type: lower-roman; |
| | | } |
| | | |
| | | .markdown-body ol ol ol, |
| | | .markdown-body ol ul ol, |
| | | .markdown-body ul ol ol, |
| | | .markdown-body ul ul ol { |
| | | list-style-type: lower-alpha; |
| | | } |
| | | |
| | | .markdown-body dd { |
| | | margin-left: 0; |
| | | } |
| | | |
| | | .markdown-body code, |
| | | .markdown-body pre, |
| | | .markdown-body .line-number { |
| | | font-size: 14px !important; |
| | | border-radius: 8px; |
| | | background-color: #282c34; |
| | | } |
| | | |
| | | .dark { |
| | | .markdown-body code, |
| | | .markdown-body pre, |
| | | .markdown-body .line-number { |
| | | background-color: #252525; |
| | | } |
| | | } |
| | | |
| | | .markdown-body pre { |
| | | margin-top: 0; |
| | | margin-bottom: 0; |
| | | } |
| | | |
| | | .markdown-body input::-webkit-inner-spin-button, |
| | | .markdown-body input::-webkit-outer-spin-button { |
| | | margin: 0; |
| | | -webkit-appearance: none; |
| | | appearance: none; |
| | | } |
| | | |
| | | .markdown-body :checked + .radio-label { |
| | | position: relative; |
| | | z-index: 1; |
| | | border-color: #0366d6; |
| | | } |
| | | |
| | | .markdown-body .border { |
| | | border: 1px solid #e1e4e8 !important; |
| | | } |
| | | |
| | | .markdown-body .border-0 { |
| | | border: 0 !important; |
| | | } |
| | | |
| | | .markdown-body .border-bottom { |
| | | border-bottom: 1px solid #e1e4e8 !important; |
| | | } |
| | | |
| | | .markdown-body .rounded-1 { |
| | | border-radius: 3px !important; |
| | | } |
| | | |
| | | .markdown-body .bg-white { |
| | | background-color: #fff !important; |
| | | } |
| | | |
| | | .markdown-body .bg-gray-light { |
| | | background-color: #fafbfc !important; |
| | | } |
| | | |
| | | .markdown-body .text-gray-light { |
| | | color: #6a737d !important; |
| | | } |
| | | |
| | | .markdown-body .mb-0 { |
| | | margin-bottom: 0 !important; |
| | | } |
| | | |
| | | .markdown-body .my-2 { |
| | | margin-top: 8px !important; |
| | | margin-bottom: 8px !important; |
| | | } |
| | | |
| | | .markdown-body .pl-0 { |
| | | padding-left: 0 !important; |
| | | } |
| | | |
| | | .markdown-body .py-0 { |
| | | padding-top: 0 !important; |
| | | padding-bottom: 0 !important; |
| | | } |
| | | |
| | | .markdown-body .pl-1 { |
| | | padding-left: 4px !important; |
| | | } |
| | | |
| | | .markdown-body .pl-2 { |
| | | padding-left: 8px !important; |
| | | } |
| | | |
| | | .markdown-body .py-2 { |
| | | padding-top: 8px !important; |
| | | padding-bottom: 8px !important; |
| | | } |
| | | |
| | | .markdown-body .pl-3, |
| | | .markdown-body .px-3 { |
| | | padding-left: 16px !important; |
| | | } |
| | | |
| | | .markdown-body .px-3 { |
| | | padding-right: 16px !important; |
| | | } |
| | | |
| | | .markdown-body .pl-4 { |
| | | padding-left: 24px !important; |
| | | } |
| | | |
| | | .markdown-body .pl-5 { |
| | | padding-left: 32px !important; |
| | | } |
| | | |
| | | .markdown-body .pl-6 { |
| | | padding-left: 40px !important; |
| | | } |
| | | |
| | | .markdown-body .f6 { |
| | | font-size: 12px !important; |
| | | } |
| | | |
| | | .markdown-body .lh-condensed { |
| | | line-height: 1.25 !important; |
| | | } |
| | | |
| | | .markdown-body .text-bold { |
| | | font-weight: 600 !important; |
| | | } |
| | | |
| | | .markdown-body .pl-c { |
| | | color: #6a737d; |
| | | } |
| | | |
| | | .markdown-body .pl-c1, |
| | | .markdown-body .pl-s .pl-v { |
| | | color: #005cc5; |
| | | } |
| | | |
| | | .markdown-body .pl-e, |
| | | .markdown-body .pl-en { |
| | | color: #6f42c1; |
| | | } |
| | | |
| | | .markdown-body .pl-s .pl-s1, |
| | | .markdown-body .pl-smi { |
| | | color: $font-color; |
| | | } |
| | | |
| | | .markdown-body .pl-ent { |
| | | color: #22863a; |
| | | } |
| | | |
| | | .markdown-body .pl-k { |
| | | color: #d73a49; |
| | | } |
| | | |
| | | .markdown-body .pl-pds, |
| | | .markdown-body .pl-s, |
| | | .markdown-body .pl-s .pl-pse .pl-s1, |
| | | .markdown-body .pl-sr, |
| | | .markdown-body .pl-sr .pl-cce, |
| | | .markdown-body .pl-sr .pl-sra, |
| | | .markdown-body .pl-sr .pl-sre { |
| | | color: #032f62; |
| | | } |
| | | |
| | | .markdown-body .pl-smw, |
| | | .markdown-body .pl-v { |
| | | color: #e36209; |
| | | } |
| | | |
| | | .markdown-body .pl-bu { |
| | | color: #b31d28; |
| | | } |
| | | |
| | | .markdown-body .pl-ii { |
| | | color: #fafbfc; |
| | | background-color: #b31d28; |
| | | } |
| | | |
| | | .markdown-body .pl-c2 { |
| | | color: #fafbfc; |
| | | background-color: #d73a49; |
| | | } |
| | | |
| | | .markdown-body .pl-c2:before { |
| | | content: '^M'; |
| | | } |
| | | |
| | | .markdown-body .pl-sr .pl-cce { |
| | | font-weight: 700; |
| | | color: #22863a; |
| | | } |
| | | |
| | | .markdown-body .pl-ml { |
| | | color: #735c0f; |
| | | } |
| | | |
| | | .markdown-body .pl-mh, |
| | | .markdown-body .pl-mh .pl-en, |
| | | .markdown-body .pl-ms { |
| | | font-weight: 700; |
| | | color: #005cc5; |
| | | } |
| | | |
| | | .markdown-body .pl-mi { |
| | | font-style: italic; |
| | | color: $font-color; |
| | | } |
| | | |
| | | .markdown-body .pl-mb { |
| | | font-weight: 700; |
| | | color: $font-color; |
| | | } |
| | | |
| | | .markdown-body .pl-md { |
| | | color: #b31d28; |
| | | background-color: #ffeef0; |
| | | } |
| | | |
| | | .markdown-body .pl-mi1 { |
| | | color: #22863a; |
| | | background-color: #f0fff4; |
| | | } |
| | | |
| | | .markdown-body .pl-mc { |
| | | color: #e36209; |
| | | background-color: #ffebda; |
| | | } |
| | | |
| | | .markdown-body .pl-mi2 { |
| | | color: #f6f8fa; |
| | | background-color: #005cc5; |
| | | } |
| | | |
| | | .markdown-body .pl-mdr { |
| | | font-weight: 700; |
| | | color: #6f42c1; |
| | | } |
| | | |
| | | .markdown-body .pl-ba { |
| | | color: #586069; |
| | | } |
| | | |
| | | .markdown-body .pl-sg { |
| | | color: #959da5; |
| | | } |
| | | |
| | | .markdown-body .pl-corl { |
| | | text-decoration: underline; |
| | | color: #032f62; |
| | | } |
| | | |
| | | .markdown-body .mb-0 { |
| | | margin-bottom: 0 !important; |
| | | } |
| | | |
| | | .markdown-body .my-2 { |
| | | margin-bottom: 8px !important; |
| | | } |
| | | |
| | | .markdown-body .my-2 { |
| | | margin-top: 8px !important; |
| | | } |
| | | |
| | | .markdown-body .pl-0 { |
| | | padding-left: 0 !important; |
| | | } |
| | | |
| | | .markdown-body .py-0 { |
| | | padding-top: 0 !important; |
| | | padding-bottom: 0 !important; |
| | | } |
| | | |
| | | .markdown-body .pl-1 { |
| | | padding-left: 4px !important; |
| | | } |
| | | |
| | | .markdown-body .pl-2 { |
| | | padding-left: 8px !important; |
| | | } |
| | | |
| | | .markdown-body .py-2 { |
| | | padding-top: 8px !important; |
| | | padding-bottom: 8px !important; |
| | | } |
| | | |
| | | .markdown-body .pl-3 { |
| | | padding-left: 16px !important; |
| | | } |
| | | |
| | | .markdown-body .pl-4 { |
| | | padding-left: 24px !important; |
| | | } |
| | | |
| | | .markdown-body .pl-5 { |
| | | padding-left: 32px !important; |
| | | } |
| | | |
| | | .markdown-body .pl-6 { |
| | | padding-left: 40px !important; |
| | | } |
| | | |
| | | .markdown-body .pl-7 { |
| | | padding-left: 48px !important; |
| | | } |
| | | |
| | | .markdown-body .pl-8 { |
| | | padding-left: 64px !important; |
| | | } |
| | | |
| | | .markdown-body .pl-9 { |
| | | padding-left: 80px !important; |
| | | } |
| | | |
| | | .markdown-body .pl-10 { |
| | | padding-left: 96px !important; |
| | | } |
| | | |
| | | .markdown-body .pl-11 { |
| | | padding-left: 112px !important; |
| | | } |
| | | |
| | | .markdown-body .pl-12 { |
| | | padding-left: 128px !important; |
| | | } |
| | | |
| | | .markdown-body hr { |
| | | border-bottom-color: #eee; |
| | | } |
| | | |
| | | .markdown-body kbd { |
| | | display: inline-block; |
| | | padding: 3px 5px; |
| | | font: |
| | | 11px SFMono-Regular, |
| | | Consolas, |
| | | Liberation Mono, |
| | | Menlo, |
| | | monospace; |
| | | line-height: 10px; |
| | | color: #444d56; |
| | | vertical-align: middle; |
| | | background-color: #fafbfc; |
| | | border: 1px solid #d1d5da; |
| | | border-radius: 3px; |
| | | box-shadow: inset 0 -1px 0 #d1d5da; |
| | | } |
| | | |
| | | .markdown-body:after, |
| | | .markdown-body:before { |
| | | display: table; |
| | | content: ''; |
| | | } |
| | | |
| | | .markdown-body:after { |
| | | clear: both; |
| | | } |
| | | |
| | | .markdown-body > :first-child { |
| | | margin-top: 0 !important; |
| | | } |
| | | |
| | | .markdown-body > :last-child { |
| | | margin-bottom: 0 !important; |
| | | } |
| | | |
| | | .markdown-body a:not([href]) { |
| | | color: inherit; |
| | | text-decoration: none; |
| | | } |
| | | |
| | | .markdown-body blockquote, |
| | | .markdown-body details, |
| | | .markdown-body dl, |
| | | .markdown-body ol, |
| | | .markdown-body pre, |
| | | .markdown-body table, |
| | | .markdown-body ul { |
| | | margin-top: 0; |
| | | margin-bottom: 16px; |
| | | } |
| | | |
| | | .markdown-body hr { |
| | | height: 0.25em; |
| | | padding: 0; |
| | | margin: 24px 0; |
| | | background-color: #e1e4e8; |
| | | border: 0; |
| | | } |
| | | |
| | | .markdown-body blockquote > :first-child { |
| | | margin-top: 0; |
| | | } |
| | | |
| | | .markdown-body blockquote > :last-child { |
| | | margin-bottom: 0; |
| | | } |
| | | |
| | | .markdown-body ol, |
| | | .markdown-body ul { |
| | | padding-left: 1em; |
| | | } |
| | | |
| | | .markdown-body ol ol, |
| | | .markdown-body ol ul, |
| | | .markdown-body ul ol, |
| | | .markdown-body ul ul { |
| | | margin-top: 0; |
| | | margin-bottom: 0; |
| | | } |
| | | |
| | | .markdown-body li { |
| | | line-height: 28px; |
| | | font-size: 14px; |
| | | word-wrap: break-all; |
| | | list-style: disc; |
| | | margin-left: 10px; |
| | | } |
| | | |
| | | .markdown-body li > p { |
| | | margin-top: 16px; |
| | | } |
| | | |
| | | .markdown-body li + li { |
| | | margin-top: 0.25em; |
| | | } |
| | | |
| | | .markdown-body dl { |
| | | padding: 0; |
| | | } |
| | | |
| | | .markdown-body dl dt { |
| | | padding: 0; |
| | | margin-top: 16px; |
| | | font-size: 1em; |
| | | font-style: italic; |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .markdown-body dl dd { |
| | | padding: 0 16px; |
| | | margin-bottom: 16px; |
| | | } |
| | | |
| | | .markdown-body table { |
| | | display: block; |
| | | width: 100%; |
| | | overflow: auto; |
| | | } |
| | | |
| | | .markdown-body table th { |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .markdown-body table td, |
| | | .markdown-body table th { |
| | | padding: 6px 13px; |
| | | border: 1px solid #dfe2e5; |
| | | } |
| | | |
| | | .markdown-body table tr { |
| | | background-color: #fff; |
| | | border-top: 1px solid #c6cbd1; |
| | | } |
| | | |
| | | .markdown-body table tr:nth-child(2n) { |
| | | background-color: #f6f8fa; |
| | | } |
| | | |
| | | .markdown-body img { |
| | | max-width: 100%; |
| | | box-sizing: initial; |
| | | background-color: #fff; |
| | | border: 1px solid #eee; |
| | | border: 1px solid var(--art-c-border-2); |
| | | cursor: zoom-in; |
| | | } |
| | | |
| | | .markdown-body img[align='right'] { |
| | | padding-left: 20px; |
| | | } |
| | | |
| | | .markdown-body img[align='left'] { |
| | | padding-right: 20px; |
| | | } |
| | | |
| | | .markdown-body code { |
| | | padding: 0.2em 0.4em; |
| | | margin: 0; |
| | | font-size: 85%; |
| | | background-color: rgba(27, 31, 35, 0.05); |
| | | border-radius: 3px; |
| | | } |
| | | |
| | | .markdown-body pre { |
| | | word-wrap: normal; |
| | | } |
| | | |
| | | .markdown-body pre > code { |
| | | padding: 0; |
| | | margin: 0; |
| | | font-size: 100%; |
| | | word-break: normal; |
| | | white-space: pre; |
| | | background: transparent; |
| | | border: 0; |
| | | } |
| | | |
| | | .markdown-body .highlight { |
| | | margin-bottom: 16px; |
| | | } |
| | | |
| | | .markdown-body .highlight pre { |
| | | margin-bottom: 0; |
| | | word-break: normal; |
| | | } |
| | | |
| | | .markdown-body .highlight pre, |
| | | .markdown-body pre { |
| | | padding: 15px 20px 15px 0; |
| | | overflow: auto; |
| | | font-size: 92%; |
| | | line-height: 1.6; |
| | | } |
| | | |
| | | .markdown-body pre code { |
| | | display: inline; |
| | | max-width: auto; |
| | | padding: 0; |
| | | margin: 0; |
| | | overflow: visible; |
| | | line-height: inherit; |
| | | word-wrap: normal; |
| | | background-color: initial; |
| | | border: 0; |
| | | } |
| | | |
| | | .markdown-body .commit-tease-sha { |
| | | display: inline-block; |
| | | font-size: 90%; |
| | | color: #444d56; |
| | | } |
| | | |
| | | .markdown-body .full-commit .btn-outline:not(:disabled):hover { |
| | | color: #005cc5; |
| | | border-color: #005cc5; |
| | | } |
| | | |
| | | .markdown-body .blob-wrapper { |
| | | overflow-x: auto; |
| | | overflow-y: hidden; |
| | | } |
| | | |
| | | .markdown-body .blob-wrapper-embedded { |
| | | max-height: 240px; |
| | | overflow-y: auto; |
| | | } |
| | | |
| | | .markdown-body .blob-num { |
| | | width: 1%; |
| | | min-width: 50px; |
| | | padding-right: 10px; |
| | | padding-left: 10px; |
| | | font-size: 12px; |
| | | line-height: 20px; |
| | | color: rgba(27, 31, 35, 0.3); |
| | | text-align: right; |
| | | white-space: nowrap; |
| | | vertical-align: top; |
| | | cursor: pointer; |
| | | -webkit-user-select: none; |
| | | -moz-user-select: none; |
| | | -ms-user-select: none; |
| | | user-select: none; |
| | | } |
| | | |
| | | .markdown-body .blob-num:hover { |
| | | color: rgba(27, 31, 35, 0.6); |
| | | } |
| | | |
| | | .markdown-body .blob-num:before { |
| | | content: attr(data-line-number); |
| | | } |
| | | |
| | | .markdown-body .blob-code { |
| | | position: relative; |
| | | padding-right: 10px; |
| | | padding-left: 10px; |
| | | line-height: 20px; |
| | | vertical-align: top; |
| | | } |
| | | |
| | | .markdown-body .blob-code-inner { |
| | | overflow: visible; |
| | | font-size: 12px; |
| | | color: $font-color; |
| | | word-wrap: normal; |
| | | white-space: pre; |
| | | } |
| | | |
| | | .markdown-body .pl-token.active, |
| | | .markdown-body .pl-token:hover { |
| | | cursor: pointer; |
| | | background: #ffea7f; |
| | | } |
| | | |
| | | .markdown-body .tab-size[data-tab-size='1'] { |
| | | -moz-tab-size: 1; |
| | | tab-size: 1; |
| | | } |
| | | |
| | | .markdown-body .tab-size[data-tab-size='2'] { |
| | | -moz-tab-size: 2; |
| | | tab-size: 2; |
| | | } |
| | | |
| | | .markdown-body .tab-size[data-tab-size='3'] { |
| | | -moz-tab-size: 3; |
| | | tab-size: 3; |
| | | } |
| | | |
| | | .markdown-body .tab-size[data-tab-size='4'] { |
| | | -moz-tab-size: 4; |
| | | tab-size: 4; |
| | | } |
| | | |
| | | .markdown-body .tab-size[data-tab-size='5'] { |
| | | -moz-tab-size: 5; |
| | | tab-size: 5; |
| | | } |
| | | |
| | | .markdown-body .tab-size[data-tab-size='6'] { |
| | | -moz-tab-size: 6; |
| | | tab-size: 6; |
| | | } |
| | | |
| | | .markdown-body .tab-size[data-tab-size='7'] { |
| | | -moz-tab-size: 7; |
| | | tab-size: 7; |
| | | } |
| | | |
| | | .markdown-body .tab-size[data-tab-size='8'] { |
| | | -moz-tab-size: 8; |
| | | tab-size: 8; |
| | | } |
| | | |
| | | .markdown-body .tab-size[data-tab-size='9'] { |
| | | -moz-tab-size: 9; |
| | | tab-size: 9; |
| | | } |
| | | |
| | | .markdown-body .tab-size[data-tab-size='10'] { |
| | | -moz-tab-size: 10; |
| | | tab-size: 10; |
| | | } |
| | | |
| | | .markdown-body .tab-size[data-tab-size='11'] { |
| | | -moz-tab-size: 11; |
| | | tab-size: 11; |
| | | } |
| | | |
| | | .markdown-body .tab-size[data-tab-size='12'] { |
| | | -moz-tab-size: 12; |
| | | tab-size: 12; |
| | | } |
| | | |
| | | .markdown-body .task-list-item { |
| | | list-style-type: none; |
| | | } |
| | | |
| | | .markdown-body .task-list-item + .task-list-item { |
| | | margin-top: 3px; |
| | | } |
| | | |
| | | .markdown-body .task-list-item input { |
| | | margin: 0 0.2em 0.25em -1.6em; |
| | | vertical-align: middle; |
| | | } |
| New file |
| | |
| | | // sass 混合宏(函数) |
| | | |
| | | /** |
| | | * 溢出省略号 |
| | | * @param {Number} 行数 |
| | | */ |
| | | @mixin ellipsis($rowCount: 1) { |
| | | @if $rowCount <=1 { |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } @else { |
| | | min-width: 0; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | display: -webkit-box; |
| | | -webkit-line-clamp: $rowCount; |
| | | -webkit-box-orient: vertical; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 控制用户能否选中文本 |
| | | * @param {String} 类型 |
| | | */ |
| | | @mixin userSelect($value: none) { |
| | | user-select: $value; |
| | | -moz-user-select: $value; |
| | | -ms-user-select: $value; |
| | | -webkit-user-select: $value; |
| | | } |
| | | |
| | | // 绝对定位居中 |
| | | @mixin absoluteCenter() { |
| | | position: absolute; |
| | | left: 0; |
| | | right: 0; |
| | | top: 0; |
| | | bottom: 0; |
| | | margin: auto; |
| | | } |
| | | |
| | | /** |
| | | * css3动画 |
| | | * |
| | | */ |
| | | @mixin animation( |
| | | $from: ( |
| | | width: 0px |
| | | ), |
| | | $to: ( |
| | | width: 100px |
| | | ), |
| | | $name: mymove, |
| | | $animate: mymove 2s 1 linear infinite |
| | | ) { |
| | | -webkit-animation: $animate; |
| | | -o-animation: $animate; |
| | | animation: $animate; |
| | | |
| | | @keyframes #{$name} { |
| | | from { |
| | | @each $key, $value in $from { |
| | | #{$key}: #{$value}; |
| | | } |
| | | } |
| | | |
| | | to { |
| | | @each $key, $value in $to { |
| | | #{$key}: #{$value}; |
| | | } |
| | | } |
| | | } |
| | | |
| | | @-webkit-keyframes #{$name} { |
| | | from { |
| | | @each $key, $value in $from { |
| | | $key: $value; |
| | | } |
| | | } |
| | | |
| | | to { |
| | | @each $key, $value in $to { |
| | | $key: $value; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 圆形盒子 |
| | | @mixin circle($size: 11px, $bg: #fff) { |
| | | border-radius: 50%; |
| | | width: $size; |
| | | height: $size; |
| | | line-height: $size; |
| | | text-align: center; |
| | | background: $bg; |
| | | } |
| | | |
| | | // placeholder |
| | | @mixin placeholder($color: #bbb) { |
| | | // Firefox |
| | | &::-moz-placeholder { |
| | | color: $color; |
| | | opacity: 1; |
| | | } |
| | | |
| | | // Internet Explorer 10+ |
| | | &:-ms-input-placeholder { |
| | | color: $color; |
| | | } |
| | | |
| | | // Safari and Chrome |
| | | &::-webkit-input-placeholder { |
| | | color: $color; |
| | | } |
| | | |
| | | &:placeholder-shown { |
| | | text-overflow: ellipsis; |
| | | } |
| | | } |
| | | |
| | | //背景透明,文字不透明。兼容IE8 |
| | | @mixin betterTransparentize($color, $alpha) { |
| | | $c: rgba($color, $alpha); |
| | | $ie_c: ie_hex_str($c); |
| | | background: rgba($color, 1); |
| | | background: $c; |
| | | background: transparent \9; |
| | | zoom: 1; |
| | | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c}); |
| | | -ms-filter: 'progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c})'; |
| | | } |
| | | |
| | | //添加浏览器前缀 |
| | | @mixin browserPrefix($propertyName, $value) { |
| | | @each $prefix in -webkit-, -moz-, -ms-, -o-, '' { |
| | | #{$prefix}#{$propertyName}: $value; |
| | | } |
| | | } |
| | | |
| | | // 边框 |
| | | @mixin border($color: red) { |
| | | border: 1px solid $color; |
| | | } |
| | | |
| | | // 背景滤镜 |
| | | @mixin backdropBlur() { |
| | | --tw-backdrop-blur: blur(30px); |
| | | -webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) |
| | | var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) |
| | | var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) |
| | | var(--tw-backdrop-sepia); |
| | | backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) |
| | | var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) |
| | | var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); |
| | | } |
| New file |
| | |
| | | @charset "UTF-8"; |
| | | |
| | | /*滚动条*/ |
| | | /*滚动条整体部分,必须要设置*/ |
| | | ::-webkit-scrollbar { |
| | | width: 8px !important; |
| | | height: 0 !important; |
| | | } |
| | | |
| | | /*滚动条的轨道*/ |
| | | ::-webkit-scrollbar-track { |
| | | background-color: var(--art-gray-200); |
| | | } |
| | | |
| | | /*滚动条的滑块按钮*/ |
| | | ::-webkit-scrollbar-thumb { |
| | | border-radius: 5px; |
| | | background-color: #cccccc !important; |
| | | transition: all 0.2s; |
| | | -webkit-transition: all 0.2s; |
| | | } |
| | | |
| | | ::-webkit-scrollbar-thumb:hover { |
| | | background-color: #b0abab !important; |
| | | } |
| | | |
| | | /*滚动条的上下两端的按钮*/ |
| | | ::-webkit-scrollbar-button { |
| | | height: 0px; |
| | | width: 0; |
| | | } |
| | | |
| | | .dark { |
| | | ::-webkit-scrollbar-track { |
| | | background-color: var(--default-bg-color); |
| | | } |
| | | |
| | | ::-webkit-scrollbar-thumb { |
| | | background-color: var(--art-gray-300) !important; |
| | | } |
| | | } |
| New file |
| | |
| | | @use 'sass:map'; |
| | | |
| | | // === 变量区域 === |
| | | $transition: ( |
| | | // 动画持续时间 |
| | | duration: 0.25s, |
| | | // 滑动动画的移动距离 |
| | | distance: 15px, |
| | | // 默认缓动函数 |
| | | easing: cubic-bezier(0.25, 0.1, 0.25, 1), |
| | | // 淡入淡出专用的缓动函数 |
| | | fade-easing: cubic-bezier(0.4, 0, 0.6, 1) |
| | | ); |
| | | |
| | | // 抽取配置值函数,提高可复用性 |
| | | @function transition-config($key) { |
| | | @return map.get($transition, $key); |
| | | } |
| | | |
| | | // 变量简写 |
| | | $duration: transition-config('duration'); |
| | | $distance: transition-config('distance'); |
| | | $easing: transition-config('easing'); |
| | | $fade-easing: transition-config('fade-easing'); |
| | | |
| | | // === 动画类 === |
| | | |
| | | // 淡入淡出动画 |
| | | .fade { |
| | | &-enter-active, |
| | | &-leave-active { |
| | | transition: opacity $duration $fade-easing; |
| | | will-change: opacity; |
| | | } |
| | | |
| | | &-enter-from, |
| | | &-leave-to { |
| | | opacity: 0; |
| | | } |
| | | |
| | | &-enter-to, |
| | | &-leave-from { |
| | | opacity: 1; |
| | | } |
| | | } |
| | | |
| | | // 滑动动画通用样式 |
| | | @mixin slide-transition($direction) { |
| | | $distance-x: 0; |
| | | $distance-y: 0; |
| | | |
| | | @if $direction == 'left' { |
| | | $distance-x: -$distance; |
| | | } @else if $direction == 'right' { |
| | | $distance-x: $distance; |
| | | } @else if $direction == 'top' { |
| | | $distance-y: -$distance; |
| | | } @else if $direction == 'bottom' { |
| | | $distance-y: $distance; |
| | | } |
| | | |
| | | &-enter-active { |
| | | transition: |
| | | opacity $duration $easing, |
| | | transform $duration $easing; |
| | | will-change: opacity, transform; |
| | | } |
| | | |
| | | &-leave-active { |
| | | transition: |
| | | opacity calc($duration * 0.7) $easing, |
| | | transform calc($duration * 0.7) $easing; |
| | | will-change: opacity, transform; |
| | | } |
| | | |
| | | &-enter-from { |
| | | opacity: 0; |
| | | transform: translate3d($distance-x, $distance-y, 0); |
| | | } |
| | | |
| | | &-enter-to { |
| | | opacity: 1; |
| | | transform: translate3d(0, 0, 0); |
| | | } |
| | | |
| | | &-leave-to { |
| | | opacity: 0; |
| | | transform: translate3d(-$distance-x, -$distance-y, 0); |
| | | } |
| | | } |
| | | |
| | | // 滑动动画方向类 |
| | | .slide-left { |
| | | @include slide-transition('left'); |
| | | } |
| | | .slide-right { |
| | | @include slide-transition('right'); |
| | | } |
| | | .slide-top { |
| | | @include slide-transition('top'); |
| | | } |
| | | .slide-bottom { |
| | | @include slide-transition('bottom'); |
| | | } |
| New file |
| | |
| | | @import 'tailwindcss'; |
| | | @custom-variant dark (&:where(.dark, .dark *)); |
| | | |
| | | /* ==================== Light Mode Variables ==================== */ |
| | | :root { |
| | | /* Base Colors */ |
| | | --art-color: #ffffff; |
| | | --theme-color: var(--main-color); |
| | | |
| | | /* Theme Colors - OKLCH Format */ |
| | | --art-primary: oklch(0.7 0.23 260); |
| | | --art-secondary: oklch(0.72 0.19 231.6); |
| | | --art-error: oklch(0.73 0.15 25.3); |
| | | --art-info: oklch(0.58 0.03 254.1); |
| | | --art-success: oklch(0.78 0.17 166.1); |
| | | --art-warning: oklch(0.78 0.14 75.5); |
| | | --art-danger: oklch(0.68 0.22 25.3); |
| | | |
| | | /* Gray Scale - Light Mode */ |
| | | --art-gray-100: #f9fafb; |
| | | --art-gray-200: #f2f4f5; |
| | | --art-gray-300: #e6eaeb; |
| | | --art-gray-400: #dbdfe1; |
| | | --art-gray-500: #949eb7; |
| | | --art-gray-600: #7987a1; |
| | | --art-gray-700: #4d5875; |
| | | --art-gray-800: #383853; |
| | | --art-gray-900: #323251; |
| | | |
| | | /* Border Colors */ |
| | | --art-card-border: rgba(0, 0, 0, 0.08); |
| | | |
| | | --default-border: #e2e8ee; |
| | | --default-border-dashed: #dbdfe9; |
| | | |
| | | /* Background Colors */ |
| | | --default-bg-color: #fafbfc; |
| | | --default-box-color: #ffffff; |
| | | |
| | | /* Hover Color */ |
| | | --art-hover-color: #edeff0; |
| | | |
| | | /* Active Color */ |
| | | --art-active-color: #f2f4f5; |
| | | |
| | | /* Element Component Active Color */ |
| | | --art-el-active-color: #f2f4f5; |
| | | } |
| | | |
| | | /* ==================== Dark Mode Variables ==================== */ |
| | | .dark { |
| | | /* Base Colors */ |
| | | --art-color: #000000; |
| | | |
| | | /* Gray Scale - Dark Mode */ |
| | | --art-gray-100: #110f0f; |
| | | --art-gray-200: #17171c; |
| | | --art-gray-300: #393946; |
| | | --art-gray-400: #505062; |
| | | --art-gray-500: #73738c; |
| | | --art-gray-600: #8f8fa3; |
| | | --art-gray-700: #ababba; |
| | | --art-gray-800: #c7c7d1; |
| | | --art-gray-900: #e3e3e8; |
| | | |
| | | /* Border Colors */ |
| | | --art-card-border: rgba(255, 255, 255, 0.08); |
| | | |
| | | --default-border: rgba(255, 255, 255, 0.1); |
| | | --default-border-dashed: #363843; |
| | | |
| | | /* Background Colors */ |
| | | --default-bg-color: #070707; |
| | | --default-box-color: #161618; |
| | | |
| | | /* Hover Color */ |
| | | --art-hover-color: #252530; |
| | | |
| | | /* Active Color */ |
| | | --art-active-color: #202226; |
| | | |
| | | /* Element Component Active Color */ |
| | | --art-el-active-color: #2e2e38; |
| | | } |
| | | |
| | | /* ==================== Tailwind Theme Configuration ==================== */ |
| | | @theme { |
| | | /* Box Color (Light: white / Dark: black) */ |
| | | --color-box: var(--default-box-color); |
| | | |
| | | /* System Theme Color */ |
| | | --color-theme: var(--theme-color); |
| | | |
| | | /* Hover Color */ |
| | | --color-hover-color: var(--art-hover-color); |
| | | |
| | | /* Active Color */ |
| | | --color-active-color: var(--art-active-color); |
| | | |
| | | /* Active Color */ |
| | | --color-el-active-color: var(--art-active-color); |
| | | |
| | | /* ElementPlus Theme Colors */ |
| | | --color-primary: var(--art-primary); |
| | | --color-secondary: var(--art-secondary); |
| | | --color-error: var(--art-error); |
| | | --color-info: var(--art-info); |
| | | --color-success: var(--art-success); |
| | | --color-warning: var(--art-warning); |
| | | --color-danger: var(--art-danger); |
| | | |
| | | /* Gray Scale Colors (Auto-adapts to dark mode) */ |
| | | --color-g-100: var(--art-gray-100); |
| | | --color-g-200: var(--art-gray-200); |
| | | --color-g-300: var(--art-gray-300); |
| | | --color-g-400: var(--art-gray-400); |
| | | --color-g-500: var(--art-gray-500); |
| | | --color-g-600: var(--art-gray-600); |
| | | --color-g-700: var(--art-gray-700); |
| | | --color-g-800: var(--art-gray-800); |
| | | --color-g-900: var(--art-gray-900); |
| | | } |
| | | |
| | | /* ==================== Custom Border Radius Utilities ==================== */ |
| | | @utility rounded-custom-xs { |
| | | border-radius: calc(var(--custom-radius) / 2); |
| | | } |
| | | |
| | | @utility rounded-custom-sm { |
| | | border-radius: calc(var(--custom-radius) / 2 + 2px); |
| | | } |
| | | |
| | | /* ==================== Custom Utility Classes ==================== */ |
| | | @layer utilities { |
| | | /* Flexbox Layout Utilities */ |
| | | .flex-c { |
| | | @apply flex items-center; |
| | | } |
| | | |
| | | .flex-b { |
| | | @apply flex justify-between; |
| | | } |
| | | |
| | | .flex-cc { |
| | | @apply flex items-center justify-center; |
| | | } |
| | | |
| | | .flex-cb { |
| | | @apply flex items-center justify-between; |
| | | } |
| | | |
| | | /* Transition Utilities */ |
| | | .tad-200 { |
| | | @apply transition-all duration-200; |
| | | } |
| | | |
| | | .tad-300 { |
| | | @apply transition-all duration-300; |
| | | } |
| | | |
| | | /* Border Utilities */ |
| | | .border-full-d { |
| | | @apply border border-[var(--default-border)]; |
| | | } |
| | | |
| | | .border-b-d { |
| | | @apply border-b border-[var(--default-border)]; |
| | | } |
| | | |
| | | .border-t-d { |
| | | @apply border-t border-[var(--default-border)]; |
| | | } |
| | | |
| | | .border-l-d { |
| | | @apply border-l border-[var(--default-border)]; |
| | | } |
| | | |
| | | .border-r-d { |
| | | @apply border-r border-[var(--default-border)]; |
| | | } |
| | | |
| | | /* Cursor Utilities */ |
| | | .c-p { |
| | | @apply cursor-pointer; |
| | | } |
| | | } |
| | | |
| | | /* ==================== Custom Component Classes ==================== */ |
| | | @layer components { |
| | | /* Art Card Header Component */ |
| | | .art-card-header { |
| | | @apply flex justify-between pr-6 pb-1; |
| | | |
| | | .title { |
| | | h4 { |
| | | @apply text-lg font-medium text-g-900; |
| | | } |
| | | |
| | | p { |
| | | @apply mt-1 text-sm text-g-600; |
| | | |
| | | span { |
| | | @apply ml-2 font-medium; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | // 定义基础变量 |
| | | $bg-animation-color-light: #000; |
| | | $bg-animation-color-dark: #fff; |
| | | $bg-animation-duration: 0.5s; |
| | | |
| | | html { |
| | | --bg-animation-color: $bg-animation-color-light; |
| | | |
| | | &.dark { |
| | | --bg-animation-color: $bg-animation-color-dark; |
| | | } |
| | | |
| | | // View transition styles |
| | | &::view-transition-old(*) { |
| | | animation: none; |
| | | } |
| | | |
| | | &::view-transition-new(*) { |
| | | animation: clip $bg-animation-duration ease-in both; |
| | | } |
| | | |
| | | &::view-transition-old(root) { |
| | | z-index: 1; |
| | | } |
| | | |
| | | &::view-transition-new(root) { |
| | | z-index: 9999; |
| | | } |
| | | |
| | | &.dark { |
| | | &::view-transition-old(*) { |
| | | animation: clip $bg-animation-duration ease-in reverse both; |
| | | } |
| | | |
| | | &::view-transition-new(*) { |
| | | animation: none; |
| | | } |
| | | |
| | | &::view-transition-old(root) { |
| | | z-index: 9999; |
| | | } |
| | | |
| | | &::view-transition-new(root) { |
| | | z-index: 1; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 定义动画 |
| | | @keyframes clip { |
| | | from { |
| | | clip-path: circle(0% at var(--x) var(--y)); |
| | | } |
| | | |
| | | to { |
| | | clip-path: circle(var(--r) at var(--x) var(--y)); |
| | | } |
| | | } |
| | | |
| | | // body 相关样式 |
| | | body { |
| | | background-color: var(--bg-animation-color); |
| | | } |
| New file |
| | |
| | | // 主题切换过渡优化,优化除视觉上的不适感 |
| | | .theme-change { |
| | | * { |
| | | transition: 0s !important; |
| | | } |
| | | |
| | | .el-switch__core, |
| | | .el-switch__action { |
| | | transition: all 0.3s !important; |
| | | } |
| | | } |
| New file |
| | |
| | | .hljs { |
| | | display: block; |
| | | overflow-x: auto; |
| | | padding: 0.5em; |
| | | |
| | | color: #a6accd; |
| | | } |
| | | |
| | | .hljs-string, |
| | | .hljs-section, |
| | | .hljs-selector-class, |
| | | .hljs-template-variable, |
| | | .hljs-deletion { |
| | | color: #aed07e !important; |
| | | } |
| | | |
| | | .hljs-comment, |
| | | .hljs-quote { |
| | | color: #6f747d; |
| | | } |
| | | |
| | | .hljs-doctag, |
| | | .hljs-keyword, |
| | | .hljs-formula { |
| | | color: #c792ea; |
| | | } |
| | | |
| | | .hljs-section, |
| | | .hljs-name, |
| | | .hljs-selector-tag, |
| | | .hljs-deletion, |
| | | .hljs-subst { |
| | | color: #c86068; |
| | | } |
| | | |
| | | .hljs-literal { |
| | | color: #56b6c2; |
| | | } |
| | | |
| | | .hljs-string, |
| | | .hljs-regexp, |
| | | .hljs-addition, |
| | | .hljs-attribute, |
| | | .hljs-meta-string { |
| | | color: #abb2bf; |
| | | } |
| | | |
| | | .hljs-attribute { |
| | | color: #c792ea; |
| | | } |
| | | |
| | | .hljs-function { |
| | | color: #c792ea; |
| | | } |
| | | |
| | | .hljs-type { |
| | | color: #f07178; |
| | | } |
| | | |
| | | .hljs-title { |
| | | color: #82aaff !important; |
| | | } |
| | | |
| | | .hljs-built_in, |
| | | .hljs-class { |
| | | color: #82aaff; |
| | | } |
| | | |
| | | // 括号 |
| | | .hljs-params { |
| | | color: #a6accd; |
| | | } |
| | | |
| | | .hljs-attr, |
| | | .hljs-variable, |
| | | .hljs-template-variable, |
| | | .hljs-selector-class, |
| | | .hljs-selector-attr, |
| | | .hljs-selector-pseudo, |
| | | .hljs-number { |
| | | color: #de7e61; |
| | | } |
| | | |
| | | .hljs-symbol, |
| | | .hljs-bullet, |
| | | .hljs-link, |
| | | .hljs-meta, |
| | | .hljs-selector-id { |
| | | color: #61aeee; |
| | | } |
| | | |
| | | .hljs-strong { |
| | | font-weight: bold; |
| | | } |
| | | |
| | | .hljs-link { |
| | | text-decoration: underline; |
| | | } |
| New file |
| | |
| | | // 重置默认样式 |
| | | @use './core/reset.scss'; |
| | | |
| | | // 应用全局样式 |
| | | @use './core/app.scss'; |
| | | |
| | | // Element Plus 样式优化 |
| | | @use './core/el-ui.scss'; |
| | | |
| | | // Element Plus 暗黑主题 |
| | | @use './core/el-dark.scss'; |
| | | |
| | | // 暗黑主题样式优化 |
| | | @use './core/dark.scss'; |
| | | |
| | | // 路由切换动画 |
| | | @use './core/router-transition'; |
| | | |
| | | // 主题切换过渡优化 |
| | | @use './core/theme-change.scss'; |
| | | |
| | | // 主题切换圆形扩散动画 |
| | | @use './core/theme-animation.scss'; |
| New file |
| | |
| | | const fourDotsSpinnerSvg = ` |
| | | <svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"> |
| | | <style> |
| | | .spinner { |
| | | transform-origin: 20px 20px; |
| | | animation: rotate 1.6s linear infinite; |
| | | } |
| | | .dot { |
| | | fill: var(--theme-color); |
| | | animation: fade 1.6s infinite; |
| | | } |
| | | .dot:nth-child(1) { animation-delay: 0s; } |
| | | .dot:nth-child(2) { animation-delay: 0.5s; } |
| | | .dot:nth-child(3) { animation-delay: 1s; } |
| | | .dot:nth-child(4) { animation-delay: 1.5s; } |
| | | @keyframes rotate { |
| | | 100% { transform: rotate(360deg); } |
| | | } |
| | | @keyframes fade { |
| | | 0%, 100% { opacity: 1; } |
| | | 50% { opacity: 0.5; } |
| | | } |
| | | </style> |
| | | <g class="spinner"> |
| | | <circle class="dot" cx="20" cy="8" r="4"/> |
| | | <circle class="dot" cx="32" cy="20" r="4"/> |
| | | <circle class="dot" cx="20" cy="32" r="4"/> |
| | | <circle class="dot" cx="8" cy="20" r="4"/> |
| | | </g> |
| | | </svg> |
| | | `; |
| | | export { |
| | | fourDotsSpinnerSvg |
| | | }; |
| New file |
| | |
| | | <!-- 基础横幅组件 --> |
| | | <template> |
| | | <div |
| | | class="art-card basic-banner" |
| | | :class="[{ 'has-decoration': decoration }, boxStyle]" |
| | | :style="{ height }" |
| | | @click="emit('click')" |
| | | > |
| | | <!-- 流星效果 --> |
| | | <div v-if="meteorConfig?.enabled && isDark" class="basic-banner__meteors"> |
| | | <span |
| | | v-for="(meteor, index) in meteors" |
| | | :key="index" |
| | | class="meteor" |
| | | :style="{ |
| | | top: '-60px', |
| | | left: `${meteor.x}%`, |
| | | animationDuration: `${meteor.speed}s`, |
| | | animationDelay: `${meteor.delay}s` |
| | | }" |
| | | ></span> |
| | | </div> |
| | | |
| | | <div class="basic-banner__content"> |
| | | <!-- title slot --> |
| | | <slot name="title"> |
| | | <p v-if="title" class="basic-banner__title" :style="{ color: titleColor }">{{ title }}</p> |
| | | </slot> |
| | | |
| | | <!-- subtitle slot --> |
| | | <slot name="subtitle"> |
| | | <p v-if="subtitle" class="basic-banner__subtitle" :style="{ color: subtitleColor }">{{ |
| | | subtitle |
| | | }}</p> |
| | | </slot> |
| | | |
| | | <!-- button slot --> |
| | | <slot name="button"> |
| | | <div |
| | | v-if="buttonConfig?.show" |
| | | class="basic-banner__button" |
| | | :style="{ |
| | | backgroundColor: buttonColor, |
| | | color: buttonTextColor, |
| | | borderRadius: buttonRadius |
| | | }" |
| | | @click.stop="emit('buttonClick')" |
| | | > |
| | | {{ buttonConfig?.text }} |
| | | </div> |
| | | </slot> |
| | | |
| | | <!-- default slot --> |
| | | <slot></slot> |
| | | |
| | | <!-- background image --> |
| | | <img |
| | | v-if="imageConfig.src" |
| | | class="basic-banner__background-image" |
| | | :src="imageConfig.src" |
| | | :style="{ width: imageConfig.width, bottom: imageConfig.bottom, right: imageConfig.right }" |
| | | loading="lazy" |
| | | alt="背景图片" |
| | | /> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { onMounted, ref, computed } from 'vue' |
| | | import { useSettingStore } from '@/store/modules/setting' |
| | | const settingStore = useSettingStore() |
| | | const { isDark } = storeToRefs(settingStore) |
| | | defineOptions({ name: 'ArtBasicBanner' }) |
| | | const props = defineProps({ |
| | | height: { required: false, default: '11rem' }, |
| | | title: { required: false, default: '' }, |
| | | subtitle: { required: false, default: '' }, |
| | | titleColor: { required: false, default: 'white' }, |
| | | subtitleColor: { required: false, default: 'white' }, |
| | | boxStyle: { required: false, default: '!bg-theme/60' }, |
| | | decoration: { required: false, default: true }, |
| | | buttonConfig: { |
| | | required: false, |
| | | default: () => ({ |
| | | show: true, |
| | | text: '查看', |
| | | color: '#fff', |
| | | textColor: '#333', |
| | | radius: '6px' |
| | | }) |
| | | }, |
| | | meteorConfig: { required: false, default: () => ({ enabled: false, count: 10 }) }, |
| | | imageConfig: { |
| | | required: false, |
| | | default: () => ({ src: '', width: '12rem', bottom: '-3rem', right: '0' }) |
| | | } |
| | | }) |
| | | const emit = defineEmits(['click', 'buttonClick']) |
| | | const buttonColor = computed(() => props.buttonConfig?.color ?? '#fff') |
| | | const buttonTextColor = computed(() => props.buttonConfig?.textColor ?? '#333') |
| | | const buttonRadius = computed(() => props.buttonConfig?.radius ?? '6px') |
| | | const meteors = ref([]) |
| | | onMounted(() => { |
| | | if (props.meteorConfig?.enabled) { |
| | | meteors.value = generateMeteors(props.meteorConfig?.count ?? 10) |
| | | } |
| | | }) |
| | | function generateMeteors(count) { |
| | | const segmentWidth = 100 / count |
| | | return Array.from({ length: count }, (_, index) => { |
| | | const segmentStart = index * segmentWidth |
| | | const x = segmentStart + Math.random() * segmentWidth |
| | | const isSlow = Math.random() > 0.5 |
| | | return { |
| | | x, |
| | | speed: isSlow ? 5 + Math.random() * 3 : 2 + Math.random() * 2, |
| | | delay: Math.random() * 5 |
| | | } |
| | | }) |
| | | } |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | .basic-banner { |
| | | position: relative; |
| | | display: flex; |
| | | flex-direction: column; |
| | | justify-content: center; |
| | | padding: 0 2rem; |
| | | overflow: hidden; |
| | | color: white; |
| | | border-radius: calc(var(--custom-radius) + 2px) !important; |
| | | |
| | | &__content { |
| | | position: relative; |
| | | z-index: 1; |
| | | } |
| | | |
| | | &__title { |
| | | margin: 0 0 0.5rem; |
| | | font-size: 1.5rem; |
| | | font-weight: 600; |
| | | } |
| | | |
| | | &__subtitle { |
| | | position: relative; |
| | | z-index: 10; |
| | | margin: 0 0 1.5rem; |
| | | font-size: 0.9rem; |
| | | opacity: 0.9; |
| | | } |
| | | |
| | | &__button { |
| | | box-sizing: border-box; |
| | | display: inline-block; |
| | | min-width: 80px; |
| | | height: var(--el-component-custom-height); |
| | | padding: 0 12px; |
| | | font-size: 14px; |
| | | line-height: var(--el-component-custom-height); |
| | | text-align: center; |
| | | cursor: pointer; |
| | | user-select: none; |
| | | transition: all 0.3s; |
| | | |
| | | &:hover { |
| | | opacity: 0.8; |
| | | } |
| | | } |
| | | |
| | | &__background-image { |
| | | position: absolute; |
| | | right: 0; |
| | | bottom: -3rem; |
| | | z-index: 0; |
| | | width: 12rem; |
| | | } |
| | | |
| | | &.has-decoration::after { |
| | | position: absolute; |
| | | right: -10%; |
| | | bottom: -20%; |
| | | width: 60%; |
| | | height: 140%; |
| | | content: ''; |
| | | background: rgb(255 255 255 / 10%); |
| | | border-radius: 30%; |
| | | transform: rotate(-20deg); |
| | | } |
| | | |
| | | &__meteors { |
| | | position: absolute; |
| | | top: 0; |
| | | left: 0; |
| | | z-index: 0; |
| | | width: 100%; |
| | | height: 100%; |
| | | pointer-events: none; |
| | | |
| | | .meteor { |
| | | position: absolute; |
| | | width: 2px; |
| | | height: 60px; |
| | | background: linear-gradient( |
| | | to top, |
| | | rgb(255 255 255 / 40%), |
| | | rgb(255 255 255 / 10%), |
| | | transparent |
| | | ); |
| | | opacity: 0; |
| | | transform-origin: top left; |
| | | animation-name: meteor-fall; |
| | | animation-timing-function: linear; |
| | | animation-iteration-count: infinite; |
| | | |
| | | &::before { |
| | | position: absolute; |
| | | right: 0; |
| | | bottom: 0; |
| | | width: 2px; |
| | | height: 2px; |
| | | content: ''; |
| | | background: rgb(255 255 255 / 50%); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | @keyframes meteor-fall { |
| | | 0% { |
| | | opacity: 1; |
| | | transform: translate(0, -60px) rotate(-45deg); |
| | | } |
| | | |
| | | 100% { |
| | | opacity: 0; |
| | | transform: translate(400px, 340px) rotate(-45deg); |
| | | } |
| | | } |
| | | |
| | | @media (width <= 640px) { |
| | | .basic-banner { |
| | | box-sizing: border-box; |
| | | justify-content: flex-start; |
| | | padding: 16px; |
| | | |
| | | &__title { |
| | | font-size: 1.4rem; |
| | | } |
| | | |
| | | &__background-image { |
| | | display: none; |
| | | } |
| | | |
| | | &.has-decoration::after { |
| | | display: none; |
| | | } |
| | | } |
| | | } |
| | | </style> |
| New file |
| | |
| | | <!-- 卡片横幅组件 --> |
| | | <template> |
| | | <div class="art-card-sm flex-c flex-col pb-6" :style="{ height: height }"> |
| | | <div class="flex-c flex-col gap-4 text-center"> |
| | | <div class="w-45"> |
| | | <img :src="image" :alt="title" class="w-full h-full object-contain" /> |
| | | </div> |
| | | <div class="box-border px-4"> |
| | | <p class="mb-2 text-lg font-semibold text-g-800">{{ title }}</p> |
| | | <p class="m-0 text-sm text-g-600">{{ description }}</p> |
| | | </div> |
| | | <div class="flex-c gap-3"> |
| | | <div |
| | | v-if="cancelButton?.show" |
| | | class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md border border-g-300" |
| | | :style="{ |
| | | backgroundColor: cancelButton?.color, |
| | | color: cancelButton?.textColor |
| | | }" |
| | | @click="handleCancel" |
| | | > |
| | | {{ cancelButton?.text }} |
| | | </div> |
| | | <div |
| | | v-if="button?.show" |
| | | class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md" |
| | | :style="{ backgroundColor: button?.color, color: button?.textColor }" |
| | | @click="handleClick" |
| | | > |
| | | {{ button?.text }} |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import defaultIcon from '@imgs/3d/icon1.webp' |
| | | defineOptions({ name: 'ArtCardBanner' }) |
| | | defineProps({ |
| | | height: { required: false, default: '24rem' }, |
| | | image: { required: false, default: defaultIcon }, |
| | | title: { required: false, default: '' }, |
| | | description: { required: false, default: '' }, |
| | | button: { |
| | | required: false, |
| | | default: () => ({ |
| | | show: true, |
| | | text: '查看详情', |
| | | color: 'var(--theme-color)', |
| | | textColor: '#fff' |
| | | }) |
| | | }, |
| | | cancelButton: { |
| | | required: false, |
| | | default: () => ({ |
| | | show: false, |
| | | text: '取消', |
| | | color: '#f5f5f5', |
| | | textColor: '#666' |
| | | }) |
| | | } |
| | | }) |
| | | const emit = defineEmits(['click', 'cancel']) |
| | | const handleClick = () => { |
| | | emit('click') |
| | | } |
| | | const handleCancel = () => { |
| | | emit('cancel') |
| | | } |
| | | </script> |
| New file |
| | |
| | | <!-- 返回顶部按钮 --> |
| | | <template> |
| | | <Transition |
| | | enter-active-class="tad-300 ease-out" |
| | | leave-active-class="tad-200 ease-in" |
| | | enter-from-class="opacity-0 translate-y-2" |
| | | enter-to-class="opacity-100 translate-y-0" |
| | | leave-from-class="opacity-100 translate-y-0" |
| | | leave-to-class="opacity-0 translate-y-2" |
| | | > |
| | | <div |
| | | v-show="showButton" |
| | | class="fixed right-10 bottom-15 size-9.5 flex-cc c-p border border-g-300 rounded-md tad-300 hover:bg-g-200" |
| | | @click="scrollToTop" |
| | | > |
| | | <ArtSvgIcon icon="ri:arrow-up-wide-line" class="text-g-500 text-lg" /> |
| | | </div> |
| | | </Transition> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useCommon } from '@/hooks/core/useCommon' |
| | | defineOptions({ name: 'ArtBackToTop' }) |
| | | const { scrollToTop } = useCommon() |
| | | const showButton = ref(false) |
| | | const scrollThreshold = 300 |
| | | onMounted(() => { |
| | | const scrollContainer = document.getElementById('app-main') |
| | | if (scrollContainer) { |
| | | const { y } = useScroll(scrollContainer) |
| | | watch(y, (newY) => { |
| | | showButton.value = newY > scrollThreshold |
| | | }) |
| | | } |
| | | }) |
| | | </script> |
| New file |
| | |
| | | <!-- 系统logo --> |
| | | <template> |
| | | <div class="flex-cc"> |
| | | <img :style="logoStyle" src="@imgs/common/logo.webp" alt="logo" class="w-full h-full" /> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | defineOptions({ name: 'ArtLogo' }) |
| | | const props = defineProps({ |
| | | size: { required: false, default: 36 } |
| | | }) |
| | | const logoStyle = computed(() => ({ width: `${props.size}px` })) |
| | | </script> |
| New file |
| | |
| | | <!-- 图标组件 --> |
| | | <template> |
| | | <span v-if="icon" v-bind="containerAttrs"> |
| | | <Icon :icon="icon" /> |
| | | </span> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { Icon } from '@iconify/vue/offline' |
| | | defineOptions({ name: 'ArtSvgIcon', inheritAttrs: false }) |
| | | defineProps({ |
| | | icon: { required: false } |
| | | }) |
| | | const attrs = useAttrs() |
| | | const containerAttrs = computed(() => ({ |
| | | ...attrs, |
| | | class: ['art-svg-icon inline-flex shrink-0', attrs.class].filter(Boolean).join(' '), |
| | | style: attrs.style |
| | | })) |
| | | </script> |
| New file |
| | |
| | | <!-- 柱状图卡片 --> |
| | | <template> |
| | | <div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }"> |
| | | <div class="mb-5 flex-b items-start px-5 pt-5"> |
| | | <div> |
| | | <p class="m-0 text-2xl font-medium leading-tight text-g-900"> |
| | | {{ value }} |
| | | </p> |
| | | <p class="mt-1 text-sm text-g-600">{{ label }}</p> |
| | | </div> |
| | | <div |
| | | class="text-sm font-medium text-danger" |
| | | :class="[percentage > 0 ? 'text-success' : '', isMiniChart ? 'absolute bottom-5' : '']" |
| | | > |
| | | {{ percentage > 0 ? '+' : '' }}{{ percentage }}% |
| | | </div> |
| | | <div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-600"> |
| | | {{ date }} |
| | | </div> |
| | | </div> |
| | | <div |
| | | ref="chartRef" |
| | | class="absolute bottom-0 left-0 right-0 mx-auto" |
| | | :class="isMiniChart ? '!absolute !top-5 !right-5 !bottom-auto !left-auto !h-15 !w-4/10' : ''" |
| | | :style="{ height: isMiniChart ? '60px' : `calc(${height}rem - 5rem)` }" |
| | | ></div> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useChartOps, useChartComponent } from '@/hooks/core/useChart' |
| | | |
| | | defineOptions({ name: 'ArtBarChartCard' }) |
| | | const props = defineProps({ |
| | | value: { required: false }, |
| | | label: { required: false }, |
| | | date: { required: false }, |
| | | percentage: { required: false, default: 0 }, |
| | | isMiniChart: { required: false, default: false }, |
| | | height: { required: false, default: 11 }, |
| | | barWidth: { required: false, default: '26%' }, |
| | | color: { required: false }, |
| | | chartData: { required: false, default: () => [] } |
| | | }) |
| | | const { chartRef } = useChartComponent({ |
| | | props: { |
| | | height: `${props.height}rem`, |
| | | loading: false, |
| | | isEmpty: !props.chartData?.length || props.chartData.every((val) => val === 0) |
| | | }, |
| | | checkEmpty: () => !props.chartData?.length || props.chartData.every((val) => val === 0), |
| | | watchSources: [() => props.chartData, () => props.color, () => props.barWidth], |
| | | generateOptions: () => { |
| | | const computedColor = props.color || useChartOps().themeColor |
| | | return { |
| | | grid: { |
| | | top: 0, |
| | | right: 0, |
| | | bottom: 15, |
| | | left: 0 |
| | | }, |
| | | xAxis: { |
| | | type: 'category', |
| | | show: false |
| | | }, |
| | | yAxis: { |
| | | type: 'value', |
| | | show: false |
| | | }, |
| | | series: [ |
| | | { |
| | | data: props.chartData, |
| | | type: 'bar', |
| | | barWidth: props.barWidth, |
| | | itemStyle: { |
| | | color: computedColor, |
| | | borderRadius: 2 |
| | | } |
| | | } |
| | | ] |
| | | } |
| | | } |
| | | }) |
| | | </script> |
| New file |
| | |
| | | <!-- 数据列表卡片 --> |
| | | <template> |
| | | <div class="art-card p-5"> |
| | | <div class="pb-3.5"> |
| | | <p class="text-lg font-medium">{{ title }}</p> |
| | | <p class="text-sm text-g-600">{{ subtitle }}</p> |
| | | </div> |
| | | <ElScrollbar :style="{ height: maxHeight }"> |
| | | <div v-for="(item, index) in list" :key="index" class="flex-c py-3"> |
| | | <div v-if="item.icon" class="flex-cc mr-3 size-10 rounded-lg" :class="item.class"> |
| | | <ArtSvgIcon :icon="item.icon" class="text-xl" /> |
| | | </div> |
| | | <div class="flex-1"> |
| | | <div class="mb-1 text-sm">{{ item.title }}</div> |
| | | <div class="text-xs text-g-500">{{ item.status }}</div> |
| | | </div> |
| | | <div class="ml-3 text-xs text-g-500">{{ item.time }}</div> |
| | | </div> |
| | | </ElScrollbar> |
| | | <ElButton |
| | | class="mt-[25px] w-full text-center" |
| | | v-if="showMoreButton" |
| | | v-ripple |
| | | @click="handleMore" |
| | | >查看更多</ElButton |
| | | > |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | defineOptions({ name: 'ArtDataListCard' }) |
| | | const ITEM_HEIGHT = 66 |
| | | const props = defineProps({ |
| | | list: { required: true }, |
| | | title: { required: true }, |
| | | subtitle: { required: false }, |
| | | maxCount: { required: false, default: 5 }, |
| | | showMoreButton: { required: false } |
| | | }) |
| | | const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`) |
| | | const emit = defineEmits(['more']) |
| | | const handleMore = () => emit('more') |
| | | </script> |
| New file |
| | |
| | | <!-- 环型图卡片 --> |
| | | <template> |
| | | <div class="art-card overflow-hidden" :style="{ height: `${height}rem` }"> |
| | | <div class="flex box-border h-full p-5 pr-2"> |
| | | <div class="flex w-full items-start gap-5"> |
| | | <div class="flex-b h-full flex-1 flex-col"> |
| | | <p class="m-0 text-xl font-medium leading-tight text-g-900"> |
| | | {{ title }} |
| | | </p> |
| | | <div> |
| | | <p class="m-0 mt-2.5 text-xl font-medium leading-tight text-g-900"> |
| | | {{ formatNumber(value) }} |
| | | </p> |
| | | <div |
| | | class="mt-1.5 text-xs font-medium" |
| | | :class="percentage > 0 ? 'text-success' : 'text-danger'" |
| | | > |
| | | {{ percentage > 0 ? '+' : '' }}{{ percentage }}% |
| | | <span v-if="percentageLabel">{{ percentageLabel }}</span> |
| | | </div> |
| | | </div> |
| | | <div class="mt-2 flex gap-4 text-xs text-g-600"> |
| | | <div v-if="currentValue" class="flex-cc"> |
| | | <div class="size-2 bg-theme/100 rounded mr-2"></div> |
| | | {{ currentValue }} |
| | | </div> |
| | | <div v-if="previousValue" class="flex-cc"> |
| | | <div class="size-2 bg-g-400 rounded mr-2"></div> |
| | | {{ previousValue }} |
| | | </div> |
| | | </div> |
| | | </div> |
| | | <div class="flex-c h-full max-w-40 flex-1"> |
| | | <div ref="chartRef" class="h-30 w-full"></div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useChartOps, useChartComponent } from '@/hooks/core/useChart' |
| | | |
| | | defineOptions({ name: 'ArtDonutChartCard' }) |
| | | const props = defineProps({ |
| | | value: { required: true }, |
| | | title: { required: true }, |
| | | percentage: { required: true }, |
| | | percentageLabel: { required: false }, |
| | | currentValue: { required: false }, |
| | | previousValue: { required: false }, |
| | | height: { required: false, default: 9 }, |
| | | color: { required: false }, |
| | | radius: { required: false, default: () => ['70%', '90%'] }, |
| | | data: { required: false, default: () => [0, 0] } |
| | | }) |
| | | const formatNumber = (num) => { |
| | | return num.toLocaleString() |
| | | } |
| | | const { chartRef } = useChartComponent({ |
| | | props: { |
| | | height: `${props.height}rem`, |
| | | loading: false, |
| | | isEmpty: props.data.every((val) => val === 0) |
| | | }, |
| | | checkEmpty: () => props.data.every((val) => val === 0), |
| | | watchSources: [ |
| | | () => props.data, |
| | | () => props.color, |
| | | () => props.radius, |
| | | () => props.currentValue, |
| | | () => props.previousValue |
| | | ], |
| | | generateOptions: () => { |
| | | const computedColor = props.color || useChartOps().themeColor |
| | | return { |
| | | series: [ |
| | | { |
| | | type: 'pie', |
| | | radius: props.radius, |
| | | avoidLabelOverlap: false, |
| | | label: { |
| | | show: false |
| | | }, |
| | | data: [ |
| | | { |
| | | value: props.data[0], |
| | | name: props.currentValue, |
| | | itemStyle: { color: computedColor } |
| | | }, |
| | | { |
| | | value: props.data[1], |
| | | name: props.previousValue, |
| | | itemStyle: { color: '#e6e8f7' } |
| | | } |
| | | ] |
| | | } |
| | | ] |
| | | } |
| | | } |
| | | }) |
| | | </script> |
| New file |
| | |
| | | <!-- 图片卡片 --> |
| | | <template> |
| | | <div class="w-full c-p" @click="handleClick"> |
| | | <div class="art-card overflow-hidden"> |
| | | <div class="relative w-full aspect-[16/10] overflow-hidden"> |
| | | <ElImage |
| | | :src="props.imageUrl" |
| | | fit="cover" |
| | | loading="lazy" |
| | | class="w-full h-full transition-transform duration-300 ease-in-out hover:scale-105" |
| | | > |
| | | <template #placeholder> |
| | | <div class="flex-cc w-full h-full bg-[#f5f7fa]"> |
| | | <ElIcon><Picture /></ElIcon> |
| | | </div> |
| | | </template> |
| | | </ElImage> |
| | | <div |
| | | class="absolute right-3.5 bottom-3.5 py-1 px-2 text-xs bg-g-200 rounded" |
| | | v-if="props.readTime" |
| | | > |
| | | {{ props.readTime }} 阅读 |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="p-4"> |
| | | <div |
| | | class="inline-block py-0.5 px-2 mb-2 text-xs bg-g-300/70 rounded" |
| | | v-if="props.category" |
| | | > |
| | | {{ props.category }} |
| | | </div> |
| | | <p class="m-0 mb-3 text-base font-medium">{{ props.title }}</p> |
| | | <div class="flex-c gap-4 text-xs text-g-600"> |
| | | <span class="flex-c gap-1" v-if="props.views"> |
| | | <ElIcon class="text-base"><View /></ElIcon> |
| | | {{ props.views }} |
| | | </span> |
| | | <span class="flex-c gap-1" v-if="props.comments"> |
| | | <ElIcon class="text-base"><ChatLineRound /></ElIcon> |
| | | {{ props.comments }} |
| | | </span> |
| | | <span>{{ props.date }}</span> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { Picture, View, ChatLineRound } from '@element-plus/icons-vue' |
| | | |
| | | defineOptions({ name: 'ArtImageCard' }) |
| | | const props = defineProps({ |
| | | imageUrl: { required: false, default: '' }, |
| | | title: { required: false, default: '' }, |
| | | category: { required: false, default: '' }, |
| | | readTime: { required: false, default: '' }, |
| | | views: { required: false, default: 0 }, |
| | | comments: { required: false, default: 0 }, |
| | | date: { required: false, default: '' } |
| | | }) |
| | | const emit = defineEmits(['click']) |
| | | const handleClick = () => { |
| | | emit('click', props) |
| | | } |
| | | </script> |
| New file |
| | |
| | | <!-- 折线图卡片 --> |
| | | <template> |
| | | <div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }"> |
| | | <div class="mb-2.5 flex-b items-start p-5"> |
| | | <div> |
| | | <p class="text-2xl font-medium leading-none"> |
| | | {{ value }} |
| | | </p> |
| | | <p class="mt-1 text-sm text-g-500">{{ label }}</p> |
| | | </div> |
| | | <div |
| | | class="text-sm font-medium" |
| | | :class="[ |
| | | percentage > 0 ? 'text-success' : 'text-danger', |
| | | isMiniChart ? 'absolute bottom-5' : '' |
| | | ]" |
| | | > |
| | | {{ percentage > 0 ? '+' : '' }}{{ percentage }}% |
| | | </div> |
| | | <div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-500"> |
| | | {{ date }} |
| | | </div> |
| | | </div> |
| | | <div |
| | | ref="chartRef" |
| | | class="absolute bottom-0 left-0 right-0 box-border w-full" |
| | | :class="isMiniChart ? '!absolute !top-5 !right-5 !bottom-auto !left-auto !h-15 !w-4/10' : ''" |
| | | :style="{ height: isMiniChart ? '60px' : `calc(${height}rem - 5rem)` }" |
| | | ></div> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { graphic } from '@/plugins/echarts' |
| | | import { getCssVar, hexToRgba } from '@/utils/ui' |
| | | import { useChartOps, useChartComponent } from '@/hooks/core/useChart' |
| | | |
| | | defineOptions({ name: 'ArtLineChartCard' }) |
| | | const props = defineProps({ |
| | | value: { required: false }, |
| | | label: { required: false }, |
| | | date: { required: false }, |
| | | percentage: { required: false, default: 0 }, |
| | | isMiniChart: { required: false, default: false }, |
| | | height: { required: false, default: 11 }, |
| | | color: { required: false }, |
| | | chartData: { required: false, default: () => [] }, |
| | | showAreaColor: { required: false, default: false } |
| | | }) |
| | | const { chartRef } = useChartComponent({ |
| | | props: { |
| | | height: `${props.height}rem`, |
| | | loading: false, |
| | | isEmpty: !props.chartData?.length || props.chartData.every((val) => val === 0) |
| | | }, |
| | | checkEmpty: () => !props.chartData?.length || props.chartData.every((val) => val === 0), |
| | | watchSources: [() => props.chartData, () => props.color, () => props.showAreaColor], |
| | | generateOptions: () => { |
| | | const computedColor = props.color || useChartOps().themeColor |
| | | return { |
| | | grid: { |
| | | top: 0, |
| | | right: 0, |
| | | bottom: 0, |
| | | left: 0 |
| | | }, |
| | | xAxis: { |
| | | type: 'category', |
| | | show: false, |
| | | boundaryGap: false |
| | | }, |
| | | yAxis: { |
| | | type: 'value', |
| | | show: false |
| | | }, |
| | | series: [ |
| | | { |
| | | data: props.chartData, |
| | | type: 'line', |
| | | smooth: true, |
| | | showSymbol: false, |
| | | lineStyle: { |
| | | width: 3, |
| | | color: computedColor |
| | | }, |
| | | areaStyle: props.showAreaColor |
| | | ? { |
| | | color: new graphic.LinearGradient(0, 0, 0, 1, [ |
| | | { |
| | | offset: 0, |
| | | color: props.color |
| | | ? hexToRgba(props.color, 0.2).rgba |
| | | : hexToRgba(getCssVar('--el-color-primary'), 0.2).rgba |
| | | }, |
| | | { |
| | | offset: 1, |
| | | color: props.color |
| | | ? hexToRgba(props.color, 0.01).rgba |
| | | : hexToRgba(getCssVar('--el-color-primary'), 0.01).rgba |
| | | } |
| | | ]) |
| | | } |
| | | : void 0 |
| | | } |
| | | ] |
| | | } |
| | | } |
| | | }) |
| | | </script> |
| New file |
| | |
| | | <!-- 进度条卡片 --> |
| | | <template> |
| | | <div class="art-card h-32 flex flex-col justify-center px-5"> |
| | | <div class="mb-3.5 flex-c" :style="{ justifyContent: icon ? 'space-between' : 'flex-start' }"> |
| | | <div v-if="icon" class="size-11 flex-cc bg-g-300 text-xl rounded-lg" :class="iconStyle"> |
| | | <ArtSvgIcon :icon="icon" class="text-2xl"></ArtSvgIcon> |
| | | </div> |
| | | <div> |
| | | <ArtCountTo |
| | | class="mb-1 block text-2xl font-semibold" |
| | | :target="percentage" |
| | | :duration="2000" |
| | | suffix="%" |
| | | :style="{ textAlign: icon ? 'right' : 'left' }" |
| | | /> |
| | | <p class="text-sm text-g-500">{{ title }}</p> |
| | | </div> |
| | | </div> |
| | | <ElProgress |
| | | :percentage="currentPercentage" |
| | | :stroke-width="strokeWidth" |
| | | :show-text="false" |
| | | :color="color" |
| | | class="[&_.el-progress-bar__outer]:bg-[rgb(240_240_240)]" |
| | | /> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | defineOptions({ name: 'ArtProgressCard' }) |
| | | const props = defineProps({ |
| | | percentage: { required: true }, |
| | | title: { required: false }, |
| | | icon: { required: false }, |
| | | iconStyle: { required: false }, |
| | | strokeWidth: { required: false, default: 5 }, |
| | | color: { required: false, default: '#67C23A' } |
| | | }) |
| | | const animationDuration = 500 |
| | | const currentPercentage = ref(0) |
| | | const animateProgress = () => { |
| | | const startTime = Date.now() |
| | | const startValue = currentPercentage.value |
| | | const endValue = props.percentage |
| | | const animate = () => { |
| | | const currentTime = Date.now() |
| | | const elapsed = currentTime - startTime |
| | | const progress = Math.min(elapsed / animationDuration, 1) |
| | | currentPercentage.value = startValue + (endValue - startValue) * progress |
| | | if (progress < 1) { |
| | | requestAnimationFrame(animate) |
| | | } |
| | | } |
| | | requestAnimationFrame(animate) |
| | | } |
| | | onMounted(() => { |
| | | animateProgress() |
| | | }) |
| | | watch( |
| | | () => props.percentage, |
| | | () => { |
| | | animateProgress() |
| | | } |
| | | ) |
| | | </script> |
| New file |
| | |
| | | <!-- 统计卡片 --> |
| | | <template> |
| | | <div |
| | | class="art-card h-32 flex-c px-5 transition-transform duration-200 hover:-translate-y-0.5" |
| | | :class="boxStyle" |
| | | > |
| | | <div v-if="icon" class="mr-4 size-11 flex-cc rounded-lg text-xl text-white" :class="iconStyle"> |
| | | <ArtSvgIcon :icon="icon"></ArtSvgIcon> |
| | | </div> |
| | | <div class="flex-1"> |
| | | <p class="m-0 text-lg font-medium" :style="{ color: textColor }" v-if="title"> |
| | | {{ title }} |
| | | </p> |
| | | <ArtCountTo |
| | | class="m-0 text-2xl font-medium" |
| | | v-if="count !== undefined" |
| | | :target="count" |
| | | :duration="2000" |
| | | :decimals="decimals" |
| | | :separator="separator" |
| | | /> |
| | | <p |
| | | class="mt-1 text-sm text-g-500 opacity-90" |
| | | :style="{ color: textColor }" |
| | | v-if="description" |
| | | >{{ description }}</p |
| | | > |
| | | </div> |
| | | <div v-if="showArrow"> |
| | | <ArtSvgIcon icon="ri:arrow-right-s-line" class="text-xl text-g-500" /> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | defineOptions({ name: 'ArtStatsCard' }) |
| | | defineProps({ |
| | | icon: { required: false }, |
| | | title: { required: false }, |
| | | description: { required: false }, |
| | | count: { required: false }, |
| | | showArrow: { required: false, default: false }, |
| | | boxStyle: { required: false }, |
| | | iconStyle: { required: false }, |
| | | textColor: { required: false }, |
| | | iconSize: { required: false, default: 30 }, |
| | | iconBgRadius: { required: false, default: 50 }, |
| | | decimals: { required: false, default: 0 }, |
| | | separator: { required: false, default: ',' } |
| | | }) |
| | | </script> |
| New file |
| | |
| | | <!-- 时间轴列表卡片 --> |
| | | <template> |
| | | <div class="art-card p-5"> |
| | | <div class="pb-3.5"> |
| | | <p class="text-lg font-medium">{{ title }}</p> |
| | | <p class="text-sm text-g-600">{{ subtitle }}</p> |
| | | </div> |
| | | <ElScrollbar :style="{ height: maxHeight }"> |
| | | <ElTimeline class="!pl-0.5"> |
| | | <ElTimelineItem |
| | | v-for="item in list" |
| | | :key="item.time" |
| | | :timestamp="item.time" |
| | | :placement="TIMELINE_PLACEMENT" |
| | | :color="item.status" |
| | | :center="true" |
| | | > |
| | | <div class="flex-c gap-3"> |
| | | <div class="flex-c gap-2"> |
| | | <span class="text-sm">{{ item.content }}</span> |
| | | <span v-if="item.code" class="text-sm text-theme"> #{{ item.code }} </span> |
| | | </div> |
| | | </div> |
| | | </ElTimelineItem> |
| | | </ElTimeline> |
| | | </ElScrollbar> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | defineOptions({ name: 'ArtTimelineListCard' }) |
| | | const ITEM_HEIGHT = 65 |
| | | const TIMELINE_PLACEMENT = 'top' |
| | | const props = defineProps({ |
| | | list: { required: true }, |
| | | title: { required: false, default: '' }, |
| | | subtitle: { required: false, default: '' }, |
| | | maxCount: { required: false, default: 5 } |
| | | }) |
| | | const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`) |
| | | </script> |
| New file |
| | |
| | | <!-- 柱状图 --> |
| | | <template> |
| | | <div ref="chartRef" :style="{ height: props.height }" v-loading="props.loading"> </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useChartOps, useChartComponent } from '@/hooks/core/useChart' |
| | | import { getCssVar } from '@/utils/ui' |
| | | import { graphic } from '@/plugins/echarts' |
| | | defineOptions({ name: 'ArtBarChart' }) |
| | | const props = defineProps({ |
| | | height: { required: false, default: useChartOps().chartHeight }, |
| | | loading: { required: false, default: false }, |
| | | isEmpty: { required: false, default: false }, |
| | | colors: { required: false, default: () => useChartOps().colors }, |
| | | borderRadius: { required: false, default: 4 }, |
| | | data: { required: false, default: () => [0, 0, 0, 0, 0, 0, 0] }, |
| | | xAxisData: { required: false, default: () => [] }, |
| | | barWidth: { required: false, default: '40%' }, |
| | | stack: { required: false, default: false }, |
| | | showAxisLabel: { required: false, default: true }, |
| | | showAxisLine: { required: false, default: true }, |
| | | showSplitLine: { required: false, default: true }, |
| | | showTooltip: { required: false, default: true }, |
| | | showLegend: { required: false, default: false }, |
| | | legendPosition: { required: false, default: 'bottom' } |
| | | }) |
| | | const isMultipleData = computed(() => { |
| | | return ( |
| | | Array.isArray(props.data) && |
| | | props.data.length > 0 && |
| | | typeof props.data[0] === 'object' && |
| | | 'name' in props.data[0] |
| | | ) |
| | | }) |
| | | const getColor = (customColor, index) => { |
| | | if (customColor) return customColor |
| | | if (index !== void 0) { |
| | | return props.colors[index % props.colors.length] |
| | | } |
| | | return new graphic.LinearGradient(0, 0, 0, 1, [ |
| | | { |
| | | offset: 0, |
| | | color: getCssVar('--el-color-primary-light-4') |
| | | }, |
| | | { |
| | | offset: 1, |
| | | color: getCssVar('--el-color-primary') |
| | | } |
| | | ]) |
| | | } |
| | | const createGradientColor = (color) => { |
| | | return new graphic.LinearGradient(0, 0, 0, 1, [ |
| | | { |
| | | offset: 0, |
| | | color |
| | | }, |
| | | { |
| | | offset: 1, |
| | | color |
| | | } |
| | | ]) |
| | | } |
| | | const getBaseItemStyle = (color) => ({ |
| | | borderRadius: props.borderRadius, |
| | | color: typeof color === 'string' ? createGradientColor(color) : color |
| | | }) |
| | | const createSeriesItem = (config) => { |
| | | const animationConfig = getAnimationConfig() |
| | | return { |
| | | name: config.name, |
| | | data: config.data, |
| | | type: 'bar', |
| | | stack: config.stack, |
| | | itemStyle: getBaseItemStyle(config.color), |
| | | barWidth: config.barWidth || props.barWidth, |
| | | ...animationConfig |
| | | } |
| | | } |
| | | const { |
| | | chartRef, |
| | | getAxisLineStyle, |
| | | getAxisLabelStyle, |
| | | getAxisTickStyle, |
| | | getSplitLineStyle, |
| | | getAnimationConfig, |
| | | getTooltipStyle, |
| | | getLegendStyle, |
| | | getGridWithLegend |
| | | } = useChartComponent({ |
| | | props, |
| | | checkEmpty: () => { |
| | | if (Array.isArray(props.data) && typeof props.data[0] === 'number') { |
| | | const singleData = props.data |
| | | return !singleData.length || singleData.every((val) => val === 0) |
| | | } |
| | | if (Array.isArray(props.data) && typeof props.data[0] === 'object') { |
| | | const multiData = props.data |
| | | return ( |
| | | !multiData.length || |
| | | multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0)) |
| | | ) |
| | | } |
| | | return true |
| | | }, |
| | | watchSources: [() => props.data, () => props.xAxisData, () => props.colors], |
| | | generateOptions: () => { |
| | | const options = { |
| | | grid: getGridWithLegend(props.showLegend && isMultipleData.value, props.legendPosition, { |
| | | top: 15, |
| | | right: 0, |
| | | left: 0 |
| | | }), |
| | | tooltip: props.showTooltip ? getTooltipStyle() : void 0, |
| | | xAxis: { |
| | | type: 'category', |
| | | data: props.xAxisData, |
| | | axisTick: getAxisTickStyle(), |
| | | axisLine: getAxisLineStyle(props.showAxisLine), |
| | | axisLabel: getAxisLabelStyle(props.showAxisLabel) |
| | | }, |
| | | yAxis: { |
| | | type: 'value', |
| | | axisLabel: getAxisLabelStyle(props.showAxisLabel), |
| | | axisLine: getAxisLineStyle(props.showAxisLine), |
| | | splitLine: getSplitLineStyle(props.showSplitLine) |
| | | } |
| | | } |
| | | if (props.showLegend && isMultipleData.value) { |
| | | options.legend = getLegendStyle(props.legendPosition) |
| | | } |
| | | if (isMultipleData.value) { |
| | | const multiData = props.data |
| | | options.series = multiData.map((item, index) => { |
| | | const computedColor = getColor(props.colors[index], index) |
| | | return createSeriesItem({ |
| | | name: item.name, |
| | | data: item.data, |
| | | color: computedColor, |
| | | barWidth: item.barWidth, |
| | | stack: props.stack ? item.stack || 'total' : void 0 |
| | | }) |
| | | }) |
| | | } else { |
| | | const singleData = props.data |
| | | const computedColor = getColor() |
| | | options.series = [ |
| | | createSeriesItem({ |
| | | data: singleData, |
| | | color: computedColor |
| | | }) |
| | | ] |
| | | } |
| | | return options |
| | | } |
| | | }) |
| | | </script> |
| New file |
| | |
| | | <!-- 双向堆叠柱状图 --> |
| | | <template> |
| | | <div ref="chartRef" :style="{ height: props.height }" v-loading="props.loading"> </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useChartOps, useChartComponent } from '@/hooks/core/useChart' |
| | | defineOptions({ name: 'ArtDualBarCompareChart' }) |
| | | const props = defineProps({ |
| | | height: { required: false, default: useChartOps().chartHeight }, |
| | | loading: { required: false, default: false }, |
| | | isEmpty: { required: false, default: false }, |
| | | colors: { required: false, default: () => useChartOps().colors }, |
| | | positiveData: { required: false, default: () => [] }, |
| | | negativeData: { required: false, default: () => [] }, |
| | | xAxisData: { required: false, default: () => [] }, |
| | | positiveName: { required: false, default: '正向数据' }, |
| | | negativeName: { required: false, default: '负向数据' }, |
| | | barWidth: { required: false, default: 16 }, |
| | | yAxisMin: { required: false, default: -100 }, |
| | | yAxisMax: { required: false, default: 100 }, |
| | | showDataLabel: { required: false, default: false }, |
| | | positiveBorderRadius: { required: false, default: () => [10, 10, 0, 0] }, |
| | | negativeBorderRadius: { required: false, default: () => [0, 0, 10, 10] }, |
| | | showAxisLabel: { required: false, default: true }, |
| | | showAxisLine: { required: false, default: false }, |
| | | showSplitLine: { required: false, default: false }, |
| | | showTooltip: { required: false, default: true }, |
| | | showLegend: { required: false, default: false }, |
| | | legendPosition: { required: false, default: 'bottom' } |
| | | }) |
| | | const createSeriesConfig = (config) => { |
| | | const { fontColor } = useChartOps() |
| | | const animationConfig = getAnimationConfig() |
| | | return { |
| | | name: config.name, |
| | | type: 'bar', |
| | | stack: 'total', |
| | | barWidth: props.barWidth, |
| | | barGap: '-100%', |
| | | data: config.data, |
| | | itemStyle: { |
| | | borderRadius: config.borderRadius, |
| | | color: props.colors[config.colorIndex] |
| | | }, |
| | | label: { |
| | | show: props.showDataLabel, |
| | | position: config.labelPosition, |
| | | formatter: config.formatter || ((params) => String(params.value)), |
| | | color: fontColor, |
| | | fontSize: 12 |
| | | }, |
| | | ...animationConfig |
| | | } |
| | | } |
| | | const { |
| | | chartRef, |
| | | getAxisLineStyle, |
| | | getAxisLabelStyle, |
| | | getAxisTickStyle, |
| | | getSplitLineStyle, |
| | | getAnimationConfig, |
| | | getTooltipStyle, |
| | | getLegendStyle, |
| | | getGridWithLegend |
| | | } = useChartComponent({ |
| | | props, |
| | | checkEmpty: () => { |
| | | return ( |
| | | props.isEmpty || |
| | | !props.positiveData.length || |
| | | !props.negativeData.length || |
| | | (props.positiveData.every((val) => val === 0) && |
| | | props.negativeData.every((val) => val === 0)) |
| | | ) |
| | | }, |
| | | watchSources: [ |
| | | () => props.positiveData, |
| | | () => props.negativeData, |
| | | () => props.xAxisData, |
| | | () => props.colors |
| | | ], |
| | | generateOptions: () => { |
| | | const processedNegativeData = props.negativeData.map((val) => (val > 0 ? -val : val)) |
| | | const gridConfig = { |
| | | top: props.showLegend ? 50 : 20, |
| | | right: 0, |
| | | left: 0, |
| | | bottom: 0, |
| | | // 增加底部间距 |
| | | containLabel: true |
| | | } |
| | | const options = { |
| | | backgroundColor: 'transparent', |
| | | animation: true, |
| | | animationDuration: 1e3, |
| | | animationEasing: 'cubicOut', |
| | | grid: getGridWithLegend(props.showLegend, props.legendPosition, gridConfig), |
| | | // 优化的提示框配置 |
| | | tooltip: props.showTooltip |
| | | ? { |
| | | ...getTooltipStyle(), |
| | | trigger: 'axis', |
| | | axisPointer: { |
| | | type: 'none' |
| | | // 去除指示线 |
| | | } |
| | | } |
| | | : void 0, |
| | | // 图例配置 |
| | | legend: props.showLegend |
| | | ? { |
| | | ...getLegendStyle(props.legendPosition), |
| | | data: [props.negativeName, props.positiveName] |
| | | } |
| | | : void 0, |
| | | // X轴配置 |
| | | xAxis: { |
| | | type: 'category', |
| | | data: props.xAxisData, |
| | | axisTick: getAxisTickStyle(), |
| | | axisLine: getAxisLineStyle(props.showAxisLine), |
| | | axisLabel: getAxisLabelStyle(props.showAxisLabel), |
| | | boundaryGap: true |
| | | }, |
| | | // Y轴配置 |
| | | yAxis: { |
| | | type: 'value', |
| | | min: props.yAxisMin, |
| | | max: props.yAxisMax, |
| | | axisLabel: getAxisLabelStyle(props.showAxisLabel), |
| | | axisLine: getAxisLineStyle(props.showAxisLine), |
| | | splitLine: getSplitLineStyle(props.showSplitLine) |
| | | }, |
| | | // 系列配置 |
| | | series: [ |
| | | // 负向数据系列 |
| | | createSeriesConfig({ |
| | | name: props.negativeName, |
| | | data: processedNegativeData, |
| | | borderRadius: props.negativeBorderRadius, |
| | | labelPosition: 'bottom', |
| | | colorIndex: 1, |
| | | formatter: (params) => String(Math.abs(params.value)) |
| | | }), |
| | | // 正向数据系列 |
| | | createSeriesConfig({ |
| | | name: props.positiveName, |
| | | data: props.positiveData, |
| | | borderRadius: props.positiveBorderRadius, |
| | | labelPosition: 'top', |
| | | colorIndex: 0 |
| | | }) |
| | | ] |
| | | } |
| | | return options |
| | | } |
| | | }) |
| | | </script> |
| New file |
| | |
| | | <!-- 水平柱状图 --> |
| | | <template> |
| | | <div |
| | | ref="chartRef" |
| | | class="relative w-full" |
| | | :style="{ height: props.height }" |
| | | v-loading="props.loading" |
| | | ></div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useChartOps, useChartComponent } from '@/hooks/core/useChart' |
| | | import { getCssVar } from '@/utils/ui' |
| | | import { graphic } from '@/plugins/echarts' |
| | | defineOptions({ name: 'ArtHBarChart' }) |
| | | const props = defineProps({ |
| | | height: { required: false, default: useChartOps().chartHeight }, |
| | | loading: { required: false, default: false }, |
| | | isEmpty: { required: false, default: false }, |
| | | colors: { required: false, default: () => useChartOps().colors }, |
| | | data: { required: false, default: () => [0, 0, 0, 0, 0, 0, 0] }, |
| | | xAxisData: { required: false, default: () => [] }, |
| | | barWidth: { required: false, default: '36%' }, |
| | | stack: { required: false, default: false }, |
| | | showAxisLabel: { required: false, default: true }, |
| | | showAxisLine: { required: false, default: true }, |
| | | showSplitLine: { required: false, default: true }, |
| | | showTooltip: { required: false, default: true }, |
| | | showLegend: { required: false, default: false }, |
| | | legendPosition: { required: false, default: 'bottom' } |
| | | }) |
| | | const isMultipleData = computed(() => { |
| | | return ( |
| | | Array.isArray(props.data) && |
| | | props.data.length > 0 && |
| | | typeof props.data[0] === 'object' && |
| | | 'name' in props.data[0] |
| | | ) |
| | | }) |
| | | const getColor = (customColor, index) => { |
| | | if (customColor) return customColor |
| | | if (index !== void 0) { |
| | | return props.colors[index % props.colors.length] |
| | | } |
| | | return new graphic.LinearGradient(0, 0, 1, 0, [ |
| | | { |
| | | offset: 0, |
| | | color: getCssVar('--el-color-primary') |
| | | }, |
| | | { |
| | | offset: 1, |
| | | color: getCssVar('--el-color-primary-light-4') |
| | | } |
| | | ]) |
| | | } |
| | | const createGradientColor = (color) => { |
| | | return new graphic.LinearGradient(0, 0, 1, 0, [ |
| | | { |
| | | offset: 0, |
| | | color |
| | | }, |
| | | { |
| | | offset: 1, |
| | | color |
| | | } |
| | | ]) |
| | | } |
| | | const getBaseItemStyle = (color) => ({ |
| | | borderRadius: 4, |
| | | color: typeof color === 'string' ? createGradientColor(color) : color |
| | | }) |
| | | const createSeriesItem = (config) => { |
| | | const animationConfig = getAnimationConfig() |
| | | return { |
| | | name: config.name, |
| | | data: config.data, |
| | | type: 'bar', |
| | | stack: config.stack, |
| | | itemStyle: getBaseItemStyle(config.color), |
| | | barWidth: config.barWidth || props.barWidth, |
| | | ...animationConfig |
| | | } |
| | | } |
| | | const { |
| | | chartRef, |
| | | getAxisLineStyle, |
| | | getAxisLabelStyle, |
| | | getAxisTickStyle, |
| | | getSplitLineStyle, |
| | | getAnimationConfig, |
| | | getTooltipStyle, |
| | | getLegendStyle, |
| | | getGridWithLegend |
| | | } = useChartComponent({ |
| | | props, |
| | | checkEmpty: () => { |
| | | if (Array.isArray(props.data) && typeof props.data[0] === 'number') { |
| | | const singleData = props.data |
| | | return !singleData.length || singleData.every((val) => val === 0) |
| | | } |
| | | if (Array.isArray(props.data) && typeof props.data[0] === 'object') { |
| | | const multiData = props.data |
| | | return ( |
| | | !multiData.length || |
| | | multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0)) |
| | | ) |
| | | } |
| | | return true |
| | | }, |
| | | watchSources: [() => props.data, () => props.xAxisData, () => props.colors], |
| | | generateOptions: () => { |
| | | const options = { |
| | | grid: getGridWithLegend(props.showLegend && isMultipleData.value, props.legendPosition, { |
| | | top: 15, |
| | | right: 0, |
| | | left: 0 |
| | | }), |
| | | tooltip: props.showTooltip ? getTooltipStyle() : void 0, |
| | | xAxis: { |
| | | type: 'value', |
| | | axisTick: getAxisTickStyle(), |
| | | axisLine: getAxisLineStyle(props.showAxisLine), |
| | | axisLabel: getAxisLabelStyle(props.showAxisLabel), |
| | | splitLine: getSplitLineStyle(props.showSplitLine) |
| | | }, |
| | | yAxis: { |
| | | type: 'category', |
| | | data: props.xAxisData, |
| | | axisTick: getAxisTickStyle(), |
| | | axisLabel: getAxisLabelStyle(props.showAxisLabel), |
| | | axisLine: getAxisLineStyle(props.showAxisLine) |
| | | } |
| | | } |
| | | if (props.showLegend && isMultipleData.value) { |
| | | options.legend = getLegendStyle(props.legendPosition) |
| | | } |
| | | if (isMultipleData.value) { |
| | | const multiData = props.data |
| | | options.series = multiData.map((item, index) => { |
| | | const computedColor = getColor(props.colors[index], index) |
| | | return createSeriesItem({ |
| | | name: item.name, |
| | | data: item.data, |
| | | color: computedColor, |
| | | barWidth: item.barWidth, |
| | | stack: props.stack ? item.stack || 'total' : void 0 |
| | | }) |
| | | }) |
| | | } else { |
| | | const singleData = props.data |
| | | const computedColor = getColor() |
| | | options.series = [ |
| | | createSeriesItem({ |
| | | data: singleData, |
| | | color: computedColor |
| | | }) |
| | | ] |
| | | } |
| | | return options |
| | | } |
| | | }) |
| | | </script> |
| New file |
| | |
| | | <!-- k线图表 --> |
| | | <template> |
| | | <div |
| | | ref="chartRef" |
| | | class="relative w-full" |
| | | :style="{ height: props.height }" |
| | | v-loading="props.loading" |
| | | ></div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useChartOps, useChartComponent } from '@/hooks/core/useChart' |
| | | defineOptions({ name: 'ArtKLineChart' }) |
| | | const props = defineProps({ |
| | | height: { required: false, default: useChartOps().chartHeight }, |
| | | loading: { required: false, default: false }, |
| | | isEmpty: { required: false, default: false }, |
| | | colors: { required: false, default: () => useChartOps().colors }, |
| | | data: { required: false, default: () => [] }, |
| | | showDataZoom: { required: false, default: false }, |
| | | dataZoomStart: { required: false, default: 0 }, |
| | | dataZoomEnd: { required: false, default: 100 } |
| | | }) |
| | | const getActualColors = () => { |
| | | const defaultUpColor = '#4C87F3' |
| | | const defaultDownColor = '#8BD8FC' |
| | | return { |
| | | upColor: props.colors?.[0] || defaultUpColor, |
| | | downColor: props.colors?.[1] || defaultDownColor |
| | | } |
| | | } |
| | | const { |
| | | chartRef, |
| | | getAxisLineStyle, |
| | | getAxisLabelStyle, |
| | | getAxisTickStyle, |
| | | getSplitLineStyle, |
| | | getAnimationConfig, |
| | | getTooltipStyle |
| | | } = useChartComponent({ |
| | | props, |
| | | checkEmpty: () => { |
| | | return ( |
| | | !props.data?.length || |
| | | props.data.every( |
| | | (item) => item.open === 0 && item.close === 0 && item.high === 0 && item.low === 0 |
| | | ) |
| | | ) |
| | | }, |
| | | watchSources: [ |
| | | () => props.data, |
| | | () => props.colors, |
| | | () => props.showDataZoom, |
| | | () => props.dataZoomStart, |
| | | () => props.dataZoomEnd |
| | | ], |
| | | generateOptions: () => { |
| | | const { upColor, downColor } = getActualColors() |
| | | return { |
| | | grid: { |
| | | top: 20, |
| | | right: 20, |
| | | bottom: props.showDataZoom ? 80 : 20, |
| | | left: 20, |
| | | containLabel: true |
| | | }, |
| | | tooltip: getTooltipStyle('axis', { |
| | | axisPointer: { |
| | | type: 'cross' |
| | | }, |
| | | formatter: (params) => { |
| | | const param = params[0] |
| | | const data = param.data |
| | | return ` |
| | | <div style="padding: 5px;"> |
| | | <div><strong>时间:</strong>${param.name}</div> |
| | | <div><strong>开盘:</strong>${data[0]}</div> |
| | | <div><strong>收盘:</strong>${data[1]}</div> |
| | | <div><strong>最低:</strong>${data[2]}</div> |
| | | <div><strong>最高:</strong>${data[3]}</div> |
| | | </div> |
| | | ` |
| | | } |
| | | }), |
| | | xAxis: { |
| | | type: 'category', |
| | | data: props.data.map((item) => item.time), |
| | | axisTick: getAxisTickStyle(), |
| | | axisLine: getAxisLineStyle(true), |
| | | axisLabel: getAxisLabelStyle(true) |
| | | }, |
| | | yAxis: { |
| | | type: 'value', |
| | | scale: true, |
| | | axisLabel: getAxisLabelStyle(true), |
| | | axisLine: getAxisLineStyle(true), |
| | | splitLine: getSplitLineStyle(true) |
| | | }, |
| | | series: [ |
| | | { |
| | | type: 'candlestick', |
| | | data: props.data.map((item) => [item.open, item.close, item.low, item.high]), |
| | | itemStyle: { |
| | | color: upColor, |
| | | color0: downColor, |
| | | borderColor: upColor, |
| | | borderColor0: downColor, |
| | | borderWidth: 1 |
| | | }, |
| | | emphasis: { |
| | | itemStyle: { |
| | | borderWidth: 2, |
| | | shadowBlur: 10, |
| | | shadowColor: 'rgba(0, 0, 0, 0.3)' |
| | | } |
| | | }, |
| | | ...getAnimationConfig() |
| | | } |
| | | ], |
| | | dataZoom: props.showDataZoom |
| | | ? [ |
| | | { |
| | | type: 'inside', |
| | | start: props.dataZoomStart, |
| | | end: props.dataZoomEnd |
| | | }, |
| | | { |
| | | show: true, |
| | | type: 'slider', |
| | | top: '90%', |
| | | start: props.dataZoomStart, |
| | | end: props.dataZoomEnd |
| | | } |
| | | ] |
| | | : void 0 |
| | | } |
| | | } |
| | | }) |
| | | </script> |
| New file |
| | |
| | | <!-- 折线图,支持多组数据,支持阶梯式动画效果 --> |
| | | <template> |
| | | <div |
| | | ref="chartRef" |
| | | class="relative w-[calc(100%+10px)]" |
| | | :style="{ height: props.height }" |
| | | v-loading="props.loading" |
| | | > |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { graphic } from '@/plugins/echarts' |
| | | import { getCssVar, hexToRgba } from '@/utils/ui' |
| | | import { useChartOps, useChartComponent } from '@/hooks/core/useChart' |
| | | defineOptions({ name: 'ArtLineChart' }) |
| | | const props = defineProps({ |
| | | height: { required: false, default: useChartOps().chartHeight }, |
| | | loading: { required: false, default: false }, |
| | | isEmpty: { required: false, default: false }, |
| | | colors: { required: false, default: () => useChartOps().colors }, |
| | | data: { required: false, default: () => [0, 0, 0, 0, 0, 0, 0] }, |
| | | xAxisData: { required: false, default: () => [] }, |
| | | lineWidth: { required: false, default: 2.5 }, |
| | | showAreaColor: { required: false, default: false }, |
| | | smooth: { required: false, default: true }, |
| | | symbol: { required: false, default: 'none' }, |
| | | symbolSize: { required: false, default: 6 }, |
| | | animationDelay: { required: false, default: 200 }, |
| | | showAxisLabel: { required: false, default: true }, |
| | | showAxisLine: { required: false, default: true }, |
| | | showSplitLine: { required: false, default: true }, |
| | | showTooltip: { required: false, default: true }, |
| | | showLegend: { required: false, default: false }, |
| | | legendPosition: { required: false, default: 'bottom' } |
| | | }) |
| | | const isAnimating = ref(false) |
| | | const animationTimers = ref([]) |
| | | const animatedData = ref([]) |
| | | const clearAnimationTimers = () => { |
| | | animationTimers.value.forEach((timer) => clearTimeout(timer)) |
| | | animationTimers.value = [] |
| | | } |
| | | const isMultipleData = computed(() => { |
| | | return ( |
| | | Array.isArray(props.data) && |
| | | props.data.length > 0 && |
| | | typeof props.data[0] === 'object' && |
| | | 'name' in props.data[0] |
| | | ) |
| | | }) |
| | | const maxValue = computed(() => { |
| | | if (isMultipleData.value) { |
| | | const multiData = props.data |
| | | return multiData.reduce((max, item) => { |
| | | if (item.data?.length) { |
| | | const itemMax = Math.max(...item.data) |
| | | return Math.max(max, itemMax) |
| | | } |
| | | return max |
| | | }, 0) |
| | | } else { |
| | | const singleData = props.data |
| | | return singleData?.length ? Math.max(...singleData) : 0 |
| | | } |
| | | }) |
| | | const initAnimationData = () => { |
| | | if (isMultipleData.value) { |
| | | const multiData = props.data |
| | | return multiData.map((item) => ({ |
| | | ...item, |
| | | data: Array(item.data.length).fill(0) |
| | | })) |
| | | } |
| | | const singleData = props.data |
| | | return Array(singleData.length).fill(0) |
| | | } |
| | | const copyRealData = () => { |
| | | if (isMultipleData.value) { |
| | | return props.data.map((item) => ({ ...item, data: [...item.data] })) |
| | | } |
| | | return [...props.data] |
| | | } |
| | | const primaryColor = computed(() => getCssVar('--el-color-primary')) |
| | | const getColor = (customColor, index) => { |
| | | if (customColor) return customColor |
| | | if (index !== void 0) return props.colors[index % props.colors.length] |
| | | return primaryColor.value |
| | | } |
| | | const generateAreaStyle = (item, color) => { |
| | | if (!item.areaStyle && !item.showAreaColor && !props.showAreaColor) return void 0 |
| | | const areaConfig = item.areaStyle || {} |
| | | if (areaConfig.custom) return areaConfig.custom |
| | | return { |
| | | color: new graphic.LinearGradient(0, 0, 0, 1, [ |
| | | { |
| | | offset: 0, |
| | | color: hexToRgba(color, areaConfig.startOpacity || 0.2).rgba |
| | | }, |
| | | { |
| | | offset: 1, |
| | | color: hexToRgba(color, areaConfig.endOpacity || 0.02).rgba |
| | | } |
| | | ]) |
| | | } |
| | | } |
| | | const generateSingleAreaStyle = () => { |
| | | if (!props.showAreaColor) return void 0 |
| | | const color = getColor(props.colors[0]) |
| | | return { |
| | | color: new graphic.LinearGradient(0, 0, 0, 1, [ |
| | | { |
| | | offset: 0, |
| | | color: hexToRgba(color, 0.2).rgba |
| | | }, |
| | | { |
| | | offset: 1, |
| | | color: hexToRgba(color, 0.02).rgba |
| | | } |
| | | ]) |
| | | } |
| | | } |
| | | const createSeriesItem = (config) => { |
| | | return { |
| | | name: config.name, |
| | | data: config.data, |
| | | type: 'line', |
| | | color: config.color, |
| | | smooth: config.smooth ?? props.smooth, |
| | | symbol: config.symbol ?? props.symbol, |
| | | symbolSize: config.symbolSize ?? props.symbolSize, |
| | | lineStyle: { |
| | | width: config.lineWidth ?? props.lineWidth, |
| | | color: config.color |
| | | }, |
| | | areaStyle: config.areaStyle, |
| | | emphasis: { |
| | | focus: 'series', |
| | | lineStyle: { |
| | | width: (config.lineWidth ?? props.lineWidth) + 1 |
| | | } |
| | | } |
| | | } |
| | | } |
| | | const generateChartOptions = (isInitial = false) => { |
| | | const options = { |
| | | animation: true, |
| | | animationDuration: isInitial ? 0 : 1300, |
| | | animationDurationUpdate: isInitial ? 0 : 1300, |
| | | grid: getGridWithLegend(props.showLegend && isMultipleData.value, props.legendPosition, { |
| | | top: 15, |
| | | right: 15, |
| | | left: 0 |
| | | }), |
| | | tooltip: props.showTooltip ? getTooltipStyle() : void 0, |
| | | xAxis: { |
| | | type: 'category', |
| | | boundaryGap: false, |
| | | data: props.xAxisData, |
| | | axisTick: getAxisTickStyle(), |
| | | axisLine: getAxisLineStyle(props.showAxisLine), |
| | | axisLabel: getAxisLabelStyle(props.showAxisLabel) |
| | | }, |
| | | yAxis: { |
| | | type: 'value', |
| | | min: 0, |
| | | max: maxValue.value, |
| | | axisLabel: getAxisLabelStyle(props.showAxisLabel), |
| | | axisLine: getAxisLineStyle(props.showAxisLine), |
| | | splitLine: getSplitLineStyle(props.showSplitLine) |
| | | } |
| | | } |
| | | if (props.showLegend && isMultipleData.value) { |
| | | options.legend = getLegendStyle(props.legendPosition) |
| | | } |
| | | if (isMultipleData.value) { |
| | | const multiData = animatedData.value |
| | | options.series = multiData.map((item, index) => { |
| | | const itemColor = getColor(props.colors[index], index) |
| | | const areaStyle = generateAreaStyle(item, itemColor) |
| | | return createSeriesItem({ |
| | | name: item.name, |
| | | data: item.data, |
| | | color: itemColor, |
| | | smooth: item.smooth, |
| | | symbol: item.symbol, |
| | | lineWidth: item.lineWidth, |
| | | areaStyle |
| | | }) |
| | | }) |
| | | } else { |
| | | const singleData = animatedData.value |
| | | const computedColor = getColor(props.colors[0]) |
| | | const areaStyle = generateSingleAreaStyle() |
| | | options.series = [ |
| | | createSeriesItem({ |
| | | data: singleData, |
| | | color: computedColor, |
| | | areaStyle |
| | | }) |
| | | ] |
| | | } |
| | | return options |
| | | } |
| | | const updateChartOptions = (options) => { |
| | | initChart(options) |
| | | } |
| | | const initChartWithAnimation = () => { |
| | | clearAnimationTimers() |
| | | isAnimating.value = true |
| | | animatedData.value = initAnimationData() |
| | | updateChartOptions(generateChartOptions(true)) |
| | | if (isMultipleData.value) { |
| | | const multiData = props.data |
| | | const currentAnimatedData = animatedData.value |
| | | multiData.forEach((item, index) => { |
| | | const timer = window.setTimeout( |
| | | () => { |
| | | currentAnimatedData[index] = { ...item, data: [...item.data] } |
| | | animatedData.value = [...currentAnimatedData] |
| | | updateChartOptions(generateChartOptions(false)) |
| | | }, |
| | | index * props.animationDelay + 100 |
| | | ) |
| | | animationTimers.value.push(timer) |
| | | }) |
| | | const totalDelay = (multiData.length - 1) * props.animationDelay + 1500 |
| | | const finishTimer = window.setTimeout(() => { |
| | | isAnimating.value = false |
| | | }, totalDelay) |
| | | animationTimers.value.push(finishTimer) |
| | | } else { |
| | | nextTick(() => { |
| | | animatedData.value = copyRealData() |
| | | updateChartOptions(generateChartOptions(false)) |
| | | isAnimating.value = false |
| | | }) |
| | | } |
| | | } |
| | | const checkIsEmpty = () => { |
| | | if (Array.isArray(props.data) && typeof props.data[0] === 'number') { |
| | | const singleData = props.data |
| | | return !singleData.length || singleData.every((val) => val === 0) |
| | | } |
| | | if (Array.isArray(props.data) && typeof props.data[0] === 'object') { |
| | | const multiData = props.data |
| | | return ( |
| | | !multiData.length || |
| | | multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0)) |
| | | ) |
| | | } |
| | | return true |
| | | } |
| | | const { |
| | | chartRef, |
| | | initChart, |
| | | getAxisLineStyle, |
| | | getAxisLabelStyle, |
| | | getAxisTickStyle, |
| | | getSplitLineStyle, |
| | | getTooltipStyle, |
| | | getLegendStyle, |
| | | getGridWithLegend, |
| | | isEmpty |
| | | } = useChartComponent({ |
| | | props, |
| | | checkEmpty: checkIsEmpty, |
| | | watchSources: [() => props.data, () => props.xAxisData, () => props.colors], |
| | | onVisible: () => { |
| | | if (!isEmpty.value) { |
| | | initChartWithAnimation() |
| | | } |
| | | }, |
| | | generateOptions: () => generateChartOptions(false) |
| | | }) |
| | | const renderChart = () => { |
| | | if (!isAnimating.value && !isEmpty.value) { |
| | | initChartWithAnimation() |
| | | } |
| | | } |
| | | watch([() => props.data, () => props.xAxisData, () => props.colors], renderChart, { deep: true }) |
| | | onMounted(() => { |
| | | renderChart() |
| | | }) |
| | | onBeforeUnmount(() => { |
| | | clearAnimationTimers() |
| | | }) |
| | | </script> |
| New file |
| | |
| | | <!-- 雷达图 --> |
| | | <template> |
| | | <div |
| | | ref="chartRef" |
| | | class="relative w-full" |
| | | :style="{ height: props.height }" |
| | | v-loading="props.loading" |
| | | ></div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useChartOps, useChartComponent } from '@/hooks/core/useChart' |
| | | defineOptions({ name: 'ArtRadarChart' }) |
| | | const props = defineProps({ |
| | | height: { required: false, default: useChartOps().chartHeight }, |
| | | loading: { required: false, default: false }, |
| | | isEmpty: { required: false, default: false }, |
| | | colors: { required: false, default: () => useChartOps().colors }, |
| | | indicator: { required: false, default: () => [] }, |
| | | data: { required: false, default: () => [] }, |
| | | showTooltip: { required: false, default: true }, |
| | | showLegend: { required: false, default: false }, |
| | | legendPosition: { required: false, default: 'bottom' } |
| | | }) |
| | | const { chartRef, isDark, getAnimationConfig, getTooltipStyle } = useChartComponent({ |
| | | props, |
| | | checkEmpty: () => { |
| | | return !props.data?.length || props.data.every((item) => item.value.every((val) => val === 0)) |
| | | }, |
| | | watchSources: [() => props.data, () => props.indicator, () => props.colors], |
| | | generateOptions: () => { |
| | | return { |
| | | tooltip: props.showTooltip ? getTooltipStyle('item') : void 0, |
| | | radar: { |
| | | indicator: props.indicator, |
| | | center: ['50%', '50%'], |
| | | radius: '70%', |
| | | axisName: { |
| | | color: isDark.value ? '#ccc' : '#666', |
| | | fontSize: 12 |
| | | }, |
| | | splitLine: { |
| | | lineStyle: { |
| | | color: isDark.value ? '#444' : '#e6e6e6' |
| | | } |
| | | }, |
| | | axisLine: { |
| | | lineStyle: { |
| | | color: isDark.value ? '#444' : '#e6e6e6' |
| | | } |
| | | }, |
| | | splitArea: { |
| | | show: true, |
| | | areaStyle: { |
| | | color: isDark.value |
| | | ? ['rgba(255, 255, 255, 0.02)', 'rgba(255, 255, 255, 0.05)'] |
| | | : ['rgba(0, 0, 0, 0.02)', 'rgba(0, 0, 0, 0.05)'] |
| | | } |
| | | } |
| | | }, |
| | | series: [ |
| | | { |
| | | type: 'radar', |
| | | data: props.data.map((item, index) => ({ |
| | | name: item.name, |
| | | value: item.value, |
| | | symbolSize: 4, |
| | | lineStyle: { |
| | | width: 2, |
| | | color: props.colors[index % props.colors.length] |
| | | }, |
| | | itemStyle: { |
| | | color: props.colors[index % props.colors.length] |
| | | }, |
| | | areaStyle: { |
| | | color: props.colors[index % props.colors.length], |
| | | opacity: 0.1 |
| | | }, |
| | | emphasis: { |
| | | areaStyle: { |
| | | opacity: 0.25 |
| | | }, |
| | | lineStyle: { |
| | | width: 3 |
| | | } |
| | | } |
| | | })), |
| | | ...getAnimationConfig(200, 1800) |
| | | } |
| | | ] |
| | | } |
| | | } |
| | | }) |
| | | </script> |
| New file |
| | |
| | | <!-- 环形图 --> |
| | | <template> |
| | | <div |
| | | ref="chartRef" |
| | | class="relative w-full" |
| | | :style="{ height: props.height }" |
| | | v-loading="props.loading" |
| | | > |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useChartOps, useChartComponent } from '@/hooks/core/useChart' |
| | | defineOptions({ name: 'ArtRingChart' }) |
| | | const props = defineProps({ |
| | | height: { required: false, default: useChartOps().chartHeight }, |
| | | loading: { required: false, default: false }, |
| | | isEmpty: { required: false, default: false }, |
| | | colors: { required: false, default: () => useChartOps().colors }, |
| | | data: { required: false, default: () => [] }, |
| | | radius: { required: false, default: () => ['50%', '80%'] }, |
| | | borderRadius: { required: false, default: 10 }, |
| | | centerText: { required: false, default: '' }, |
| | | showLabel: { required: false, default: false }, |
| | | showTooltip: { required: false, default: true }, |
| | | showLegend: { required: false, default: false }, |
| | | legendPosition: { required: false, default: 'right' } |
| | | }) |
| | | const { chartRef, isDark, getAnimationConfig, getTooltipStyle, getLegendStyle } = |
| | | useChartComponent({ |
| | | props, |
| | | checkEmpty: () => { |
| | | return !props.data?.length || props.data.every((item) => item.value === 0) |
| | | }, |
| | | watchSources: [() => props.data, () => props.centerText], |
| | | generateOptions: () => { |
| | | const getCenterPosition = () => { |
| | | if (!props.showLegend) return ['50%', '50%'] |
| | | switch (props.legendPosition) { |
| | | case 'left': |
| | | return ['60%', '50%'] |
| | | case 'right': |
| | | return ['40%', '50%'] |
| | | case 'top': |
| | | return ['50%', '60%'] |
| | | case 'bottom': |
| | | return ['50%', '40%'] |
| | | default: |
| | | return ['50%', '50%'] |
| | | } |
| | | } |
| | | const option = { |
| | | tooltip: props.showTooltip |
| | | ? getTooltipStyle('item', { |
| | | formatter: '{b}: {c} ({d}%)' |
| | | }) |
| | | : void 0, |
| | | legend: props.showLegend ? getLegendStyle(props.legendPosition) : void 0, |
| | | series: [ |
| | | { |
| | | name: '数据占比', |
| | | type: 'pie', |
| | | radius: props.radius, |
| | | center: getCenterPosition(), |
| | | avoidLabelOverlap: false, |
| | | itemStyle: { |
| | | borderRadius: props.borderRadius, |
| | | borderColor: isDark.value ? '#2c2c2c' : '#fff', |
| | | borderWidth: 0 |
| | | }, |
| | | label: { |
| | | show: props.showLabel, |
| | | formatter: '{b}\n{d}%', |
| | | position: 'outside', |
| | | color: isDark.value ? '#ccc' : '#999', |
| | | fontSize: 12 |
| | | }, |
| | | emphasis: { |
| | | label: { |
| | | show: false, |
| | | fontSize: 14, |
| | | fontWeight: 'bold' |
| | | } |
| | | }, |
| | | labelLine: { |
| | | show: props.showLabel, |
| | | length: 15, |
| | | length2: 25, |
| | | smooth: true |
| | | }, |
| | | data: props.data, |
| | | color: props.colors, |
| | | ...getAnimationConfig(), |
| | | animationType: 'expansion' |
| | | } |
| | | ] |
| | | } |
| | | if (props.centerText) { |
| | | const centerPos = getCenterPosition() |
| | | option.title = { |
| | | text: props.centerText, |
| | | left: centerPos[0], |
| | | top: centerPos[1], |
| | | textAlign: 'center', |
| | | textVerticalAlign: 'middle', |
| | | textStyle: { |
| | | fontSize: 18, |
| | | fontWeight: 500, |
| | | color: isDark.value ? '#999' : '#ADB0BC' |
| | | } |
| | | } |
| | | } |
| | | return option |
| | | } |
| | | }) |
| | | </script> |
| New file |
| | |
| | | <!-- 散点图 --> |
| | | <template> |
| | | <div |
| | | ref="chartRef" |
| | | class="relative w-full" |
| | | :style="{ height: props.height }" |
| | | v-loading="props.loading" |
| | | > |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { getCssVar } from '@/utils/ui' |
| | | import { useChartOps, useChartComponent } from '@/hooks/core/useChart' |
| | | defineOptions({ name: 'ArtScatterChart' }) |
| | | const props = defineProps({ |
| | | height: { required: false, default: useChartOps().chartHeight }, |
| | | loading: { required: false, default: false }, |
| | | isEmpty: { required: false, default: false }, |
| | | colors: { required: false, default: () => useChartOps().colors }, |
| | | data: { required: false, default: () => [{ value: [0, 0] }, { value: [0, 0] }] }, |
| | | symbolSize: { required: false, default: 14 }, |
| | | showAxisLabel: { required: false, default: true }, |
| | | showAxisLine: { required: false, default: true }, |
| | | showSplitLine: { required: false, default: true }, |
| | | showTooltip: { required: false, default: true }, |
| | | showLegend: { required: false, default: false }, |
| | | legendPosition: { required: false, default: 'bottom' } |
| | | }) |
| | | const { |
| | | chartRef, |
| | | isDark, |
| | | getAxisLineStyle, |
| | | getAxisLabelStyle, |
| | | getAxisTickStyle, |
| | | getSplitLineStyle, |
| | | getAnimationConfig, |
| | | getTooltipStyle |
| | | } = useChartComponent({ |
| | | props, |
| | | checkEmpty: () => { |
| | | return !props.data?.length || props.data.every((item) => item.value.every((val) => val === 0)) |
| | | }, |
| | | watchSources: [() => props.data, () => props.colors, () => props.symbolSize], |
| | | generateOptions: () => { |
| | | const computedColor = props.colors[0] || getCssVar('--el-color-primary') |
| | | return { |
| | | grid: { |
| | | top: 20, |
| | | right: 20, |
| | | bottom: 20, |
| | | left: 20, |
| | | containLabel: true |
| | | }, |
| | | tooltip: props.showTooltip |
| | | ? getTooltipStyle('item', { |
| | | formatter: (params) => { |
| | | const [x, y] = params.value |
| | | return `X: ${x}<br/>Y: ${y}` |
| | | } |
| | | }) |
| | | : void 0, |
| | | xAxis: { |
| | | type: 'value', |
| | | axisLabel: getAxisLabelStyle(props.showAxisLabel), |
| | | axisLine: getAxisLineStyle(props.showAxisLine), |
| | | axisTick: getAxisTickStyle(), |
| | | splitLine: getSplitLineStyle(props.showSplitLine) |
| | | }, |
| | | yAxis: { |
| | | type: 'value', |
| | | axisLabel: getAxisLabelStyle(props.showAxisLabel), |
| | | axisLine: getAxisLineStyle(props.showAxisLine), |
| | | axisTick: getAxisTickStyle(), |
| | | splitLine: getSplitLineStyle(props.showSplitLine) |
| | | }, |
| | | series: [ |
| | | { |
| | | type: 'scatter', |
| | | data: props.data, |
| | | symbolSize: props.symbolSize, |
| | | itemStyle: { |
| | | color: computedColor, |
| | | shadowBlur: 6, |
| | | shadowColor: isDark.value ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)', |
| | | shadowOffsetY: 2 |
| | | }, |
| | | emphasis: { |
| | | itemStyle: { |
| | | shadowBlur: 12, |
| | | shadowColor: isDark.value ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)' |
| | | }, |
| | | scale: true |
| | | }, |
| | | ...getAnimationConfig() |
| | | } |
| | | ] |
| | | } |
| | | } |
| | | }) |
| | | </script> |
| New file |
| | |
| | | <!-- 更多按钮 --> |
| | | <template> |
| | | <div> |
| | | <ElDropdown v-if="hasAnyAuthItem"> |
| | | <ArtIconButton icon="ri:more-2-fill" class="!size-8 bg-g-200 dark:bg-g-300/45 text-sm" /> |
| | | <template #dropdown> |
| | | <ElDropdownMenu> |
| | | <template v-for="item in list" :key="item.key"> |
| | | <ElDropdownItem |
| | | v-if="!item.auth || hasAuth(item.auth)" |
| | | :disabled="item.disabled" |
| | | @click="handleClick(item)" |
| | | > |
| | | <div class="flex-c gap-2" :style="{ color: item.color }"> |
| | | <ArtSvgIcon v-if="item.icon" :icon="item.icon" /> |
| | | <span>{{ item.label }}</span> |
| | | </div> |
| | | </ElDropdownItem> |
| | | </template> |
| | | </ElDropdownMenu> |
| | | </template> |
| | | </ElDropdown> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useAuth } from '@/hooks/core/useAuth' |
| | | defineOptions({ name: 'ArtButtonMore' }) |
| | | const { hasAuth } = useAuth() |
| | | const props = defineProps({ |
| | | list: { required: true }, |
| | | auth: { required: false } |
| | | }) |
| | | const hasAnyAuthItem = computed(() => { |
| | | return props.list.some((item) => !item.auth || hasAuth(item.auth)) |
| | | }) |
| | | const emit = defineEmits(['click']) |
| | | const handleClick = (item) => { |
| | | emit('click', item) |
| | | } |
| | | </script> |
| New file |
| | |
| | | <!-- 表格按钮 --> |
| | | <template> |
| | | <div |
| | | :class="[ |
| | | 'inline-flex items-center justify-center min-w-8 h-8 px-2.5 mr-2.5 text-sm c-p rounded-md align-middle', |
| | | buttonClass |
| | | ]" |
| | | :style="{ backgroundColor: buttonBgColor, color: iconColor }" |
| | | @click="handleClick" |
| | | > |
| | | <ArtSvgIcon :icon="iconContent" /> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | defineOptions({ name: 'ArtButtonTable' }) |
| | | const props = defineProps({ |
| | | type: { required: false }, |
| | | icon: { required: false }, |
| | | iconClass: { required: false }, |
| | | iconColor: { required: false }, |
| | | buttonBgColor: { required: false } |
| | | }) |
| | | const emit = defineEmits(['click']) |
| | | const defaultButtons = { |
| | | add: { icon: 'ri:add-fill', class: 'bg-theme/12 text-theme' }, |
| | | edit: { icon: 'ri:pencil-line', class: 'bg-secondary/12 text-secondary' }, |
| | | delete: { icon: 'ri:delete-bin-5-line', class: 'bg-error/12 text-error' }, |
| | | view: { icon: 'ri:eye-line', class: 'bg-info/12 text-info' }, |
| | | more: { icon: 'ri:more-2-fill', class: '' } |
| | | } |
| | | const iconContent = computed(() => { |
| | | return props.icon || (props.type ? defaultButtons[props.type]?.icon : '') || '' |
| | | }) |
| | | const buttonClass = computed(() => { |
| | | return props.iconClass || (props.type ? defaultButtons[props.type]?.class : '') || '' |
| | | }) |
| | | const handleClick = () => { |
| | | emit('click') |
| | | } |
| | | </script> |
| New file |
| | |
| | | <!-- 拖拽验证组件 --> |
| | | <template> |
| | | <div |
| | | ref="dragVerify" |
| | | class="drag_verify" |
| | | :style="dragVerifyStyle" |
| | | @mousemove="dragMoving" |
| | | @mouseup="dragFinish" |
| | | @mouseleave="dragFinish" |
| | | @touchmove="dragMoving" |
| | | @touchend="dragFinish" |
| | | > |
| | | <!-- 进度条 --> |
| | | <div |
| | | class="dv_progress_bar" |
| | | :class="{ goFirst2: isOk }" |
| | | ref="progressBar" |
| | | :style="progressBarStyle" |
| | | > |
| | | </div> |
| | | |
| | | <!-- 提示文本 --> |
| | | <div class="dv_text" :style="textStyle" ref="messageRef"> |
| | | <slot name="textBefore" v-if="$slots.textBefore"></slot> |
| | | {{ message }} |
| | | <slot name="textAfter" v-if="$slots.textAfter"></slot> |
| | | </div> |
| | | |
| | | <!-- 滑块处理器 --> |
| | | <div |
| | | class="dv_handler dv_handler_bg" |
| | | :class="{ goFirst: isOk }" |
| | | @mousedown="dragStart" |
| | | @touchstart="dragStart" |
| | | ref="handler" |
| | | :style="handlerStyle" |
| | | > |
| | | <ArtSvgIcon :icon="value ? successIcon : handlerIcon" class="text-g-600"></ArtSvgIcon> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | defineOptions({ name: 'ArtDragVerify' }) |
| | | const emit = defineEmits(['handlerMove', 'update:value', 'passCallback']) |
| | | const props = defineProps({ |
| | | value: { required: false, default: false }, |
| | | width: { required: false, default: '100%' }, |
| | | height: { required: false, default: 40 }, |
| | | text: { required: false, default: '按住滑块拖动' }, |
| | | successText: { required: false, default: 'success' }, |
| | | background: { required: false, default: '#eee' }, |
| | | progressBarBg: { required: false, default: '#1385FF' }, |
| | | completedBg: { required: false, default: '#57D187' }, |
| | | circle: { required: false, default: false }, |
| | | radius: { required: false, default: 'calc(var(--custom-radius) / 3 + 2px)' }, |
| | | handlerIcon: { required: false, default: 'solar:double-alt-arrow-right-linear' }, |
| | | successIcon: { required: false, default: 'ri:check-fill' }, |
| | | handlerBg: { required: false, default: '#fff' }, |
| | | textSize: { required: false, default: '13px' }, |
| | | textColor: { required: false, default: '#333' } |
| | | }) |
| | | const state = reactive({ |
| | | isMoving: false, |
| | | x: 0, |
| | | isOk: false |
| | | }) |
| | | const { isOk } = toRefs(state) |
| | | const dragVerify = ref() |
| | | const messageRef = ref() |
| | | const handler = ref() |
| | | const progressBar = ref() |
| | | let startX, startY, moveX, moveY |
| | | const onTouchStart = (e) => { |
| | | startX = e.targetTouches[0].pageX |
| | | startY = e.targetTouches[0].pageY |
| | | } |
| | | const onTouchMove = (e) => { |
| | | moveX = e.targetTouches[0].pageX |
| | | moveY = e.targetTouches[0].pageY |
| | | if (Math.abs(moveX - startX) > Math.abs(moveY - startY)) { |
| | | e.preventDefault() |
| | | } |
| | | } |
| | | document.addEventListener('touchstart', onTouchStart) |
| | | document.addEventListener('touchmove', onTouchMove, { passive: false }) |
| | | const getNumericWidth = () => { |
| | | if (typeof props.width === 'string') { |
| | | return dragVerify.value?.offsetWidth || 260 |
| | | } |
| | | return props.width |
| | | } |
| | | const getStyleWidth = () => { |
| | | if (typeof props.width === 'string') { |
| | | return props.width |
| | | } |
| | | return props.width + 'px' |
| | | } |
| | | onMounted(() => { |
| | | dragVerify.value?.style.setProperty('--textColor', props.textColor) |
| | | nextTick(() => { |
| | | const numericWidth = getNumericWidth() |
| | | dragVerify.value?.style.setProperty('--width', Math.floor(numericWidth / 2) + 'px') |
| | | dragVerify.value?.style.setProperty('--pwidth', -Math.floor(numericWidth / 2) + 'px') |
| | | }) |
| | | document.addEventListener('touchstart', onTouchStart) |
| | | document.addEventListener('touchmove', onTouchMove, { passive: false }) |
| | | }) |
| | | onBeforeUnmount(() => { |
| | | document.removeEventListener('touchstart', onTouchStart) |
| | | document.removeEventListener('touchmove', onTouchMove) |
| | | }) |
| | | const handlerStyle = { |
| | | left: '0', |
| | | width: props.height + 'px', |
| | | height: props.height + 'px', |
| | | background: props.handlerBg |
| | | } |
| | | const dragVerifyStyle = computed(() => ({ |
| | | width: getStyleWidth(), |
| | | height: props.height + 'px', |
| | | lineHeight: props.height + 'px', |
| | | background: props.background, |
| | | borderRadius: props.circle ? props.height / 2 + 'px' : props.radius |
| | | })) |
| | | const progressBarStyle = { |
| | | background: props.progressBarBg, |
| | | height: props.height + 'px', |
| | | borderRadius: props.circle |
| | | ? props.height / 2 + 'px 0 0 ' + props.height / 2 + 'px' |
| | | : props.radius |
| | | } |
| | | const textStyle = computed(() => ({ |
| | | fontSize: props.textSize |
| | | })) |
| | | const message = computed(() => { |
| | | return props.value ? props.successText : props.text |
| | | }) |
| | | const dragStart = (e) => { |
| | | if (!props.value) { |
| | | state.isMoving = true |
| | | handler.value.style.transition = 'none' |
| | | state.x = |
| | | (e.pageX || e.touches[0].pageX) - parseInt(handler.value.style.left.replace('px', ''), 10) |
| | | } |
| | | emit('handlerMove') |
| | | } |
| | | const dragMoving = (e) => { |
| | | if (state.isMoving && !props.value) { |
| | | const numericWidth = getNumericWidth() |
| | | let _x = (e.pageX || e.touches[0].pageX) - state.x |
| | | if (_x > 0 && _x <= numericWidth - props.height) { |
| | | handler.value.style.left = _x + 'px' |
| | | progressBar.value.style.width = _x + props.height / 2 + 'px' |
| | | } else if (_x > numericWidth - props.height) { |
| | | handler.value.style.left = numericWidth - props.height + 'px' |
| | | progressBar.value.style.width = numericWidth - props.height / 2 + 'px' |
| | | passVerify() |
| | | } |
| | | } |
| | | } |
| | | const dragFinish = (e) => { |
| | | if (state.isMoving && !props.value) { |
| | | const numericWidth = getNumericWidth() |
| | | let _x = (e.pageX || e.changedTouches[0].pageX) - state.x |
| | | if (_x < numericWidth - props.height) { |
| | | state.isOk = true |
| | | handler.value.style.left = '0' |
| | | handler.value.style.transition = 'all 0.2s' |
| | | progressBar.value.style.width = '0' |
| | | state.isOk = false |
| | | } else { |
| | | handler.value.style.transition = 'none' |
| | | handler.value.style.left = numericWidth - props.height + 'px' |
| | | progressBar.value.style.width = numericWidth - props.height / 2 + 'px' |
| | | passVerify() |
| | | } |
| | | state.isMoving = false |
| | | } |
| | | } |
| | | const passVerify = () => { |
| | | emit('update:value', true) |
| | | state.isMoving = false |
| | | progressBar.value.style.background = props.completedBg |
| | | messageRef.value.style['-webkit-text-fill-color'] = 'unset' |
| | | messageRef.value.style.animation = 'slidetounlock2 2s cubic-bezier(0, 0.2, 1, 1) infinite' |
| | | messageRef.value.style.color = '#fff' |
| | | emit('passCallback') |
| | | } |
| | | const reset = () => { |
| | | handler.value.style.left = '0' |
| | | progressBar.value.style.width = '0' |
| | | progressBar.value.style.background = props.progressBarBg |
| | | messageRef.value.style['-webkit-text-fill-color'] = 'transparent' |
| | | messageRef.value.style.animation = 'slidetounlock 2s cubic-bezier(0, 0.2, 1, 1) infinite' |
| | | messageRef.value.style.color = props.background |
| | | emit('update:value', false) |
| | | state.isOk = false |
| | | state.isMoving = false |
| | | state.x = 0 |
| | | } |
| | | defineExpose({ |
| | | reset |
| | | }) |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | .drag_verify { |
| | | position: relative; |
| | | box-sizing: border-box; |
| | | overflow: hidden; |
| | | text-align: center; |
| | | border: 1px solid var(--default-border-dashed); |
| | | |
| | | .dv_handler { |
| | | position: absolute; |
| | | top: 0; |
| | | left: 0; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | cursor: move; |
| | | |
| | | i { |
| | | padding-left: 0; |
| | | font-size: 14px; |
| | | color: #999; |
| | | } |
| | | |
| | | .el-icon-circle-check { |
| | | margin-top: 9px; |
| | | color: #6c6; |
| | | } |
| | | } |
| | | |
| | | .dv_progress_bar { |
| | | position: absolute; |
| | | width: 0; |
| | | height: 34px; |
| | | } |
| | | |
| | | .dv_text { |
| | | position: absolute; |
| | | inset: 0; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | color: transparent; |
| | | user-select: none; |
| | | background: linear-gradient( |
| | | to right, |
| | | var(--textColor) 0%, |
| | | var(--textColor) 40%, |
| | | #fff 50%, |
| | | var(--textColor) 60%, |
| | | var(--textColor) 100% |
| | | ); |
| | | -webkit-background-clip: text; |
| | | background-clip: text; |
| | | animation: slidetounlock 2s cubic-bezier(0, 0.2, 1, 1) infinite; |
| | | -webkit-text-fill-color: transparent; |
| | | text-size-adjust: none; |
| | | |
| | | * { |
| | | -webkit-text-fill-color: var(--textColor); |
| | | } |
| | | } |
| | | } |
| | | |
| | | .goFirst { |
| | | left: 0 !important; |
| | | transition: left 0.5s; |
| | | } |
| | | |
| | | .goFirst2 { |
| | | width: 0 !important; |
| | | transition: width 0.5s; |
| | | } |
| | | </style> |
| | | |
| | | <style lang="scss"> |
| | | @keyframes slidetounlock { |
| | | 0% { |
| | | background-position: var(--pwidth) 0; |
| | | } |
| | | |
| | | 100% { |
| | | background-position: var(--width) 0; |
| | | } |
| | | } |
| | | |
| | | @keyframes slidetounlock2 { |
| | | 0% { |
| | | background-position: var(--pwidth) 0; |
| | | } |
| | | |
| | | 100% { |
| | | background-position: var(--pwidth) 0; |
| | | } |
| | | } |
| | | </style> |
| New file |
| | |
| | | <!-- 导出 Excel 文件 --> |
| | | <template> |
| | | <ElButton |
| | | :type="type" |
| | | :size="size" |
| | | :loading="isExporting" |
| | | :disabled="disabled || !hasData" |
| | | v-ripple |
| | | @click="handleExport" |
| | | > |
| | | <template #loading> |
| | | <ElIcon class="is-loading"> |
| | | <Loading /> |
| | | </ElIcon> |
| | | {{ loadingText }} |
| | | </template> |
| | | <slot>{{ buttonText }}</slot> |
| | | </ElButton> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import * as XLSX from 'xlsx' |
| | | import FileSaver from 'file-saver' |
| | | import { ref, computed, nextTick } from 'vue' |
| | | import { Loading } from '@element-plus/icons-vue' |
| | | import { useThrottleFn } from '@vueuse/core' |
| | | defineOptions({ name: 'ArtExcelExport' }) |
| | | const props = defineProps({ |
| | | filename: { |
| | | required: false, |
| | | default: () => `export_${/* @__PURE__ */ new Date().toISOString().slice(0, 10)}` |
| | | }, |
| | | sheetName: { required: false, default: 'Sheet1' }, |
| | | type: { required: false, default: 'primary' }, |
| | | size: { required: false, default: 'default' }, |
| | | disabled: { required: false, default: false }, |
| | | buttonText: { required: false, default: '导出 Excel' }, |
| | | loadingText: { required: false, default: '导出中...' }, |
| | | autoIndex: { required: false, default: false }, |
| | | indexColumnTitle: { required: false, default: '序号' }, |
| | | columns: { required: false, default: () => ({}) }, |
| | | headers: { required: false, default: () => ({}) }, |
| | | maxRows: { required: false, default: 1e5 }, |
| | | showSuccessMessage: { required: false, default: true }, |
| | | showErrorMessage: { required: false, default: true }, |
| | | workbookOptions: { required: false, default: () => ({}) } |
| | | }) |
| | | const emit = defineEmits(['before-export', 'export-success', 'export-error', 'export-progress']) |
| | | class ExportError extends Error { |
| | | constructor(message, code, details) { |
| | | super(message) |
| | | this.code = code |
| | | this.details = details |
| | | this.name = 'ExportError' |
| | | } |
| | | } |
| | | const isExporting = ref(false) |
| | | const hasData = computed(() => Array.isArray(props.data) && props.data.length > 0) |
| | | const validateData = (data) => { |
| | | if (!Array.isArray(data)) { |
| | | throw new ExportError('数据必须是数组格式', 'INVALID_DATA_TYPE') |
| | | } |
| | | if (data.length === 0) { |
| | | throw new ExportError('没有可导出的数据', 'NO_DATA') |
| | | } |
| | | if (data.length > props.maxRows) { |
| | | throw new ExportError(`数据行数超过限制(${props.maxRows}行)`, 'EXCEED_MAX_ROWS', { |
| | | currentRows: data.length, |
| | | maxRows: props.maxRows |
| | | }) |
| | | } |
| | | } |
| | | const formatCellValue = (value, key, row, index) => { |
| | | const column = props.columns[key] |
| | | if (column?.formatter) { |
| | | return column.formatter(value, row, index) |
| | | } |
| | | if (value === null || value === void 0) { |
| | | return '' |
| | | } |
| | | if (value instanceof Date) { |
| | | return value.toLocaleDateString('zh-CN') |
| | | } |
| | | if (typeof value === 'boolean') { |
| | | return value ? '是' : '否' |
| | | } |
| | | return String(value) |
| | | } |
| | | const processData = (data) => { |
| | | const processedData = data.map((item, index) => { |
| | | const processedItem = {} |
| | | if (props.autoIndex) { |
| | | processedItem[props.indexColumnTitle] = String(index + 1) |
| | | } |
| | | Object.entries(item).forEach(([key, value]) => { |
| | | let columnTitle = key |
| | | if (props.columns[key]?.title) { |
| | | columnTitle = props.columns[key].title |
| | | } else if (props.headers[key]) { |
| | | columnTitle = props.headers[key] |
| | | } |
| | | processedItem[columnTitle] = formatCellValue(value, key, item, index) |
| | | }) |
| | | return processedItem |
| | | }) |
| | | return processedData |
| | | } |
| | | const calculateColumnWidths = (data) => { |
| | | if (data.length === 0) return [] |
| | | const sampleSize = Math.min(data.length, 100) |
| | | const columns = Object.keys(data[0]) |
| | | return columns.map((column) => { |
| | | const configWidth = Object.values(props.columns).find((col) => col.title === column)?.width |
| | | if (configWidth) { |
| | | return { wch: configWidth } |
| | | } |
| | | const maxLength = Math.max( |
| | | column.length, |
| | | ...data.slice(0, sampleSize).map((row) => String(row[column] || '').length) |
| | | ) |
| | | const width = Math.min(Math.max(maxLength + 2, 8), 50) |
| | | return { wch: width } |
| | | }) |
| | | } |
| | | const exportToExcel = async (data, filename, sheetName) => { |
| | | try { |
| | | emit('export-progress', 10) |
| | | const processedData = processData(data) |
| | | emit('export-progress', 30) |
| | | const workbook = XLSX.utils.book_new() |
| | | if (props.workbookOptions) { |
| | | workbook.Props = { |
| | | Title: filename, |
| | | Subject: '数据导出', |
| | | Author: props.workbookOptions.creator || 'Art Design Pro', |
| | | Manager: props.workbookOptions.lastModifiedBy || '', |
| | | Company: '系统导出', |
| | | Category: '数据', |
| | | Keywords: 'excel,export,data', |
| | | Comments: '由系统自动生成', |
| | | CreatedDate: props.workbookOptions.created || /* @__PURE__ */ new Date(), |
| | | ModifiedDate: props.workbookOptions.modified || /* @__PURE__ */ new Date() |
| | | } |
| | | } |
| | | emit('export-progress', 50) |
| | | const worksheet = XLSX.utils.json_to_sheet(processedData) |
| | | worksheet['!cols'] = calculateColumnWidths(processedData) |
| | | emit('export-progress', 70) |
| | | XLSX.utils.book_append_sheet(workbook, worksheet, sheetName) |
| | | emit('export-progress', 85) |
| | | const excelBuffer = XLSX.write(workbook, { |
| | | bookType: 'xlsx', |
| | | type: 'array', |
| | | compression: true |
| | | }) |
| | | const blob = new Blob([excelBuffer], { |
| | | type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' |
| | | }) |
| | | emit('export-progress', 95) |
| | | const timestamp = /* @__PURE__ */ new Date().toISOString().replace(/[:.]/g, '-') |
| | | const finalFilename = `${filename}_${timestamp}.xlsx` |
| | | FileSaver.saveAs(blob, finalFilename) |
| | | emit('export-progress', 100) |
| | | await nextTick() |
| | | return Promise.resolve() |
| | | } catch (error) { |
| | | throw new ExportError(`Excel 导出失败: ${error.message}`, 'EXPORT_FAILED', error) |
| | | } |
| | | } |
| | | const handleExport = useThrottleFn(async () => { |
| | | if (isExporting.value) return |
| | | isExporting.value = true |
| | | try { |
| | | validateData(props.data) |
| | | emit('before-export', props.data) |
| | | await exportToExcel(props.data, props.filename, props.sheetName) |
| | | emit('export-success', props.filename, props.data.length) |
| | | if (props.showSuccessMessage) { |
| | | ElMessage.success({ |
| | | message: `成功导出 ${props.data.length} 条数据`, |
| | | duration: 3e3 |
| | | }) |
| | | } |
| | | } catch (error) { |
| | | const exportError = |
| | | error instanceof ExportError |
| | | ? error |
| | | : new ExportError(`导出失败: ${error.message}`, 'UNKNOWN_ERROR', error) |
| | | emit('export-error', exportError) |
| | | if (props.showErrorMessage) { |
| | | ElMessage.error({ |
| | | message: exportError.message, |
| | | duration: 5e3 |
| | | }) |
| | | } |
| | | console.error('Excel 导出错误:', exportError) |
| | | } finally { |
| | | isExporting.value = false |
| | | emit('export-progress', 0) |
| | | } |
| | | }, 1e3) |
| | | defineExpose({ |
| | | exportData: handleExport, |
| | | isExporting: readonly(isExporting), |
| | | hasData |
| | | }) |
| | | </script> |
| | | |
| | | <style scoped> |
| | | .is-loading { |
| | | animation: rotating 2s linear infinite; |
| | | } |
| | | |
| | | @keyframes rotating { |
| | | 0% { |
| | | transform: rotate(0deg); |
| | | } |
| | | |
| | | 100% { |
| | | transform: rotate(360deg); |
| | | } |
| | | } |
| | | </style> |
| New file |
| | |
| | | <!-- 导入 Excel 文件 --> |
| | | <template> |
| | | <div class="inline-block"> |
| | | <ElUpload |
| | | :auto-upload="false" |
| | | accept=".xlsx, .xls" |
| | | :show-file-list="false" |
| | | @change="handleFileChange" |
| | | > |
| | | <ElButton type="primary" v-ripple> |
| | | <slot>导入 Excel</slot> |
| | | </ElButton> |
| | | </ElUpload> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import * as XLSX from 'xlsx' |
| | | defineOptions({ name: 'ArtExcelImport' }) |
| | | async function importExcel(file) { |
| | | return new Promise((resolve, reject) => { |
| | | const reader = new FileReader() |
| | | reader.onload = (e) => { |
| | | try { |
| | | const data = e.target?.result |
| | | const workbook = XLSX.read(data, { type: 'array' }) |
| | | const firstSheetName = workbook.SheetNames[0] |
| | | const worksheet = workbook.Sheets[firstSheetName] |
| | | const results = XLSX.utils.sheet_to_json(worksheet) |
| | | resolve(results) |
| | | } catch (error) { |
| | | reject(error) |
| | | } |
| | | } |
| | | reader.onerror = (error) => reject(error) |
| | | reader.readAsArrayBuffer(file) |
| | | }) |
| | | } |
| | | const emit = defineEmits(['import-success', 'import-error']) |
| | | const handleFileChange = async (uploadFile) => { |
| | | try { |
| | | if (!uploadFile.raw) return |
| | | const results = await importExcel(uploadFile.raw) |
| | | emit('import-success', results) |
| | | } catch (error) { |
| | | emit('import-error', error) |
| | | } |
| | | } |
| | | </script> |
| New file |
| | |
| | | <!-- 表单组件 --> |
| | | <!-- 支持常用表单组件、自定义组件、插槽、校验、隐藏表单项 --> |
| | | <!-- 写法同 ElementPlus 官方文档组件,把属性写在 props 里面就可以了 --> |
| | | <template> |
| | | <section class="px-4 pb-0 pt-4 md:px-4 md:pt-4"> |
| | | <ElForm |
| | | ref="formRef" |
| | | :model="modelValue" |
| | | :label-position="labelPosition" |
| | | v-bind="{ ...$attrs }" |
| | | > |
| | | <ElRow class="flex flex-wrap" :gutter="gutter"> |
| | | <ElCol |
| | | v-for="item in visibleFormItems" |
| | | :key="item.key" |
| | | :xs="getColSpan(item.span, 'xs')" |
| | | :sm="getColSpan(item.span, 'sm')" |
| | | :md="getColSpan(item.span, 'md')" |
| | | :lg="getColSpan(item.span, 'lg')" |
| | | :xl="getColSpan(item.span, 'xl')" |
| | | > |
| | | <ElFormItem |
| | | :prop="item.key" |
| | | :label-width="item.label ? item.labelWidth || labelWidth : undefined" |
| | | > |
| | | <template #label v-if="item.label"> |
| | | <component v-if="typeof item.label !== 'string'" :is="item.label" /> |
| | | <span v-else>{{ item.label }}</span> |
| | | </template> |
| | | <slot :name="item.key" :item="item" :modelValue="modelValue"> |
| | | <component |
| | | :is="getComponent(item)" |
| | | :model-value="getFieldValue(item.key)" |
| | | @update:model-value="setFieldValue(item.key, $event)" |
| | | v-bind="getProps(item)" |
| | | > |
| | | <!-- 下拉选择 --> |
| | | <template v-if="item.type === 'select' && getProps(item)?.options"> |
| | | <ElOption |
| | | v-for="option in getProps(item).options" |
| | | v-bind="option" |
| | | :key="option.value" |
| | | /> |
| | | </template> |
| | | |
| | | <!-- 复选框组 --> |
| | | <template v-if="item.type === 'checkboxgroup' && getProps(item)?.options"> |
| | | <ElCheckbox |
| | | v-for="option in getProps(item).options" |
| | | v-bind="option" |
| | | :key="option.value" |
| | | /> |
| | | </template> |
| | | |
| | | <!-- 单选框组 --> |
| | | <template v-if="item.type === 'radiogroup' && getProps(item)?.options"> |
| | | <ElRadio |
| | | v-for="option in getProps(item).options" |
| | | v-bind="option" |
| | | :key="option.value" |
| | | /> |
| | | </template> |
| | | |
| | | <!-- 动态插槽支持 --> |
| | | <template v-for="(slotFn, slotName) in getSlots(item)" :key="slotName" #[slotName]> |
| | | <component :is="slotFn" /> |
| | | </template> |
| | | </component> |
| | | </slot> |
| | | </ElFormItem> |
| | | </ElCol> |
| | | <ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="max-w-full flex-1"> |
| | | <div |
| | | class="mb-3 flex-c flex-wrap justify-end md:flex-row md:items-stretch md:gap-2" |
| | | :style="actionButtonsStyle" |
| | | > |
| | | <div class="flex gap-2 md:justify-center"> |
| | | <ElButton v-if="showReset" class="reset-button" @click="handleReset" v-ripple> |
| | | {{ t('table.form.reset') }} |
| | | </ElButton> |
| | | <ElButton |
| | | v-if="showSubmit" |
| | | type="primary" |
| | | class="submit-button" |
| | | @click="handleSubmit" |
| | | v-ripple |
| | | :disabled="disabledSubmit" |
| | | > |
| | | {{ t('table.form.submit') }} |
| | | </ElButton> |
| | | </div> |
| | | </div> |
| | | </ElCol> |
| | | </ElRow> |
| | | </ElForm> |
| | | </section> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useWindowSize } from '@vueuse/core' |
| | | import { useI18n } from 'vue-i18n' |
| | | import { toRaw } from 'vue' |
| | | import { |
| | | ElCascader, |
| | | ElCheckbox, |
| | | ElCheckboxGroup, |
| | | ElDatePicker, |
| | | ElInput, |
| | | ElInputTag, |
| | | ElInputNumber, |
| | | ElRadioGroup, |
| | | ElRate, |
| | | ElSelect, |
| | | ElSlider, |
| | | ElSwitch, |
| | | ElTimePicker, |
| | | ElTimeSelect, |
| | | ElTreeSelect |
| | | } from 'element-plus' |
| | | import { calculateResponsiveSpan } from '@/utils/form/responsive' |
| | | defineOptions({ name: 'ArtForm' }) |
| | | const componentMap = { |
| | | input: ElInput, |
| | | // 输入框 |
| | | inputtag: ElInputTag, |
| | | // 标签输入框 |
| | | number: ElInputNumber, |
| | | // 数字输入框 |
| | | select: ElSelect, |
| | | // 选择器 |
| | | switch: ElSwitch, |
| | | // 开关 |
| | | checkbox: ElCheckbox, |
| | | // 复选框 |
| | | checkboxgroup: ElCheckboxGroup, |
| | | // 复选框组 |
| | | radiogroup: ElRadioGroup, |
| | | // 单选框组 |
| | | date: ElDatePicker, |
| | | // 日期选择器 |
| | | daterange: ElDatePicker, |
| | | // 日期范围选择器 |
| | | datetime: ElDatePicker, |
| | | // 日期时间选择器 |
| | | datetimerange: ElDatePicker, |
| | | // 日期时间范围选择器 |
| | | rate: ElRate, |
| | | // 评分 |
| | | slider: ElSlider, |
| | | // 滑块 |
| | | cascader: ElCascader, |
| | | // 级联选择器 |
| | | timepicker: ElTimePicker, |
| | | // 时间选择器 |
| | | timeselect: ElTimeSelect, |
| | | // 时间选择 |
| | | treeselect: ElTreeSelect |
| | | // 树选择器 |
| | | } |
| | | const { width } = useWindowSize() |
| | | const { t } = useI18n() |
| | | const isMobile = computed(() => width.value < 500) |
| | | const formInstance = useTemplateRef('formRef') |
| | | const props = defineProps({ |
| | | items: { required: false, default: () => [] }, |
| | | span: { required: false, default: 6 }, |
| | | gutter: { required: false, default: 12 }, |
| | | labelPosition: { required: false, default: 'right' }, |
| | | labelWidth: { required: false, default: '70px' }, |
| | | buttonLeftLimit: { required: false, default: 2 }, |
| | | showReset: { required: false, default: true }, |
| | | showSubmit: { required: false, default: true }, |
| | | disabledSubmit: { required: false, default: false }, |
| | | sanitizeOutput: { required: false, default: () => ({}) } |
| | | }) |
| | | const emit = defineEmits(['reset', 'submit']) |
| | | const modelValue = defineModel({ default: {} }) |
| | | const initialModelValue = ref({}) |
| | | const cloneModelValue = (value) => { |
| | | if (!value) return {} |
| | | const deepClone = (source) => { |
| | | if (Array.isArray(source)) { |
| | | return source.map((item) => deepClone(item)) |
| | | } |
| | | if (source && typeof source === 'object') { |
| | | const rawSource = toRaw(source) |
| | | return Object.keys(rawSource).reduce((accumulator, key) => { |
| | | accumulator[key] = deepClone(rawSource[key]) |
| | | return accumulator |
| | | }, {}) |
| | | } |
| | | return source |
| | | } |
| | | return deepClone(toRaw(value)) |
| | | } |
| | | initialModelValue.value = cloneModelValue(modelValue.value) |
| | | const rootProps = ['label', 'labelWidth', 'key', 'type', 'hidden', 'span', 'slots'] |
| | | const sanitizeOutputOptions = computed(() => ({ |
| | | removeEmptyString: true, |
| | | removeEmptyArray: true, |
| | | removeEmptyObject: true, |
| | | removeEmptyRichText: true, |
| | | keepZero: true, |
| | | keepFalse: true, |
| | | ...props.sanitizeOutput |
| | | })) |
| | | const PATH_NUMBER_RE = /^\d+$/ |
| | | const parsePath = (path) => { |
| | | return path |
| | | .split('.') |
| | | .filter(Boolean) |
| | | .map((segment) => (PATH_NUMBER_RE.test(segment) ? Number(segment) : segment)) |
| | | } |
| | | const getFieldValue = (path) => { |
| | | return parsePath(path).reduce((currentValue, segment) => { |
| | | if (currentValue == null) return void 0 |
| | | return currentValue[segment] |
| | | }, modelValue.value) |
| | | } |
| | | const deleteFieldValue = (path) => { |
| | | const segments = parsePath(path) |
| | | if (!segments.length) return |
| | | const lastSegment = segments.pop() |
| | | const parent = segments.reduce((currentValue, segment) => { |
| | | if (currentValue == null) return void 0 |
| | | return currentValue[segment] |
| | | }, modelValue.value) |
| | | if (parent != null && lastSegment !== void 0) { |
| | | delete parent[lastSegment] |
| | | } |
| | | } |
| | | const setFieldValue = (path, value) => { |
| | | const normalizedValue = value === '' ? void 0 : value |
| | | const segments = parsePath(path) |
| | | if (!segments.length) return |
| | | if (normalizedValue === void 0) { |
| | | deleteFieldValue(path) |
| | | return |
| | | } |
| | | let currentValue = modelValue.value |
| | | segments.forEach((segment, index) => { |
| | | const isLast = index === segments.length - 1 |
| | | if (isLast) { |
| | | currentValue[segment] = normalizedValue |
| | | return |
| | | } |
| | | const nextSegment = segments[index + 1] |
| | | const nextContainer = typeof nextSegment === 'number' ? [] : {} |
| | | if ( |
| | | currentValue[segment] === null || |
| | | currentValue[segment] === void 0 || |
| | | typeof currentValue[segment] !== 'object' |
| | | ) { |
| | | currentValue[segment] = nextContainer |
| | | } |
| | | currentValue = currentValue[segment] |
| | | }) |
| | | } |
| | | const isRichTextEmpty = (value) => { |
| | | if (/<(img|video|audio|iframe|embed|object)\b/i.test(value)) { |
| | | return false |
| | | } |
| | | return ( |
| | | value |
| | | .replace(/ /gi, '') |
| | | .replace(/<br\s*\/?>/gi, '') |
| | | .replace(/<[^>]*>/g, '') |
| | | .trim() === '' |
| | | ) |
| | | } |
| | | const sanitizeOutputValue = (value) => { |
| | | const options = sanitizeOutputOptions.value |
| | | if (Array.isArray(value)) { |
| | | const sanitizedArray = value |
| | | .map((item) => sanitizeOutputValue(item)) |
| | | .filter((item) => item !== void 0) |
| | | return sanitizedArray.length === 0 && options.removeEmptyArray ? void 0 : sanitizedArray |
| | | } |
| | | if (value && typeof value === 'object') { |
| | | const rawValue = toRaw(value) |
| | | const sanitizedObject = Object.entries(rawValue).reduce((accumulator, [key, item]) => { |
| | | const sanitizedItem = sanitizeOutputValue(item) |
| | | if (sanitizedItem !== void 0) { |
| | | accumulator[key] = sanitizedItem |
| | | } |
| | | return accumulator |
| | | }, {}) |
| | | return Object.keys(sanitizedObject).length === 0 && options.removeEmptyObject |
| | | ? void 0 |
| | | : sanitizedObject |
| | | } |
| | | if (typeof value === 'string') { |
| | | if (options.removeEmptyString && value.trim() === '') { |
| | | return void 0 |
| | | } |
| | | if (options.removeEmptyRichText && isRichTextEmpty(value)) { |
| | | return void 0 |
| | | } |
| | | return value |
| | | } |
| | | if (value === 0) { |
| | | return options.keepZero ? value : void 0 |
| | | } |
| | | if (value === false) { |
| | | return options.keepFalse ? value : void 0 |
| | | } |
| | | return value ?? void 0 |
| | | } |
| | | const getSanitizedOutput = () => { |
| | | return sanitizeOutputValue(cloneModelValue(modelValue.value)) || {} |
| | | } |
| | | const getProps = (item) => { |
| | | if (item.props) return item.props |
| | | const props2 = { ...item } |
| | | rootProps.forEach((key) => delete props2[key]) |
| | | return props2 |
| | | } |
| | | const getSlots = (item) => { |
| | | if (!item.slots) return {} |
| | | const validSlots = {} |
| | | Object.entries(item.slots).forEach(([key, slotFn]) => { |
| | | if (slotFn) { |
| | | validSlots[key] = slotFn |
| | | } |
| | | }) |
| | | return validSlots |
| | | } |
| | | const getComponent = (item) => { |
| | | if (item.render) { |
| | | return item.render |
| | | } |
| | | const { type } = item |
| | | return componentMap[type] || componentMap['input'] |
| | | } |
| | | const getColSpan = (itemSpan, breakpoint) => { |
| | | return calculateResponsiveSpan(itemSpan, span.value, breakpoint) |
| | | } |
| | | const visibleFormItems = computed(() => { |
| | | return props.items.filter((item) => !item.hidden) |
| | | }) |
| | | const actionButtonsStyle = computed(() => ({ |
| | | 'justify-content': isMobile.value |
| | | ? 'flex-end' |
| | | : props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit |
| | | ? 'flex-start' |
| | | : 'flex-end' |
| | | })) |
| | | const handleReset = () => { |
| | | formInstance.value?.resetFields() |
| | | Object.keys(modelValue.value).forEach((key) => { |
| | | delete modelValue.value[key] |
| | | }) |
| | | Object.assign(modelValue.value, cloneModelValue(initialModelValue.value)) |
| | | emit('reset') |
| | | } |
| | | const handleSubmit = () => { |
| | | emit('submit', getSanitizedOutput()) |
| | | } |
| | | defineExpose({ |
| | | ref: formInstance, |
| | | validate: (...args) => formInstance.value?.validate(...args), |
| | | reset: handleReset, |
| | | // 允许外部在不触发提交事件时主动获取清洗后的输出。 |
| | | getOutput: getSanitizedOutput |
| | | }) |
| | | const { span, gutter, labelPosition, labelWidth } = toRefs(props) |
| | | </script> |
| New file |
| | |
| | | <!-- 表格搜索组件 --> |
| | | <!-- 支持常用表单组件、自定义组件、插槽、校验、隐藏表单项 --> |
| | | <!-- 写法同 ElementPlus 官方文档组件,把属性写在 props 里面就可以了 --> |
| | | <template> |
| | | <section class="art-search-bar art-card-xs" :class="{ 'is-expanded': isExpanded }"> |
| | | <ElForm |
| | | ref="formRef" |
| | | :model="modelValue" |
| | | :label-position="labelPosition" |
| | | v-bind="{ ...$attrs }" |
| | | > |
| | | <ElRow :gutter="gutter"> |
| | | <ElCol |
| | | v-for="item in visibleFormItems" |
| | | :key="item.key" |
| | | :xs="getColSpan(item.span, 'xs')" |
| | | :sm="getColSpan(item.span, 'sm')" |
| | | :md="getColSpan(item.span, 'md')" |
| | | :lg="getColSpan(item.span, 'lg')" |
| | | :xl="getColSpan(item.span, 'xl')" |
| | | > |
| | | <ElFormItem |
| | | :prop="item.key" |
| | | :label-width="item.label ? item.labelWidth || labelWidth : undefined" |
| | | > |
| | | <template #label v-if="item.label"> |
| | | <component v-if="typeof item.label !== 'string'" :is="item.label" /> |
| | | <span v-else>{{ item.label }}</span> |
| | | </template> |
| | | <slot :name="item.key" :item="item" :modelValue="modelValue"> |
| | | <component |
| | | :is="getComponent(item)" |
| | | :model-value="getFieldValue(item.key)" |
| | | @update:model-value="setFieldValue(item.key, $event)" |
| | | v-bind="getProps(item)" |
| | | > |
| | | <!-- 下拉选择 --> |
| | | <template v-if="item.type === 'select' && getProps(item)?.options"> |
| | | <ElOption |
| | | v-for="option in getProps(item).options" |
| | | v-bind="option" |
| | | :key="option.value" |
| | | /> |
| | | </template> |
| | | |
| | | <!-- 复选框组 --> |
| | | <template v-if="item.type === 'checkboxgroup' && getProps(item)?.options"> |
| | | <ElCheckbox |
| | | v-for="option in getProps(item).options" |
| | | v-bind="option" |
| | | :key="option.value" |
| | | /> |
| | | </template> |
| | | |
| | | <!-- 单选框组 --> |
| | | <template v-if="item.type === 'radiogroup' && getProps(item)?.options"> |
| | | <ElRadio |
| | | v-for="option in getProps(item).options" |
| | | v-bind="option" |
| | | :key="option.value" |
| | | /> |
| | | </template> |
| | | |
| | | <!-- 动态插槽支持 --> |
| | | <template v-for="(slotFn, slotName) in getSlots(item)" :key="slotName" #[slotName]> |
| | | <component :is="slotFn" /> |
| | | </template> |
| | | </component> |
| | | </slot> |
| | | </ElFormItem> |
| | | </ElCol> |
| | | <ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="action-column"> |
| | | <div class="action-buttons-wrapper" :style="actionButtonsStyle"> |
| | | <div class="form-buttons"> |
| | | <ElButton v-if="showReset" class="reset-button" @click="handleReset" v-ripple> |
| | | {{ t('table.searchBar.reset') }} |
| | | </ElButton> |
| | | <ElButton |
| | | v-if="showSearch" |
| | | type="primary" |
| | | class="search-button" |
| | | @click="handleSearch" |
| | | v-ripple |
| | | :disabled="disabledSearch" |
| | | > |
| | | {{ t('table.searchBar.search') }} |
| | | </ElButton> |
| | | </div> |
| | | <div v-if="shouldShowExpandToggle" class="filter-toggle" @click="toggleExpand"> |
| | | <span>{{ expandToggleText }}</span> |
| | | <div class="icon-wrapper"> |
| | | <ElIcon> |
| | | <ArrowUpBold v-if="isExpanded" /> |
| | | <ArrowDownBold v-else /> |
| | | </ElIcon> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </ElCol> |
| | | </ElRow> |
| | | </ElForm> |
| | | </section> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { ArrowUpBold, ArrowDownBold } from '@element-plus/icons-vue' |
| | | |
| | | import { useWindowSize } from '@vueuse/core' |
| | | import { useI18n } from 'vue-i18n' |
| | | import { toRaw } from 'vue' |
| | | import { |
| | | ElCascader, |
| | | ElCheckbox, |
| | | ElCheckboxGroup, |
| | | ElDatePicker, |
| | | ElInput, |
| | | ElInputTag, |
| | | ElInputNumber, |
| | | ElRadioGroup, |
| | | ElRate, |
| | | ElSelect, |
| | | ElSlider, |
| | | ElSwitch, |
| | | ElTimePicker, |
| | | ElTimeSelect, |
| | | ElTreeSelect |
| | | } from 'element-plus' |
| | | import { calculateResponsiveSpan } from '@/utils/form/responsive' |
| | | defineOptions({ name: 'ArtSearchBar' }) |
| | | const componentMap = { |
| | | input: ElInput, |
| | | // 输入框 |
| | | inputTag: ElInputTag, |
| | | // 标签输入框 |
| | | number: ElInputNumber, |
| | | // 数字输入框 |
| | | select: ElSelect, |
| | | // 选择器 |
| | | switch: ElSwitch, |
| | | // 开关 |
| | | checkbox: ElCheckbox, |
| | | // 复选框 |
| | | checkboxgroup: ElCheckboxGroup, |
| | | // 复选框组 |
| | | radiogroup: ElRadioGroup, |
| | | // 单选框组 |
| | | date: ElDatePicker, |
| | | // 日期选择器 |
| | | daterange: ElDatePicker, |
| | | // 日期范围选择器 |
| | | datetime: ElDatePicker, |
| | | // 日期时间选择器 |
| | | datetimerange: ElDatePicker, |
| | | // 日期时间范围选择器 |
| | | rate: ElRate, |
| | | // 评分 |
| | | slider: ElSlider, |
| | | // 滑块 |
| | | cascader: ElCascader, |
| | | // 级联选择器 |
| | | timepicker: ElTimePicker, |
| | | // 时间选择器 |
| | | timeselect: ElTimeSelect, |
| | | // 时间选择 |
| | | treeselect: ElTreeSelect |
| | | // 树选择器 |
| | | } |
| | | const { width } = useWindowSize() |
| | | const { t } = useI18n() |
| | | const isMobile = computed(() => width.value < 500) |
| | | const formInstance = useTemplateRef('formRef') |
| | | const props = defineProps({ |
| | | items: { required: false, default: () => [] }, |
| | | span: { required: false, default: 6 }, |
| | | gutter: { required: false, default: 12 }, |
| | | isExpand: { required: false, default: false }, |
| | | labelPosition: { required: false, default: 'right' }, |
| | | labelWidth: { required: false, default: '70px' }, |
| | | showExpand: { required: false, default: true }, |
| | | defaultExpanded: { required: false, default: false }, |
| | | buttonLeftLimit: { required: false, default: 2 }, |
| | | showReset: { required: false, default: true }, |
| | | showSearch: { required: false, default: true }, |
| | | disabledSearch: { required: false, default: false }, |
| | | sanitizeOutput: { required: false, default: () => ({}) } |
| | | }) |
| | | const emit = defineEmits(['reset', 'search']) |
| | | const modelValue = defineModel({ default: {} }) |
| | | const initialModelValue = ref({}) |
| | | const cloneModelValue = (value) => { |
| | | if (!value) return {} |
| | | const deepClone = (source) => { |
| | | if (Array.isArray(source)) { |
| | | return source.map((item) => deepClone(item)) |
| | | } |
| | | if (source && typeof source === 'object') { |
| | | const rawSource = toRaw(source) |
| | | return Object.keys(rawSource).reduce((accumulator, key) => { |
| | | accumulator[key] = deepClone(rawSource[key]) |
| | | return accumulator |
| | | }, {}) |
| | | } |
| | | return source |
| | | } |
| | | return deepClone(toRaw(value)) |
| | | } |
| | | initialModelValue.value = cloneModelValue(modelValue.value) |
| | | const isExpanded = ref(props.defaultExpanded) |
| | | const rootProps = ['label', 'labelWidth', 'key', 'type', 'hidden', 'span', 'slots'] |
| | | const sanitizeOutputOptions = computed(() => ({ |
| | | removeEmptyString: true, |
| | | removeEmptyArray: true, |
| | | removeEmptyObject: true, |
| | | removeEmptyRichText: true, |
| | | keepZero: true, |
| | | keepFalse: true, |
| | | ...props.sanitizeOutput |
| | | })) |
| | | const getProps = (item) => { |
| | | if (item.props) return item.props |
| | | const props2 = { ...item } |
| | | rootProps.forEach((key) => delete props2[key]) |
| | | return props2 |
| | | } |
| | | const getSlots = (item) => { |
| | | if (!item.slots) return {} |
| | | const validSlots = {} |
| | | Object.entries(item.slots).forEach(([key, slotFn]) => { |
| | | if (slotFn) { |
| | | validSlots[key] = slotFn |
| | | } |
| | | }) |
| | | return validSlots |
| | | } |
| | | const getColSpan = (itemSpan, breakpoint) => { |
| | | return calculateResponsiveSpan(itemSpan, span.value, breakpoint) |
| | | } |
| | | const normalizeFieldValue = (value) => { |
| | | return value === '' ? void 0 : value |
| | | } |
| | | const getFieldValue = (key) => modelValue.value[key] |
| | | const setFieldValue = (key, value) => { |
| | | const normalizedValue = normalizeFieldValue(value) |
| | | if (normalizedValue === void 0) { |
| | | delete modelValue.value[key] |
| | | return |
| | | } |
| | | modelValue.value[key] = normalizedValue |
| | | } |
| | | const isRichTextEmpty = (value) => { |
| | | if (/<(img|video|audio|iframe|embed|object)\b/i.test(value)) { |
| | | return false |
| | | } |
| | | return ( |
| | | value |
| | | .replace(/ /gi, '') |
| | | .replace(/<br\s*\/?>/gi, '') |
| | | .replace(/<[^>]*>/g, '') |
| | | .trim() === '' |
| | | ) |
| | | } |
| | | const sanitizeOutputValue = (value) => { |
| | | const options = sanitizeOutputOptions.value |
| | | if (Array.isArray(value)) { |
| | | const sanitizedArray = value |
| | | .map((item) => sanitizeOutputValue(item)) |
| | | .filter((item) => item !== void 0) |
| | | return sanitizedArray.length === 0 && options.removeEmptyArray ? void 0 : sanitizedArray |
| | | } |
| | | if (value && typeof value === 'object') { |
| | | const rawValue = toRaw(value) |
| | | const sanitizedObject = Object.entries(rawValue).reduce((accumulator, [key, item]) => { |
| | | const sanitizedItem = sanitizeOutputValue(item) |
| | | if (sanitizedItem !== void 0) { |
| | | accumulator[key] = sanitizedItem |
| | | } |
| | | return accumulator |
| | | }, {}) |
| | | return Object.keys(sanitizedObject).length === 0 && options.removeEmptyObject |
| | | ? void 0 |
| | | : sanitizedObject |
| | | } |
| | | if (typeof value === 'string') { |
| | | if (options.removeEmptyString && value.trim() === '') { |
| | | return void 0 |
| | | } |
| | | if (options.removeEmptyRichText && isRichTextEmpty(value)) { |
| | | return void 0 |
| | | } |
| | | return value |
| | | } |
| | | if (value === 0) { |
| | | return options.keepZero ? value : void 0 |
| | | } |
| | | if (value === false) { |
| | | return options.keepFalse ? value : void 0 |
| | | } |
| | | return value ?? void 0 |
| | | } |
| | | const getSanitizedOutput = () => { |
| | | return sanitizeOutputValue(cloneModelValue(modelValue.value)) || {} |
| | | } |
| | | const getComponent = (item) => { |
| | | if (item.render) { |
| | | return item.render |
| | | } |
| | | const { type } = item |
| | | return componentMap[type] || componentMap['input'] |
| | | } |
| | | const visibleFormItems = computed(() => { |
| | | const filteredItems = props.items.filter((item) => !item.hidden) |
| | | const shouldShowLess = !props.isExpand && !isExpanded.value |
| | | if (shouldShowLess) { |
| | | const maxItemsPerRow = Math.floor(24 / props.span) - 1 |
| | | return filteredItems.slice(0, maxItemsPerRow) |
| | | } |
| | | return filteredItems |
| | | }) |
| | | const shouldShowExpandToggle = computed(() => { |
| | | const filteredItems = props.items.filter((item) => !item.hidden) |
| | | return ( |
| | | !props.isExpand && props.showExpand && filteredItems.length > Math.floor(24 / props.span) - 1 |
| | | ) |
| | | }) |
| | | const expandToggleText = computed(() => { |
| | | return isExpanded.value ? t('table.searchBar.collapse') : t('table.searchBar.expand') |
| | | }) |
| | | const actionButtonsStyle = computed(() => ({ |
| | | 'justify-content': isMobile.value |
| | | ? 'flex-end' |
| | | : props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit |
| | | ? 'flex-start' |
| | | : 'flex-end' |
| | | })) |
| | | const toggleExpand = () => { |
| | | isExpanded.value = !isExpanded.value |
| | | } |
| | | const handleReset = () => { |
| | | formInstance.value?.resetFields() |
| | | Object.keys(modelValue.value).forEach((key) => { |
| | | delete modelValue.value[key] |
| | | }) |
| | | Object.assign(modelValue.value, cloneModelValue(initialModelValue.value)) |
| | | emit('reset') |
| | | } |
| | | const handleSearch = () => { |
| | | emit('search', getSanitizedOutput()) |
| | | } |
| | | defineExpose({ |
| | | ref: formInstance, |
| | | validate: (...args) => formInstance.value?.validate(...args), |
| | | reset: handleReset, |
| | | // 允许外部在手动组装请求前直接读取清洗后的参数。 |
| | | getOutput: getSanitizedOutput |
| | | }) |
| | | const { span, gutter, labelPosition, labelWidth } = toRefs(props) |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | .art-search-bar { |
| | | padding: 15px 20px 0; |
| | | |
| | | .action-column { |
| | | flex: 1; |
| | | max-width: 100%; |
| | | |
| | | .action-buttons-wrapper { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | justify-content: flex-end; |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .form-buttons { |
| | | display: flex; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .filter-toggle { |
| | | display: flex; |
| | | align-items: center; |
| | | margin-left: 10px; |
| | | line-height: 32px; |
| | | color: var(--theme-color); |
| | | cursor: pointer; |
| | | transition: color 0.2s ease; |
| | | |
| | | &:hover { |
| | | color: var(--ElColor-primary); |
| | | } |
| | | |
| | | span { |
| | | font-size: 14px; |
| | | user-select: none; |
| | | } |
| | | |
| | | .icon-wrapper { |
| | | display: flex; |
| | | align-items: center; |
| | | margin-left: 4px; |
| | | font-size: 14px; |
| | | transition: transform 0.2s ease; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 响应式优化 |
| | | @media (width <= 768px) { |
| | | .art-search-bar { |
| | | padding: 16px 16px 0; |
| | | |
| | | .action-column { |
| | | .action-buttons-wrapper { |
| | | flex-direction: column; |
| | | gap: 8px; |
| | | align-items: stretch; |
| | | |
| | | .form-buttons { |
| | | justify-content: center; |
| | | } |
| | | |
| | | .filter-toggle { |
| | | justify-content: center; |
| | | margin-left: 0; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | </style> |
| New file |
| | |
| | | <!-- WangEditor 富文本编辑器 插件地址:https://www.wangeditor.com/ --> |
| | | <template> |
| | | <div class="editor-wrapper"> |
| | | <Toolbar |
| | | class="editor-toolbar" |
| | | :editor="editorRef" |
| | | :mode="mode" |
| | | :defaultConfig="toolbarConfig" |
| | | /> |
| | | <Editor |
| | | :style="{ height: height, overflowY: 'hidden' }" |
| | | v-model="modelValue" |
| | | :mode="mode" |
| | | :defaultConfig="editorConfig" |
| | | @onCreated="onCreateEditor" |
| | | /> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { Editor, Toolbar } from '@wangeditor/editor-for-vue' |
| | | |
| | | import '@wangeditor/editor/dist/css/style.css' |
| | | import { onBeforeUnmount, onMounted, shallowRef, computed } from 'vue' |
| | | import { useUserStore } from '@/store/modules/user' |
| | | import EmojiText from '@/utils/ui/emojo' |
| | | import request from '@/utils/http' |
| | | defineOptions({ name: 'ArtWangEditor' }) |
| | | const { VITE_API_URL } = import.meta.env |
| | | const props = defineProps({ |
| | | height: { required: false, default: '500px' }, |
| | | mode: { required: false, default: 'default' }, |
| | | placeholder: { required: false, default: '请输入内容...' }, |
| | | excludeKeys: { required: false, default: () => ['fontFamily'] }, |
| | | isCustomUpload: { required: false, default: false } |
| | | }) |
| | | const modelValue = defineModel({ required: true }) |
| | | const editorRef = shallowRef() |
| | | const userStore = useUserStore() |
| | | const DEFAULT_UPLOAD_CONFIG = { |
| | | maxFileSize: 3 * 1024 * 1024, |
| | | // 3MB |
| | | maxNumberOfFiles: 10, |
| | | fieldName: 'file', |
| | | allowedFileTypes: ['image/*'] |
| | | } |
| | | const uploadServer = computed( |
| | | () => props.uploadConfig?.server || `${VITE_API_URL}/api/common/upload/wangeditor` |
| | | ) |
| | | const mergedUploadConfig = computed(() => ({ |
| | | ...DEFAULT_UPLOAD_CONFIG, |
| | | ...props.uploadConfig |
| | | })) |
| | | const toolbarConfig = computed(() => { |
| | | const config = {} |
| | | if (props.toolbarKeys && props.toolbarKeys.length > 0) { |
| | | config.toolbarKeys = props.toolbarKeys |
| | | } |
| | | if (props.insertKeys) { |
| | | config.insertKeys = props.insertKeys |
| | | } |
| | | if (props.excludeKeys && props.excludeKeys.length > 0) { |
| | | config.excludeKeys = props.excludeKeys |
| | | } |
| | | return config |
| | | }) |
| | | const editorConfig = { |
| | | placeholder: props.placeholder, |
| | | MENU_CONF: { |
| | | uploadImage: { |
| | | fieldName: mergedUploadConfig.value.fieldName, |
| | | maxFileSize: mergedUploadConfig.value.maxFileSize, |
| | | maxNumberOfFiles: mergedUploadConfig.value.maxNumberOfFiles, |
| | | allowedFileTypes: mergedUploadConfig.value.allowedFileTypes, |
| | | server: uploadServer.value, |
| | | headers: { |
| | | Authorization: userStore.accessToken |
| | | }, |
| | | onSuccess() { |
| | | ElMessage.success(`图片上传成功 ${EmojiText[200]}`) |
| | | }, |
| | | onError(file, err, res) { |
| | | console.error('图片上传失败:', err, res) |
| | | ElMessage.error(`图片上传失败 ${EmojiText[500]}`) |
| | | } |
| | | } |
| | | } |
| | | } |
| | | if (props.uploadConfig?.isCustomUpload && props.uploadConfig?.server && editorConfig.MENU_CONF) { |
| | | editorConfig.MENU_CONF.uploadImage.customUpload = async (file, insertFn) => { |
| | | try { |
| | | const formData = new FormData() |
| | | formData.append(mergedUploadConfig.value.fieldName, file) |
| | | const response = await request.post({ |
| | | url: props.uploadConfig?.server, |
| | | data: formData, |
| | | headers: { |
| | | 'Content-Type': 'multipart/form-data', |
| | | Authorization: userStore.accessToken |
| | | } |
| | | }) |
| | | const { url, alt, href } = response |
| | | if (!url) { |
| | | throw new Error('上传失败,请检查服务端配置') |
| | | } |
| | | insertFn(url, alt, href) |
| | | ElMessage.success(`图片上传成功 ${EmojiText[200]}`) |
| | | } catch (error) { |
| | | console.error('图片上传失败:', error) |
| | | ElMessage.error(`图片上传失败 ${EmojiText[500]}`) |
| | | } |
| | | } |
| | | } |
| | | const onCreateEditor = (editor) => { |
| | | editorRef.value = editor |
| | | editor.on('fullScreen', () => { |
| | | console.log('编辑器进入全屏模式') |
| | | }) |
| | | applyCustomIcons() |
| | | } |
| | | const applyCustomIcons = () => { |
| | | let retryCount = 0 |
| | | const maxRetries = 10 |
| | | const retryDelay = 100 |
| | | const tryApplyIcons = () => { |
| | | const editor = editorRef.value |
| | | if (!editor) { |
| | | if (retryCount < maxRetries) { |
| | | retryCount++ |
| | | setTimeout(tryApplyIcons, retryDelay) |
| | | } |
| | | return |
| | | } |
| | | const editorContainer = editor.getEditableContainer().closest('.editor-wrapper') |
| | | if (!editorContainer) { |
| | | if (retryCount < maxRetries) { |
| | | retryCount++ |
| | | setTimeout(tryApplyIcons, retryDelay) |
| | | } |
| | | return |
| | | } |
| | | const toolbar = editorContainer.querySelector('.w-e-toolbar') |
| | | const toolbarButtons = editorContainer.querySelectorAll('.w-e-bar-item button[data-menu-key]') |
| | | if (toolbar && toolbarButtons.length > 0) { |
| | | return |
| | | } |
| | | if (retryCount < maxRetries) { |
| | | retryCount++ |
| | | setTimeout(tryApplyIcons, retryDelay) |
| | | } else { |
| | | console.warn('工具栏渲染超时,无法应用自定义图标 - 编辑器实例:', editor.id) |
| | | } |
| | | } |
| | | requestAnimationFrame(tryApplyIcons) |
| | | } |
| | | defineExpose({ |
| | | /** 获取编辑器实例 */ |
| | | getEditor: () => editorRef.value, |
| | | /** 设置编辑器内容 */ |
| | | setHtml: (html) => editorRef.value?.setHtml(html), |
| | | /** 获取编辑器内容 */ |
| | | getHtml: () => editorRef.value?.getHtml(), |
| | | /** 清空编辑器 */ |
| | | clear: () => editorRef.value?.clear(), |
| | | /** 聚焦编辑器 */ |
| | | focus: () => editorRef.value?.focus() |
| | | }) |
| | | onMounted(() => {}) |
| | | onBeforeUnmount(() => { |
| | | const editor = editorRef.value |
| | | if (editor) { |
| | | editor.destroy() |
| | | } |
| | | }) |
| | | </script> |
| | | |
| | | <style lang="scss"> |
| | | @use './style'; |
| | | </style> |
| New file |
| | |
| | | $box-radius: calc(var(--custom-radius) / 3 + 2px); |
| | | |
| | | // 全屏容器 z-index 调整 |
| | | .w-e-full-screen-container { |
| | | z-index: 100 !important; |
| | | } |
| | | |
| | | /* 编辑器容器 */ |
| | | .editor-wrapper { |
| | | width: 100%; |
| | | height: 100%; |
| | | border: 1px solid var(--art-gray-300); |
| | | border-radius: $box-radius !important; |
| | | |
| | | .w-e-bar { |
| | | border-radius: $box-radius $box-radius 0 0 !important; |
| | | } |
| | | |
| | | .menu-item { |
| | | display: flex; |
| | | flex-direction: row; |
| | | align-items: center; |
| | | |
| | | i { |
| | | margin-right: 5px; |
| | | } |
| | | } |
| | | |
| | | /* 工具栏 */ |
| | | .editor-toolbar { |
| | | border-bottom: 1px solid var(--default-border); |
| | | } |
| | | |
| | | /* 下拉选择框配置 */ |
| | | .w-e-select-list { |
| | | min-width: 140px; |
| | | padding: 5px 10px 10px; |
| | | border: none; |
| | | border-radius: $box-radius; |
| | | } |
| | | |
| | | /* 下拉选择框元素配置 */ |
| | | .w-e-select-list ul li { |
| | | margin-top: 5px; |
| | | font-size: 15px !important; |
| | | border-radius: $box-radius; |
| | | } |
| | | |
| | | /* 下拉选择框 正文文字大小调整 */ |
| | | .w-e-select-list ul li:last-of-type { |
| | | font-size: 16px !important; |
| | | } |
| | | |
| | | /* 下拉选择框 hover 样式调整 */ |
| | | .w-e-select-list ul li:hover { |
| | | background-color: var(--art-gray-200); |
| | | } |
| | | |
| | | :root { |
| | | /* 激活颜色 */ |
| | | --w-e-toolbar-active-bg-color: var(--art-gray-200); |
| | | |
| | | /* toolbar 图标和文字颜色 */ |
| | | --w-e-toolbar-color: #000; |
| | | |
| | | /* 表格选中时候的边框颜色 */ |
| | | --w-e-textarea-selected-border-color: #ddd; |
| | | |
| | | /* 表格头背景颜色 */ |
| | | --w-e-textarea-slight-bg-color: var(--art-gray-200); |
| | | } |
| | | |
| | | /* 工具栏按钮样式 */ |
| | | .w-e-bar-item svg { |
| | | fill: var(--art-gray-800); |
| | | } |
| | | |
| | | .w-e-bar-item button { |
| | | color: var(--art-gray-800); |
| | | border-radius: $box-radius; |
| | | } |
| | | |
| | | /* 工具栏 hover 按钮背景颜色 */ |
| | | .w-e-bar-item button:hover { |
| | | background-color: var(--art-gray-200); |
| | | } |
| | | |
| | | /* 工具栏分割线 */ |
| | | .w-e-bar-divider { |
| | | height: 20px; |
| | | margin-top: 10px; |
| | | background-color: #ccc; |
| | | } |
| | | |
| | | /* 工具栏菜单 */ |
| | | .w-e-bar-item-group .w-e-bar-item-menus-container { |
| | | min-width: 120px; |
| | | padding: 10px 0; |
| | | border: none; |
| | | border-radius: $box-radius; |
| | | |
| | | .w-e-bar-item { |
| | | button { |
| | | width: 100%; |
| | | margin: 0 5px; |
| | | } |
| | | } |
| | | } |
| | | |
| | | /* 代码块 */ |
| | | .w-e-text-container [data-slate-editor] pre > code { |
| | | padding: 0.6rem 1rem; |
| | | background-color: var(--art-gray-50); |
| | | border-radius: $box-radius; |
| | | } |
| | | |
| | | /* 弹出框 */ |
| | | .w-e-drop-panel { |
| | | border: 0; |
| | | border-radius: $box-radius; |
| | | } |
| | | |
| | | a { |
| | | color: #318ef4; |
| | | } |
| | | |
| | | .w-e-text-container { |
| | | [data-slate-editor] { |
| | | h1, |
| | | h2, |
| | | h3, |
| | | h4, |
| | | h5, |
| | | h6 { |
| | | margin: 0.8em 0 0.4em; |
| | | font-weight: 700; |
| | | line-height: 1.35; |
| | | } |
| | | |
| | | h1 { |
| | | font-size: 2em; |
| | | } |
| | | |
| | | h2 { |
| | | font-size: 1.5em; |
| | | } |
| | | |
| | | h3 { |
| | | font-size: 1.25em; |
| | | } |
| | | |
| | | h4 { |
| | | font-size: 1.125em; |
| | | } |
| | | |
| | | h5 { |
| | | font-size: 1em; |
| | | } |
| | | |
| | | h6 { |
| | | font-size: 0.875em; |
| | | } |
| | | |
| | | ul, |
| | | ol { |
| | | padding-left: 1.5em; |
| | | margin: 0.8em 0; |
| | | } |
| | | |
| | | ul { |
| | | list-style: disc; |
| | | } |
| | | |
| | | ol { |
| | | list-style: decimal; |
| | | } |
| | | |
| | | li { |
| | | margin: 0.25em 0; |
| | | } |
| | | |
| | | ul ul { |
| | | list-style: circle; |
| | | } |
| | | |
| | | ul ul ul { |
| | | list-style: square; |
| | | } |
| | | } |
| | | |
| | | strong, |
| | | b { |
| | | font-weight: 700; |
| | | } |
| | | |
| | | i, |
| | | em { |
| | | font-style: italic; |
| | | } |
| | | } |
| | | |
| | | /* 表格样式优化 */ |
| | | .w-e-text-container [data-slate-editor] .table-container th { |
| | | border-right: none; |
| | | } |
| | | |
| | | .w-e-text-container [data-slate-editor] .table-container th:last-of-type { |
| | | border-right: 1px solid #ccc !important; |
| | | } |
| | | |
| | | /* 引用 */ |
| | | .w-e-text-container [data-slate-editor] blockquote { |
| | | background-color: var(--art-gray-200); |
| | | border-left: 4px solid var(--art-gray-300); |
| | | } |
| | | |
| | | /* 输入区域弹出 bar */ |
| | | .w-e-hover-bar { |
| | | border-radius: $box-radius; |
| | | } |
| | | |
| | | /* 超链接弹窗 */ |
| | | .w-e-modal { |
| | | border: none; |
| | | border-radius: $box-radius; |
| | | } |
| | | |
| | | /* 图片样式调整 */ |
| | | .w-e-text-container [data-slate-editor] .w-e-selected-image-container { |
| | | overflow: inherit; |
| | | |
| | | &:hover { |
| | | border: 0; |
| | | } |
| | | |
| | | img { |
| | | border: 1px solid transparent; |
| | | transition: border 0.3s; |
| | | |
| | | &:hover { |
| | | border: 1px solid #318ef4 !important; |
| | | } |
| | | } |
| | | |
| | | .w-e-image-dragger { |
| | | width: 12px; |
| | | height: 12px; |
| | | background-color: #318ef4; |
| | | border: 2px solid #fff; |
| | | border-radius: $box-radius; |
| | | } |
| | | |
| | | .left-top { |
| | | top: -6px; |
| | | left: -6px; |
| | | } |
| | | |
| | | .right-top { |
| | | top: -6px; |
| | | right: -6px; |
| | | } |
| | | |
| | | .left-bottom { |
| | | bottom: -6px; |
| | | left: -6px; |
| | | } |
| | | |
| | | .right-bottom { |
| | | right: -6px; |
| | | bottom: -6px; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | <!-- 面包屑导航 --> |
| | | <template> |
| | | <nav class="ml-2.5 max-lg:!hidden" aria-label="breadcrumb"> |
| | | <ul class="flex-c h-full"> |
| | | <li |
| | | v-for="(item, index) in breadcrumbItems" |
| | | :key="item.path" |
| | | class="box-border flex-c h-7 text-sm leading-7" |
| | | > |
| | | <div |
| | | :class=" |
| | | isClickable(item, index) |
| | | ? 'c-p py-1 rounded tad-200 hover:bg-active-color hover:[&_span]:text-g-600' |
| | | : '' |
| | | " |
| | | @click="handleBreadcrumbClick(item, index)" |
| | | > |
| | | <span |
| | | class="block max-w-46 overflow-hidden text-ellipsis whitespace-nowrap px-1.5 text-sm text-g-600 dark:text-g-800" |
| | | >{{ formatMenuTitle(item.meta?.title) }}</span |
| | | > |
| | | </div> |
| | | <div |
| | | v-if="!isLastItem(index) && item.meta?.title" |
| | | class="mx-1 text-sm not-italic text-g-500" |
| | | aria-hidden="true" |
| | | > |
| | | / |
| | | </div> |
| | | </li> |
| | | </ul> |
| | | </nav> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed } from 'vue' |
| | | import { useRouter, useRoute } from 'vue-router' |
| | | import { formatMenuTitle } from '@/utils/router' |
| | | defineOptions({ name: 'ArtBreadcrumb' }) |
| | | const route = useRoute() |
| | | const router = useRouter() |
| | | const breadcrumbItems = computed(() => { |
| | | const { matched } = route |
| | | const matchedLength = matched.length |
| | | if (!matchedLength || isHomeRoute(matched[0])) { |
| | | return [] |
| | | } |
| | | const firstRoute = matched[0] |
| | | const isFirstLevel = firstRoute.meta?.isFirstLevel |
| | | const lastIndex = matchedLength - 1 |
| | | const currentRoute = matched[lastIndex] |
| | | const currentRouteMeta = currentRoute.meta |
| | | let items = isFirstLevel |
| | | ? [createBreadcrumbItem(currentRoute)] |
| | | : matched.map(createBreadcrumbItem) |
| | | if (items.length > 1 && isWrapperContainer(items[0])) { |
| | | items = items.slice(1) |
| | | } |
| | | if (currentRouteMeta?.isIframe && (items.length === 1 || items.every(isWrapperContainer))) { |
| | | return [createBreadcrumbItem(currentRoute)] |
| | | } |
| | | return items |
| | | }) |
| | | const isWrapperContainer = (item) => item.path === '/outside' && !!item.meta?.isIframe |
| | | const createBreadcrumbItem = (route2) => ({ |
| | | path: route2.path, |
| | | meta: route2.meta |
| | | }) |
| | | const isHomeRoute = (route2) => route2.name === '/' |
| | | const isLastItem = (index) => { |
| | | const itemsLength = breadcrumbItems.value.length |
| | | return index === itemsLength - 1 |
| | | } |
| | | const isClickable = (item, index) => item.path !== '/outside' && !isLastItem(index) |
| | | const findFirstValidChild = (route2) => |
| | | route2.children?.find((child) => !child.redirect && !child.meta?.isHide) |
| | | const buildFullPath = (childPath) => `/${childPath}`.replace('//', '/') |
| | | async function handleBreadcrumbClick(item, index) { |
| | | if (isLastItem(index) || item.path === '/outside') { |
| | | return |
| | | } |
| | | try { |
| | | const routes = router.getRoutes() |
| | | const targetRoute = routes.find((route2) => route2.path === item.path) |
| | | if (!targetRoute?.children?.length) { |
| | | await router.push(item.path) |
| | | return |
| | | } |
| | | const firstValidChild = findFirstValidChild(targetRoute) |
| | | if (firstValidChild) { |
| | | await router.push(buildFullPath(firstValidChild.path)) |
| | | } else { |
| | | await router.push(item.path) |
| | | } |
| | | } catch (error) { |
| | | console.error('导航失败:', error) |
| | | } |
| | | } |
| | | </script> |
| New file |
| | |
| | | <!-- 系统聊天窗口 --> |
| | | <template> |
| | | <div> |
| | | <ElDrawer v-model="isDrawerVisible" :size="isMobile ? '100%' : '480px'" :with-header="false"> |
| | | <div class="mb-5 flex-cb"> |
| | | <div> |
| | | <span class="text-base font-medium">Art Bot</span> |
| | | <div class="mt-1.5 flex-c gap-1"> |
| | | <div |
| | | class="h-2 w-2 rounded-full" |
| | | :class="isOnline ? 'bg-success/100' : 'bg-danger/100'" |
| | | ></div> |
| | | <span class="text-xs text-g-600">{{ isOnline ? '在线' : '离线' }}</span> |
| | | </div> |
| | | </div> |
| | | <div> |
| | | <ElIcon class="c-p" :size="20" @click="closeChat"> |
| | | <Close /> |
| | | </ElIcon> |
| | | </div> |
| | | </div> |
| | | <div class="flex h-[calc(100%-70px)] flex-col"> |
| | | <!-- 聊天消息区域 --> |
| | | <div |
| | | class="flex-1 overflow-y-auto border-t-d px-4 py-7.5 [&::-webkit-scrollbar]:!w-1" |
| | | ref="messageContainer" |
| | | > |
| | | <template v-for="(message, index) in messages" :key="index"> |
| | | <div |
| | | :class="[ |
| | | 'mb-7.5 flex w-full items-start gap-2', |
| | | message.isMe ? 'flex-row-reverse' : 'flex-row' |
| | | ]" |
| | | > |
| | | <ElAvatar :size="32" :src="message.avatar" class="shrink-0" /> |
| | | <div |
| | | :class="['flex max-w-[70%] flex-col', message.isMe ? 'items-end' : 'items-start']" |
| | | > |
| | | <div |
| | | :class="[ |
| | | 'mb-1 flex gap-2 text-xs', |
| | | message.isMe ? 'flex-row-reverse' : 'flex-row' |
| | | ]" |
| | | > |
| | | <span class="font-medium">{{ message.sender }}</span> |
| | | <span class="text-g-600">{{ message.time }}</span> |
| | | </div> |
| | | <div |
| | | :class="[ |
| | | 'rounded-md px-3.5 py-2.5 text-sm leading-[1.4] text-g-900', |
| | | message.isMe ? 'message-right bg-theme/15' : 'message-left bg-g-300/50' |
| | | ]" |
| | | >{{ message.content }}</div |
| | | > |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </div> |
| | | |
| | | <!-- 聊天输入区域 --> |
| | | <div class="px-4 pt-4"> |
| | | <ElInput |
| | | v-model="messageText" |
| | | type="textarea" |
| | | :rows="3" |
| | | placeholder="输入消息" |
| | | resize="none" |
| | | @keyup.enter.prevent="sendMessage" |
| | | > |
| | | <template #append> |
| | | <div class="flex gap-2 py-2"> |
| | | <ElButton :icon="Paperclip" circle plain /> |
| | | <ElButton :icon="Picture" circle plain /> |
| | | <ElButton type="primary" @click="sendMessage" v-ripple>发送</ElButton> |
| | | </div> |
| | | </template> |
| | | </ElInput> |
| | | <div class="mt-3 flex-cb"> |
| | | <div class="flex-c"> |
| | | <ArtSvgIcon icon="ri:image-line" class="mr-5 c-p text-g-600 text-lg" /> |
| | | <ArtSvgIcon icon="ri:emotion-happy-line" class="mr-5 c-p text-g-600 text-lg" /> |
| | | </div> |
| | | <ElButton type="primary" @click="sendMessage" v-ripple class="min-w-20">发送</ElButton> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </ElDrawer> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { Picture, Paperclip, Close } from '@element-plus/icons-vue' |
| | | |
| | | import { mittBus } from '@/utils/sys' |
| | | import meAvatar from '@/assets/images/avatar/avatar5.webp' |
| | | import aiAvatar from '@/assets/images/avatar/avatar10.webp' |
| | | defineOptions({ name: 'ArtChatWindow' }) |
| | | const MOBILE_BREAKPOINT = 640 |
| | | const SCROLL_DELAY = 100 |
| | | const BOT_NAME = 'Art Bot' |
| | | const USER_NAME = 'Ricky' |
| | | const { width } = useWindowSize() |
| | | const isMobile = computed(() => width.value < MOBILE_BREAKPOINT) |
| | | const isDrawerVisible = ref(false) |
| | | const isOnline = ref(true) |
| | | const messageText = ref('') |
| | | const messageId = ref(10) |
| | | const messageContainer = ref(null) |
| | | const initializeMessages = () => [ |
| | | { |
| | | id: 1, |
| | | sender: BOT_NAME, |
| | | content: '你好!我是你的AI助手,有什么我可以帮你的吗?', |
| | | time: '10:00', |
| | | isMe: false, |
| | | avatar: aiAvatar |
| | | }, |
| | | { |
| | | id: 2, |
| | | sender: USER_NAME, |
| | | content: '我想了解一下系统的使用方法。', |
| | | time: '10:01', |
| | | isMe: true, |
| | | avatar: meAvatar |
| | | }, |
| | | { |
| | | id: 3, |
| | | sender: BOT_NAME, |
| | | content: '好的,我来为您介绍系统的主要功能。首先,您可以通过左侧菜单访问不同的功能模块...', |
| | | time: '10:02', |
| | | isMe: false, |
| | | avatar: aiAvatar |
| | | }, |
| | | { |
| | | id: 4, |
| | | sender: USER_NAME, |
| | | content: '听起来很不错,能具体讲讲数据分析部分吗?', |
| | | time: '10:05', |
| | | isMe: true, |
| | | avatar: meAvatar |
| | | }, |
| | | { |
| | | id: 5, |
| | | sender: BOT_NAME, |
| | | content: '当然可以。数据分析模块可以帮助您实时监控关键指标,并生成详细的报表...', |
| | | time: '10:06', |
| | | isMe: false, |
| | | avatar: aiAvatar |
| | | }, |
| | | { |
| | | id: 6, |
| | | sender: USER_NAME, |
| | | content: '太好了,那我如何开始使用呢?', |
| | | time: '10:08', |
| | | isMe: true, |
| | | avatar: meAvatar |
| | | }, |
| | | { |
| | | id: 7, |
| | | sender: BOT_NAME, |
| | | content: '您可以先创建一个项目,然后在项目中添加相关的数据源,系统会自动进行分析。', |
| | | time: '10:09', |
| | | isMe: false, |
| | | avatar: aiAvatar |
| | | }, |
| | | { |
| | | id: 8, |
| | | sender: USER_NAME, |
| | | content: '明白了,谢谢你的帮助!', |
| | | time: '10:10', |
| | | isMe: true, |
| | | avatar: meAvatar |
| | | }, |
| | | { |
| | | id: 9, |
| | | sender: BOT_NAME, |
| | | content: '不客气,有任何问题随时联系我。', |
| | | time: '10:11', |
| | | isMe: false, |
| | | avatar: aiAvatar |
| | | } |
| | | ] |
| | | const messages = ref(initializeMessages()) |
| | | const formatCurrentTime = () => { |
| | | return /* @__PURE__ */ new Date().toLocaleTimeString([], { |
| | | hour: '2-digit', |
| | | minute: '2-digit' |
| | | }) |
| | | } |
| | | const scrollToBottom = () => { |
| | | nextTick(() => { |
| | | setTimeout(() => { |
| | | if (messageContainer.value) { |
| | | messageContainer.value.scrollTop = messageContainer.value.scrollHeight |
| | | } |
| | | }, SCROLL_DELAY) |
| | | }) |
| | | } |
| | | const sendMessage = () => { |
| | | const text = messageText.value.trim() |
| | | if (!text) return |
| | | const newMessage = { |
| | | id: messageId.value++, |
| | | sender: USER_NAME, |
| | | content: text, |
| | | time: formatCurrentTime(), |
| | | isMe: true, |
| | | avatar: meAvatar |
| | | } |
| | | messages.value.push(newMessage) |
| | | messageText.value = '' |
| | | scrollToBottom() |
| | | } |
| | | const openChat = () => { |
| | | isDrawerVisible.value = true |
| | | scrollToBottom() |
| | | } |
| | | const closeChat = () => { |
| | | isDrawerVisible.value = false |
| | | } |
| | | onMounted(() => { |
| | | scrollToBottom() |
| | | mittBus.on('openChat', openChat) |
| | | }) |
| | | onUnmounted(() => { |
| | | mittBus.off('openChat', openChat) |
| | | }) |
| | | </script> |
| New file |
| | |
| | | <!-- 顶部快速入口面板 --> |
| | | <template> |
| | | <ElPopover |
| | | ref="popoverRef" |
| | | :width="700" |
| | | :offset="0" |
| | | :show-arrow="false" |
| | | trigger="hover" |
| | | placement="bottom-start" |
| | | popper-class="fast-enter-popover" |
| | | :popper-style="{ |
| | | border: '1px solid var(--default-border)', |
| | | borderRadius: 'calc(var(--custom-radius) / 2 + 4px)' |
| | | }" |
| | | > |
| | | <template #reference> |
| | | <div class="flex-c gap-2"> |
| | | <slot /> |
| | | </div> |
| | | </template> |
| | | |
| | | <div class="grid grid-cols-[2fr_0.8fr]"> |
| | | <div> |
| | | <div class="grid grid-cols-2 gap-1.5"> |
| | | <!-- 应用列表 --> |
| | | <div |
| | | v-for="application in enabledApplications" |
| | | :key="application.name" |
| | | class="mr-3 c-p flex-c gap-3 rounded-lg p-2 hover:bg-g-200/70 dark:hover:bg-g-200/90 hover:[&_.app-icon]:!bg-transparent" |
| | | @click="handleApplicationClick(application)" |
| | | > |
| | | <div class="app-icon size-12 flex-cc rounded-lg bg-g-200/80 dark:bg-g-300/30"> |
| | | <ArtSvgIcon |
| | | class="text-xl" |
| | | :icon="application.icon" |
| | | :style="{ color: application.iconColor }" |
| | | /> |
| | | </div> |
| | | <div> |
| | | <h3 class="m-0 text-sm font-medium text-g-800">{{ application.name }}</h3> |
| | | <p class="mt-1 text-xs text-g-600">{{ application.description }}</p> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="border-l-d pl-6 pt-2"> |
| | | <h3 class="mb-2.5 text-base font-medium text-g-800">快速链接</h3> |
| | | <ul> |
| | | <li |
| | | v-for="quickLink in enabledQuickLinks" |
| | | :key="quickLink.name" |
| | | class="c-p py-2 hover:[&_span]:text-theme" |
| | | @click="handleQuickLinkClick(quickLink)" |
| | | > |
| | | <span class="text-g-600 no-underline">{{ quickLink.name }}</span> |
| | | </li> |
| | | </ul> |
| | | </div> |
| | | </div> |
| | | </ElPopover> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useFastEnter } from '@/hooks/core/useFastEnter' |
| | | defineOptions({ name: 'ArtFastEnter' }) |
| | | const router = useRouter() |
| | | const popoverRef = ref() |
| | | const { enabledApplications, enabledQuickLinks } = useFastEnter() |
| | | const handleNavigate = (routeName, link) => { |
| | | const targetPath = routeName || link |
| | | if (!targetPath) { |
| | | console.warn('导航配置无效:缺少路由名称或链接') |
| | | return |
| | | } |
| | | if (targetPath.startsWith('http')) { |
| | | window.open(targetPath, '_blank') |
| | | } else { |
| | | router.push({ name: targetPath }) |
| | | } |
| | | popoverRef.value?.hide() |
| | | } |
| | | const handleApplicationClick = (application) => { |
| | | handleNavigate(application.routeName, application.link) |
| | | } |
| | | const handleQuickLinkClick = (quickLink) => { |
| | | handleNavigate(quickLink.routeName, quickLink.link) |
| | | } |
| | | </script> |
| New file |
| | |
| | | <!-- 烟花效果 | 礼花效果 --> |
| | | <template> |
| | | <canvas |
| | | ref="canvasRef" |
| | | class="fixed top-0 left-0 z-[9999] w-full h-full pointer-events-none" |
| | | ></canvas> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useEventListener } from '@vueuse/core' |
| | | import { mittBus } from '@/utils/sys' |
| | | import bp from '@/assets/images/ceremony/hb.png' |
| | | import sd from '@/assets/images/ceremony/sd.png' |
| | | import yd from '@/assets/images/ceremony/yd.png' |
| | | defineOptions({ name: 'ArtFireworksEffect' }) |
| | | const CONFIG = { |
| | | // 性能相关配置 |
| | | POOL_SIZE: 600, |
| | | // 对象池大小,影响同时存在的最大粒子数 |
| | | PARTICLES_PER_BURST: 200, |
| | | // 每次爆炸的粒子数量,影响视觉效果密度 |
| | | // 粒子尺寸配置 |
| | | SIZES: { |
| | | RECTANGLE: { WIDTH: 24, HEIGHT: 12 }, |
| | | // 矩形粒子尺寸 |
| | | SQUARE: { SIZE: 12 }, |
| | | // 正方形粒子尺寸 |
| | | CIRCLE: { SIZE: 12 }, |
| | | // 圆形粒子尺寸 |
| | | TRIANGLE: { SIZE: 10 }, |
| | | // 三角形粒子尺寸 |
| | | OVAL: { WIDTH: 24, HEIGHT: 12 }, |
| | | // 椭圆粒子尺寸 |
| | | IMAGE: { WIDTH: 30, HEIGHT: 30 } |
| | | // 图片粒子尺寸 |
| | | }, |
| | | // 旋转动画配置 |
| | | ROTATION: { |
| | | BASE_SPEED: 2, |
| | | // 基础旋转速度 |
| | | RANDOM_SPEED: 3, |
| | | // 额外随机旋转速度范围 |
| | | DECAY: 0.98 |
| | | // 旋转速度衰减系数 (越小衰减越快) |
| | | }, |
| | | // 物理效果配置 |
| | | PHYSICS: { |
| | | GRAVITY: 0.525, |
| | | // 重力加速度,影响粒子下落速度 |
| | | VELOCITY_THRESHOLD: 10, |
| | | // 速度阈值,超过时开始透明度衰减 |
| | | OPACITY_DECAY: 0.02 |
| | | // 透明度衰减速度,影响粒子消失快慢 |
| | | }, |
| | | // 粒子颜色配置 - 使用RGBA格式支持透明度 |
| | | COLORS: [ |
| | | 'rgba(255, 68, 68, 1)', |
| | | // 红色系 |
| | | 'rgba(255, 68, 68, 0.9)', |
| | | 'rgba(255, 68, 68, 0.8)', |
| | | 'rgba(255, 116, 188, 1)', |
| | | // 粉色系 |
| | | 'rgba(255, 116, 188, 0.9)', |
| | | 'rgba(255, 116, 188, 0.8)', |
| | | 'rgba(68, 68, 255, 0.8)', |
| | | // 蓝色系 |
| | | 'rgba(92, 202, 56, 0.7)', |
| | | // 绿色系 |
| | | 'rgba(255, 68, 255, 0.8)', |
| | | // 紫色系 |
| | | 'rgba(68, 255, 255, 0.7)', |
| | | // 青色系 |
| | | 'rgba(255, 136, 68, 0.7)', |
| | | // 橙色系 |
| | | 'rgba(68, 136, 255, 1)', |
| | | // 蓝色系 |
| | | 'rgba(250, 198, 122, 0.8)' |
| | | // 金色系 |
| | | ], |
| | | // 粒子形状配置 - 矩形出现概率更高,营造更丰富的视觉效果 |
| | | SHAPES: [ |
| | | 'rectangle', |
| | | 'rectangle', |
| | | 'rectangle', |
| | | 'rectangle', |
| | | 'rectangle', |
| | | 'rectangle', |
| | | 'rectangle', |
| | | 'circle', |
| | | 'triangle', |
| | | 'oval' |
| | | ] |
| | | } |
| | | const canvasRef = ref() |
| | | const ctx = ref(null) |
| | | class FireworkSystem { |
| | | constructor() { |
| | | this.particlePool = [] |
| | | this.activeParticles = [] |
| | | this.poolIndex = 0 |
| | | this.imageCache = {} |
| | | this.animationId = 0 |
| | | this.canvasWidth = 0 |
| | | this.canvasHeight = 0 |
| | | this.animate = () => { |
| | | this.updateParticles() |
| | | this.render() |
| | | this.animationId = requestAnimationFrame(this.animate) |
| | | } |
| | | this.initializePool() |
| | | } |
| | | /** |
| | | * 初始化对象池 |
| | | * 预先创建指定数量的粒子对象,避免运行时频繁创建 |
| | | */ |
| | | initializePool() { |
| | | for (let i = 0; i < CONFIG.POOL_SIZE; i++) { |
| | | this.particlePool.push(this.createParticle()) |
| | | } |
| | | } |
| | | /** |
| | | * 创建一个新的粒子对象 |
| | | * 返回初始化状态的粒子 |
| | | */ |
| | | createParticle() { |
| | | return { |
| | | x: 0, |
| | | y: 0, |
| | | vx: 0, |
| | | vy: 0, |
| | | color: '', |
| | | rotation: 0, |
| | | rotationSpeed: 0, |
| | | scale: 1, |
| | | shape: 'circle', |
| | | opacity: 1, |
| | | active: false |
| | | } |
| | | } |
| | | /** |
| | | * 从对象池获取可用粒子 (性能优化版本) |
| | | * 使用循环索引而非Array.find(),时间复杂度从O(n)降至O(1) |
| | | * @returns 可用的粒子对象或null |
| | | */ |
| | | getAvailableParticle() { |
| | | for (let i = 0; i < CONFIG.POOL_SIZE; i++) { |
| | | const index = (this.poolIndex + i) % CONFIG.POOL_SIZE |
| | | const particle = this.particlePool[index] |
| | | if (!particle.active) { |
| | | this.poolIndex = (index + 1) % CONFIG.POOL_SIZE |
| | | particle.active = true |
| | | return particle |
| | | } |
| | | } |
| | | return null |
| | | } |
| | | /** |
| | | * 预加载单个图片资源 |
| | | * @param url 图片URL |
| | | * @returns Promise<HTMLImageElement> |
| | | */ |
| | | async preloadImage(url) { |
| | | if (this.imageCache[url]) { |
| | | return this.imageCache[url] |
| | | } |
| | | return new Promise((resolve, reject) => { |
| | | const img = new Image() |
| | | img.crossOrigin = 'anonymous' |
| | | img.onload = () => { |
| | | this.imageCache[url] = img |
| | | resolve(img) |
| | | } |
| | | img.onerror = reject |
| | | img.src = url |
| | | }) |
| | | } |
| | | /** |
| | | * 预加载所有需要的图片资源 |
| | | * 在组件初始化时调用,确保图片ready |
| | | */ |
| | | async preloadAllImages() { |
| | | const imageUrls = [bp, sd, yd] |
| | | try { |
| | | await Promise.all(imageUrls.map((url) => this.preloadImage(url))) |
| | | } catch (error) { |
| | | console.error('Image preloading failed:', error) |
| | | } |
| | | } |
| | | /** |
| | | * 创建烟花爆炸效果 |
| | | * @param imageUrl 可选的图片URL,如果提供则使用图片粒子 |
| | | */ |
| | | createFirework(imageUrl) { |
| | | const startX = Math.random() * this.canvasWidth |
| | | const startY = this.canvasHeight |
| | | const availableShapes = imageUrl && this.imageCache[imageUrl] ? ['image'] : CONFIG.SHAPES |
| | | const particles = [] |
| | | for (let i = 0; i < CONFIG.PARTICLES_PER_BURST; i++) { |
| | | const particle = this.getAvailableParticle() |
| | | if (!particle) continue |
| | | const angle = (Math.PI * i) / (CONFIG.PARTICLES_PER_BURST / 2) |
| | | const speed = (12 + Math.random() * 6) * 1.5 |
| | | const spread = Math.random() * Math.PI * 2 |
| | | particle.x = startX |
| | | particle.y = startY |
| | | particle.vx = Math.cos(angle) * Math.cos(spread) * speed * (Math.random() * 0.5 + 0.5) |
| | | particle.vy = Math.sin(angle) * speed - 15 |
| | | particle.color = CONFIG.COLORS[Math.floor(Math.random() * CONFIG.COLORS.length)] |
| | | particle.rotation = Math.random() * 360 |
| | | particle.rotationSpeed = |
| | | (Math.random() * CONFIG.ROTATION.RANDOM_SPEED + CONFIG.ROTATION.BASE_SPEED) * |
| | | (Math.random() > 0.5 ? 1 : -1) |
| | | particle.scale = 0.8 + Math.random() * 0.4 |
| | | particle.shape = availableShapes[Math.floor(Math.random() * availableShapes.length)] |
| | | particle.opacity = 1 |
| | | particle.imageUrl = imageUrl && this.imageCache[imageUrl] ? imageUrl : void 0 |
| | | particles.push(particle) |
| | | } |
| | | this.activeParticles.push(...particles) |
| | | } |
| | | /** |
| | | * 更新所有粒子的物理状态 (性能优化版本) |
| | | * 包括位置、速度、旋转、透明度等 |
| | | */ |
| | | updateParticles() { |
| | | const { GRAVITY, VELOCITY_THRESHOLD, OPACITY_DECAY } = CONFIG.PHYSICS |
| | | const { DECAY } = CONFIG.ROTATION |
| | | for (let i = this.activeParticles.length - 1; i >= 0; i--) { |
| | | const particle = this.activeParticles[i] |
| | | particle.x += particle.vx |
| | | particle.y += particle.vy |
| | | particle.vy += GRAVITY |
| | | particle.rotation += particle.rotationSpeed |
| | | particle.rotationSpeed *= DECAY |
| | | if (particle.vy > VELOCITY_THRESHOLD) { |
| | | particle.opacity -= OPACITY_DECAY |
| | | if (particle.opacity <= 0) { |
| | | this.recycleParticle(i) |
| | | continue |
| | | } |
| | | } |
| | | if (this.isOutOfBounds(particle)) { |
| | | this.recycleParticle(i) |
| | | } |
| | | } |
| | | } |
| | | /** |
| | | * 回收粒子到对象池 |
| | | * @param index 要回收的粒子在活动数组中的索引 |
| | | */ |
| | | recycleParticle(index) { |
| | | const particle = this.activeParticles[index] |
| | | particle.active = false |
| | | this.activeParticles.splice(index, 1) |
| | | } |
| | | /** |
| | | * 检查粒子是否超出屏幕边界 |
| | | * @param particle 要检查的粒子 |
| | | * @returns 是否超出边界 |
| | | */ |
| | | isOutOfBounds(particle) { |
| | | const margin = 100 |
| | | return ( |
| | | particle.x < -margin || |
| | | particle.x > this.canvasWidth + margin || |
| | | particle.y < -margin || |
| | | particle.y > this.canvasHeight + margin |
| | | ) |
| | | } |
| | | /** |
| | | * 绘制单个粒子 |
| | | * @param particle 要绘制的粒子对象 |
| | | */ |
| | | drawParticle(particle) { |
| | | if (!ctx.value) return |
| | | ctx.value.save() |
| | | ctx.value.globalAlpha = particle.opacity |
| | | ctx.value.translate(particle.x, particle.y) |
| | | ctx.value.rotate((particle.rotation * Math.PI) / 180) |
| | | ctx.value.scale(particle.scale, particle.scale) |
| | | this.renderShape(particle) |
| | | ctx.value.restore() |
| | | } |
| | | /** |
| | | * 根据粒子类型渲染对应的形状 |
| | | * @param particle 要渲染的粒子 |
| | | */ |
| | | renderShape(particle) { |
| | | if (!ctx.value) return |
| | | const { SIZES } = CONFIG |
| | | ctx.value.fillStyle = particle.color |
| | | switch (particle.shape) { |
| | | case 'rectangle': |
| | | ctx.value.fillRect( |
| | | -SIZES.RECTANGLE.WIDTH / 2, |
| | | -SIZES.RECTANGLE.HEIGHT / 2, |
| | | SIZES.RECTANGLE.WIDTH, |
| | | SIZES.RECTANGLE.HEIGHT |
| | | ) |
| | | break |
| | | case 'square': |
| | | ctx.value.fillRect( |
| | | -SIZES.SQUARE.SIZE / 2, |
| | | -SIZES.SQUARE.SIZE / 2, |
| | | SIZES.SQUARE.SIZE, |
| | | SIZES.SQUARE.SIZE |
| | | ) |
| | | break |
| | | case 'circle': |
| | | ctx.value.beginPath() |
| | | ctx.value.arc(0, 0, SIZES.CIRCLE.SIZE / 2, 0, Math.PI * 2) |
| | | ctx.value.fill() |
| | | break |
| | | case 'triangle': |
| | | ctx.value.beginPath() |
| | | ctx.value.moveTo(0, -SIZES.TRIANGLE.SIZE) |
| | | ctx.value.lineTo(SIZES.TRIANGLE.SIZE, SIZES.TRIANGLE.SIZE) |
| | | ctx.value.lineTo(-SIZES.TRIANGLE.SIZE, SIZES.TRIANGLE.SIZE) |
| | | ctx.value.closePath() |
| | | ctx.value.fill() |
| | | break |
| | | case 'oval': |
| | | ctx.value.beginPath() |
| | | ctx.value.ellipse(0, 0, SIZES.OVAL.WIDTH / 2, SIZES.OVAL.HEIGHT / 2, 0, 0, Math.PI * 2) |
| | | ctx.value.fill() |
| | | break |
| | | case 'image': |
| | | this.renderImage(particle) |
| | | break |
| | | } |
| | | } |
| | | /** |
| | | * 渲染图片类型的粒子 |
| | | * @param particle 包含图片URL的粒子对象 |
| | | */ |
| | | renderImage(particle) { |
| | | if (!ctx.value || !particle.imageUrl) return |
| | | const img = this.imageCache[particle.imageUrl] |
| | | if (img?.complete) { |
| | | const { WIDTH, HEIGHT } = CONFIG.SIZES.IMAGE |
| | | ctx.value.drawImage(img, -WIDTH / 2, -HEIGHT / 2, WIDTH, HEIGHT) |
| | | } |
| | | } |
| | | /** |
| | | * 渲染所有活动粒子到画布 |
| | | * 清除画布并重新绘制所有粒子 |
| | | */ |
| | | render() { |
| | | if (!ctx.value || !canvasRef.value) return |
| | | ctx.value.clearRect(0, 0, this.canvasWidth, this.canvasHeight) |
| | | ctx.value.globalCompositeOperation = 'lighter' |
| | | for (const particle of this.activeParticles) { |
| | | this.drawParticle(particle) |
| | | } |
| | | } |
| | | /** |
| | | * 更新画布尺寸缓存 |
| | | * 在窗口大小改变时调用 |
| | | * @param width 新的画布宽度 |
| | | * @param height 新的画布高度 |
| | | */ |
| | | updateCanvasSize(width, height) { |
| | | this.canvasWidth = width |
| | | this.canvasHeight = height |
| | | } |
| | | /** |
| | | * 启动动画循环 |
| | | */ |
| | | start() { |
| | | this.animate() |
| | | } |
| | | /** |
| | | * 停止动画循环 |
| | | * 在组件卸载时调用,避免内存泄漏 |
| | | */ |
| | | stop() { |
| | | if (this.animationId) { |
| | | cancelAnimationFrame(this.animationId) |
| | | this.animationId = 0 |
| | | } |
| | | } |
| | | /** |
| | | * 获取当前活动粒子数量 |
| | | * 用于调试和性能监控 |
| | | * @returns 活动粒子数量 |
| | | */ |
| | | getActiveParticleCount() { |
| | | return this.activeParticles.length |
| | | } |
| | | } |
| | | const fireworkSystem = new FireworkSystem() |
| | | const handleKeyPress = (event) => { |
| | | const isFireworkShortcut = |
| | | (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === 'p') || |
| | | (event.metaKey && event.shiftKey && event.key.toLowerCase() === 'p') |
| | | if (isFireworkShortcut) { |
| | | event.preventDefault() |
| | | fireworkSystem.createFirework() |
| | | } |
| | | } |
| | | const resizeCanvas = () => { |
| | | if (!canvasRef.value) return |
| | | const { innerWidth, innerHeight } = window |
| | | canvasRef.value.width = innerWidth |
| | | canvasRef.value.height = innerHeight |
| | | fireworkSystem.updateCanvasSize(innerWidth, innerHeight) |
| | | } |
| | | const handleFireworkTrigger = (event) => { |
| | | const imageUrl = event |
| | | fireworkSystem.createFirework(imageUrl) |
| | | } |
| | | onMounted(async () => { |
| | | if (!canvasRef.value) return |
| | | ctx.value = canvasRef.value.getContext('2d') |
| | | if (!ctx.value) return |
| | | resizeCanvas() |
| | | await fireworkSystem.preloadAllImages() |
| | | fireworkSystem.start() |
| | | useEventListener(window, 'keydown', handleKeyPress) |
| | | useEventListener(window, 'resize', resizeCanvas) |
| | | mittBus.on('triggerFireworks', handleFireworkTrigger) |
| | | }) |
| | | onUnmounted(() => { |
| | | fireworkSystem.stop() |
| | | mittBus.off('triggerFireworks', handleFireworkTrigger) |
| | | }) |
| | | </script> |
| New file |
| | |
| | | <!-- 全局组件 --> |
| | | <template> |
| | | <component |
| | | v-for="componentConfig in enabledComponents" |
| | | :key="componentConfig.key" |
| | | :is="componentConfig.component" |
| | | /> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { getEnabledGlobalComponents } from '@/config/modules/component' |
| | | defineOptions({ name: 'ArtGlobalComponent' }) |
| | | const enabledComponents = computed(() => getEnabledGlobalComponents()) |
| | | </script> |
| New file |
| | |
| | | <!-- 全局搜索组件 --> |
| | | <template> |
| | | <div class="layout-search"> |
| | | <ElDialog |
| | | v-model="showSearchDialog" |
| | | width="600" |
| | | :show-close="false" |
| | | :lock-scroll="false" |
| | | modal-class="search-modal" |
| | | @close="closeSearchDialog" |
| | | > |
| | | <ElInput |
| | | v-model.trim="searchVal" |
| | | :placeholder="$t('search.placeholder')" |
| | | @input="search" |
| | | @blur="searchBlur" |
| | | ref="searchInput" |
| | | :prefix-icon="Search" |
| | | class="h-12" |
| | | > |
| | | <template #suffix> |
| | | <div |
| | | class="h-4.5 flex-cc rounded border border-g-300 dark:!bg-g-200/50 !bg-box px-1.5 text-g-500" |
| | | > |
| | | <ArtSvgIcon icon="fluent:arrow-enter-left-20-filled" /> |
| | | </div> |
| | | </template> |
| | | </ElInput> |
| | | <ElScrollbar class="mt-5" max-height="370px" ref="searchResultScrollbar" always> |
| | | <div class="result w-full" v-show="searchResult.length"> |
| | | <div |
| | | class="box !mt-0 c-p text-base leading-none" |
| | | v-for="(item, index) in searchResult" |
| | | :key="index" |
| | | > |
| | | <div |
| | | class="mt-2 h-12 flex-cb rounded-custom-sm bg-g-200/80 px-4 text-sm text-g-700" |
| | | :class="isHighlighted(index) ? 'highlighted !bg-theme/70 !text-white' : ''" |
| | | @click="searchGoPage(item)" |
| | | @mouseenter="highlightOnHover(index)" |
| | | > |
| | | {{ formatMenuTitle(item.meta.title) }} |
| | | <ArtSvgIcon v-show="isHighlighted(index)" icon="fluent:arrow-enter-left-20-filled" /> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <div v-show="!searchVal && searchResult.length === 0 && historyResult.length > 0"> |
| | | <p class="text-xs text-g-500">{{ $t('search.historyTitle') }}</p> |
| | | <div class="mt-1.5 w-full"> |
| | | <div |
| | | class="box mt-2 h-12 c-p flex-cb rounded-custom-sm bg-g-200/80 px-4 text-sm text-g-800" |
| | | v-for="(item, index) in historyResult" |
| | | :key="index" |
| | | :class=" |
| | | historyHIndex === index |
| | | ? 'highlighted !bg-theme/70 !text-white [&_.selected-icon]:!text-white' |
| | | : '' |
| | | " |
| | | @click="searchGoPage(item)" |
| | | @mouseenter="highlightOnHoverHistory(index)" |
| | | > |
| | | {{ formatMenuTitle(item.meta.title) }} |
| | | <div |
| | | class="size-5 selected-icon select-none rounded-full text-g-500 flex-cc c-p" |
| | | @click.stop="deleteHistory(index)" |
| | | > |
| | | <ArtSvgIcon icon="ri:close-large-fill" class="text-xs" /> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </ElScrollbar> |
| | | |
| | | <template #footer> |
| | | <div class="dialog-footer box-border flex-c border-t-d pt-4.5 pb-1"> |
| | | <div class="flex-cc"> |
| | | <ArtSvgIcon icon="fluent:arrow-enter-left-20-filled" class="keyboard" /> |
| | | <span class="mr-3.5 text-xs text-g-700">{{ $t('search.selectKeydown') }}</span> |
| | | </div> |
| | | <div class="flex-c"> |
| | | <ArtSvgIcon icon="ri:arrow-up-wide-fill" class="keyboard" /> |
| | | <ArtSvgIcon icon="ri:arrow-down-wide-fill" class="keyboard" /> |
| | | <span class="mr-3.5 text-xs text-g-700">{{ $t('search.switchKeydown') }}</span> |
| | | </div> |
| | | <div class="flex-c"> |
| | | <i class="keyboard !w-8 flex-cc"><p class="text-[10px] font-medium">ESC</p></i> |
| | | <span class="mr-3.5 text-xs text-g-700">{{ $t('search.exitKeydown') }}</span> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </ElDialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { Search } from '@element-plus/icons-vue' |
| | | |
| | | import { useUserStore } from '@/store/modules/user' |
| | | import { mittBus } from '@/utils/sys' |
| | | import { useMenuStore } from '@/store/modules/menu' |
| | | import { formatMenuTitle } from '@/utils/router' |
| | | import { handleMenuJump } from '@/utils/navigation' |
| | | defineOptions({ name: 'ArtGlobalSearch' }) |
| | | const userStore = useUserStore() |
| | | const { menuList } = storeToRefs(useMenuStore()) |
| | | const showSearchDialog = ref(false) |
| | | const searchVal = ref('') |
| | | const searchResult = ref([]) |
| | | const historyMaxLength = 10 |
| | | const { searchHistory: historyResult } = storeToRefs(userStore) |
| | | const searchInput = ref(null) |
| | | const highlightedIndex = ref(0) |
| | | const historyHIndex = ref(0) |
| | | const searchResultScrollbar = ref() |
| | | const isKeyboardNavigating = ref(false) |
| | | onMounted(() => { |
| | | mittBus.on('openSearchDialog', openSearchDialog) |
| | | document.addEventListener('keydown', handleKeydown) |
| | | }) |
| | | onUnmounted(() => { |
| | | document.removeEventListener('keydown', handleKeydown) |
| | | }) |
| | | const handleKeydown = (event) => { |
| | | const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0 |
| | | const isCommandKey = isMac ? event.metaKey : event.ctrlKey |
| | | if (isCommandKey && event.key.toLowerCase() === 'k') { |
| | | event.preventDefault() |
| | | showSearchDialog.value = true |
| | | focusInput() |
| | | } |
| | | if (showSearchDialog.value) { |
| | | if (event.key === 'ArrowUp') { |
| | | event.preventDefault() |
| | | highlightPrevious() |
| | | } else if (event.key === 'ArrowDown') { |
| | | event.preventDefault() |
| | | highlightNext() |
| | | } else if (event.key === 'Enter') { |
| | | event.preventDefault() |
| | | selectHighlighted() |
| | | } else if (event.key === 'Escape') { |
| | | event.preventDefault() |
| | | showSearchDialog.value = false |
| | | } |
| | | } |
| | | } |
| | | const focusInput = () => { |
| | | setTimeout(() => { |
| | | searchInput.value?.focus() |
| | | }, 100) |
| | | } |
| | | const search = (val) => { |
| | | if (val) { |
| | | searchResult.value = flattenAndFilterMenuItems(menuList.value, val) |
| | | } else { |
| | | searchResult.value = [] |
| | | } |
| | | } |
| | | const flattenAndFilterMenuItems = (items, val) => { |
| | | const lowerVal = val.toLowerCase() |
| | | const result = [] |
| | | const flattenAndMatch = (item) => { |
| | | if (item.meta?.isHide) return |
| | | const lowerItemTitle = formatMenuTitle(item.meta.title).toLowerCase() |
| | | if (item.children && item.children.length > 0) { |
| | | item.children.forEach(flattenAndMatch) |
| | | return |
| | | } |
| | | if ( |
| | | lowerItemTitle.includes(lowerVal) && |
| | | ((item.path && item.path.trim()) || item.meta.link || item.meta.isIframe) |
| | | ) { |
| | | result.push({ ...item, children: void 0 }) |
| | | } |
| | | } |
| | | items.forEach(flattenAndMatch) |
| | | return result |
| | | } |
| | | const highlightPrevious = () => { |
| | | isKeyboardNavigating.value = true |
| | | if (searchVal.value) { |
| | | highlightedIndex.value = |
| | | (highlightedIndex.value - 1 + searchResult.value.length) % searchResult.value.length |
| | | scrollToHighlightedItem() |
| | | } else { |
| | | historyHIndex.value = |
| | | (historyHIndex.value - 1 + historyResult.value.length) % historyResult.value.length |
| | | scrollToHighlightedHistoryItem() |
| | | } |
| | | setTimeout(() => { |
| | | isKeyboardNavigating.value = false |
| | | }, 100) |
| | | } |
| | | const highlightNext = () => { |
| | | isKeyboardNavigating.value = true |
| | | if (searchVal.value) { |
| | | highlightedIndex.value = (highlightedIndex.value + 1) % searchResult.value.length |
| | | scrollToHighlightedItem() |
| | | } else { |
| | | historyHIndex.value = (historyHIndex.value + 1) % historyResult.value.length |
| | | scrollToHighlightedHistoryItem() |
| | | } |
| | | setTimeout(() => { |
| | | isKeyboardNavigating.value = false |
| | | }, 100) |
| | | } |
| | | const scrollToHighlightedItem = () => { |
| | | nextTick(() => { |
| | | if (!searchResultScrollbar.value || !searchResult.value.length) return |
| | | const scrollWrapper = searchResultScrollbar.value.wrapRef |
| | | if (!scrollWrapper) return |
| | | const highlightedElements = scrollWrapper.querySelectorAll('.result .box') |
| | | if (!highlightedElements[highlightedIndex.value]) return |
| | | const highlightedElement = highlightedElements[highlightedIndex.value] |
| | | const itemHeight = highlightedElement.offsetHeight |
| | | const scrollTop = scrollWrapper.scrollTop |
| | | const containerHeight = scrollWrapper.clientHeight |
| | | const itemTop = highlightedElement.offsetTop |
| | | const itemBottom = itemTop + itemHeight |
| | | if (itemTop < scrollTop) { |
| | | searchResultScrollbar.value.setScrollTop(itemTop) |
| | | } else if (itemBottom > scrollTop + containerHeight) { |
| | | searchResultScrollbar.value.setScrollTop(itemBottom - containerHeight) |
| | | } |
| | | }) |
| | | } |
| | | const scrollToHighlightedHistoryItem = () => { |
| | | nextTick(() => { |
| | | if (!searchResultScrollbar.value || !historyResult.value.length) return |
| | | const scrollWrapper = searchResultScrollbar.value.wrapRef |
| | | if (!scrollWrapper) return |
| | | const historyItems = scrollWrapper.querySelectorAll('.history-result .box') |
| | | if (!historyItems[historyHIndex.value]) return |
| | | const highlightedElement = historyItems[historyHIndex.value] |
| | | const itemHeight = highlightedElement.offsetHeight |
| | | const scrollTop = scrollWrapper.scrollTop |
| | | const containerHeight = scrollWrapper.clientHeight |
| | | const itemTop = highlightedElement.offsetTop |
| | | const itemBottom = itemTop + itemHeight |
| | | if (itemTop < scrollTop) { |
| | | searchResultScrollbar.value.setScrollTop(itemTop) |
| | | } else if (itemBottom > scrollTop + containerHeight) { |
| | | searchResultScrollbar.value.setScrollTop(itemBottom - containerHeight) |
| | | } |
| | | }) |
| | | } |
| | | const selectHighlighted = () => { |
| | | if (searchVal.value && searchResult.value.length) { |
| | | searchGoPage(searchResult.value[highlightedIndex.value]) |
| | | } else if (!searchVal.value && historyResult.value.length) { |
| | | searchGoPage(historyResult.value[historyHIndex.value]) |
| | | } |
| | | } |
| | | const isHighlighted = (index) => { |
| | | return highlightedIndex.value === index |
| | | } |
| | | const searchBlur = () => { |
| | | highlightedIndex.value = 0 |
| | | } |
| | | const searchGoPage = (item) => { |
| | | showSearchDialog.value = false |
| | | addHistory(item) |
| | | handleMenuJump(item) |
| | | searchVal.value = '' |
| | | searchResult.value = [] |
| | | } |
| | | const updateHistory = () => { |
| | | if (Array.isArray(historyResult.value)) { |
| | | userStore.setSearchHistory(historyResult.value) |
| | | } |
| | | } |
| | | const addHistory = (item) => { |
| | | const itemKey = item.path || String(item.meta.link || '') |
| | | const hasItemIndex = historyResult.value.findIndex( |
| | | (historyItem) => (historyItem.path || String(historyItem.meta.link || '')) === itemKey |
| | | ) |
| | | if (hasItemIndex !== -1) { |
| | | historyResult.value.splice(hasItemIndex, 1) |
| | | } else if (historyResult.value.length >= historyMaxLength) { |
| | | historyResult.value.pop() |
| | | } |
| | | const cleanedItem = { ...item } |
| | | delete cleanedItem.children |
| | | delete cleanedItem.meta.authList |
| | | historyResult.value.unshift(cleanedItem) |
| | | updateHistory() |
| | | } |
| | | const deleteHistory = (index) => { |
| | | historyResult.value.splice(index, 1) |
| | | updateHistory() |
| | | } |
| | | const openSearchDialog = () => { |
| | | showSearchDialog.value = true |
| | | focusInput() |
| | | } |
| | | const closeSearchDialog = () => { |
| | | searchVal.value = '' |
| | | searchResult.value = [] |
| | | highlightedIndex.value = 0 |
| | | historyHIndex.value = 0 |
| | | } |
| | | const highlightOnHover = (index) => { |
| | | if (!isKeyboardNavigating.value && searchVal.value) { |
| | | highlightedIndex.value = index |
| | | } |
| | | } |
| | | const highlightOnHoverHistory = (index) => { |
| | | if (!isKeyboardNavigating.value && !searchVal.value) { |
| | | historyHIndex.value = index |
| | | } |
| | | } |
| | | </script> |
| | | <style lang="scss" scoped> |
| | | .layout-search { |
| | | :deep(.search-modal) { |
| | | background-color: rgb(0 0 0 / 20%); |
| | | } |
| | | |
| | | :deep(.el-dialog__body) { |
| | | padding: 5px 0 0 !important; |
| | | } |
| | | |
| | | :deep(.el-dialog__header) { |
| | | padding: 0; |
| | | } |
| | | |
| | | .el-input { |
| | | :deep(.el-input__wrapper) { |
| | | background-color: var(--art-gray-200); |
| | | border: 1px solid var(--default-border-dashed); |
| | | border-radius: calc(var(--custom-radius) / 2 + 2px) !important; |
| | | box-shadow: none; |
| | | } |
| | | |
| | | :deep(.el-input__inner) { |
| | | color: var(--art-gray-800) !important; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .dark .layout-search { |
| | | .el-input { |
| | | :deep(.el-input__wrapper) { |
| | | background-color: #333; |
| | | border: 1px solid #4c4d50; |
| | | } |
| | | } |
| | | |
| | | :deep(.search-modal) { |
| | | background-color: rgb(23 23 26 / 60%); |
| | | backdrop-filter: none; |
| | | } |
| | | |
| | | :deep(.el-dialog) { |
| | | background-color: #252526; |
| | | } |
| | | } |
| | | </style> |
| | | |
| | | <style scoped> |
| | | @reference '@styles/core/tailwind.css'; |
| | | |
| | | .keyboard { |
| | | @apply mr-2 |
| | | box-border |
| | | h-5 |
| | | w-5.5 |
| | | rounded |
| | | border |
| | | border-g-400 |
| | | px-1 |
| | | text-g-500 |
| | | shadow-[0_2px_0_var(--default-border-dashed)] |
| | | last-of-type:mr-1.5; |
| | | } |
| | | </style> |
| New file |
| | |
| | | <!-- 顶部栏 --> |
| | | <template> |
| | | <div |
| | | class="w-full bg-[var(--default-bg-color)]" |
| | | :class="[ |
| | | tabStyle === 'tab-card' || tabStyle === 'tab-google' ? 'mb-5 max-sm:mb-3 !bg-box' : '' |
| | | ]" |
| | | > |
| | | <div |
| | | class="relative box-border flex-b h-15 leading-15 select-none" |
| | | :class="[ |
| | | tabStyle === 'tab-card' || tabStyle === 'tab-google' |
| | | ? 'border-b border-[var(--art-card-border)]' |
| | | : '' |
| | | ]" |
| | | > |
| | | <div class="flex-c flex-1 min-w-0 leading-15" style="display: flex"> |
| | | <!-- 系统信息 --> |
| | | <div class="flex-c c-p" @click="toHome" v-if="isTopMenu"> |
| | | <ArtLogo class="pl-4.5" /> |
| | | <p v-if="width >= 1400" class="my-0 mx-2 ml-2 text-lg">{{ AppConfig.systemInfo.name }}</p> |
| | | </div> |
| | | |
| | | <ArtLogo |
| | | class="!hidden pl-3.5 overflow-hidden align-[-0.15em] fill-current" |
| | | @click="toHome" |
| | | /> |
| | | |
| | | <!-- 菜单按钮 --> |
| | | <ArtIconButton |
| | | v-if="isLeftMenu && shouldShowMenuButton" |
| | | icon="ri:menu-2-fill" |
| | | class="ml-3 max-sm:ml-[7px]" |
| | | @click="visibleMenu" |
| | | /> |
| | | |
| | | <!-- 刷新按钮 --> |
| | | <ArtIconButton |
| | | v-if="shouldShowRefreshButton" |
| | | icon="ri:refresh-line" |
| | | class="!ml-3 refresh-btn max-sm:!hidden" |
| | | :style="{ marginLeft: !isLeftMenu ? '10px' : '0' }" |
| | | @click="reload" |
| | | /> |
| | | |
| | | <!-- 快速入口 --> |
| | | <ArtFastEnter v-if="shouldShowFastEnter && width >= headerBarFastEnterMinWidth"> |
| | | <ArtIconButton icon="ri:function-line" class="ml-3" /> |
| | | </ArtFastEnter> |
| | | |
| | | <!-- 面包屑 --> |
| | | <ArtBreadcrumb |
| | | v-if="(shouldShowBreadcrumb && isLeftMenu) || (shouldShowBreadcrumb && isDualMenu)" |
| | | /> |
| | | |
| | | <!-- 顶部菜单 --> |
| | | <ArtHorizontalMenu v-if="isTopMenu" :list="menuList" /> |
| | | |
| | | <!-- 混合菜单-顶部 --> |
| | | <ArtMixedMenu v-if="isTopLeftMenu" :list="menuList" /> |
| | | </div> |
| | | |
| | | <div class="flex-c gap-2.5"> |
| | | <!-- 搜索 --> |
| | | <div |
| | | v-if="shouldShowGlobalSearch" |
| | | class="flex-cb w-40 h-9 px-2.5 c-p border border-g-400 rounded-custom-sm max-md:!hidden" |
| | | @click="openSearchDialog" |
| | | > |
| | | <div class="flex-c"> |
| | | <ArtSvgIcon icon="ri:search-line" class="text-sm text-g-500" /> |
| | | <span class="ml-1 text-xs font-normal text-g-500">{{ $t('topBar.search.title') }}</span> |
| | | </div> |
| | | <div class="flex-c h-5 px-1.5 text-g-500/80 border border-g-400 rounded"> |
| | | <ArtSvgIcon v-if="isWindows" icon="vaadin:ctrl-a" class="text-sm" /> |
| | | <ArtSvgIcon v-else icon="ri:command-fill" class="text-xs" /> |
| | | <span class="ml-0.5 text-xs">k</span> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 全屏按钮 --> |
| | | <ArtIconButton |
| | | v-if="shouldShowFullscreen" |
| | | :icon="isFullscreen ? 'ri:fullscreen-exit-line' : 'ri:fullscreen-fill'" |
| | | :class="[!isFullscreen ? 'full-screen-btn' : 'exit-full-screen-btn', 'ml-3']" |
| | | class="max-md:!hidden" |
| | | @click="toggleFullScreen" |
| | | /> |
| | | |
| | | <!-- 国际化按钮 --> |
| | | <ElDropdown |
| | | @command="changeLanguage" |
| | | popper-class="langDropDownStyle" |
| | | v-if="shouldShowLanguage" |
| | | > |
| | | <ArtIconButton icon="ri:translate-2" class="language-btn text-[19px]" /> |
| | | <template #dropdown> |
| | | <ElDropdownMenu> |
| | | <div v-for="item in languageOptions" :key="item.value" class="lang-btn-item"> |
| | | <ElDropdownItem |
| | | :command="item.value" |
| | | :class="{ 'is-selected': locale === item.value }" |
| | | > |
| | | <span class="menu-txt">{{ item.label }}</span> |
| | | <ArtSvgIcon icon="ri:check-fill" v-if="locale === item.value" /> |
| | | </ElDropdownItem> |
| | | </div> |
| | | </ElDropdownMenu> |
| | | </template> |
| | | </ElDropdown> |
| | | |
| | | <!-- 通知按钮 --> |
| | | <ArtIconButton |
| | | v-if="shouldShowNotification" |
| | | icon="ri:notification-2-line" |
| | | class="notice-button relative" |
| | | @click="visibleNotice" |
| | | > |
| | | <div class="absolute top-2 right-2 size-1.5 !bg-danger rounded-full"></div> |
| | | </ArtIconButton> |
| | | |
| | | <!-- 聊天按钮 --> |
| | | <ArtIconButton |
| | | v-if="shouldShowChat" |
| | | icon="ri:message-3-line" |
| | | class="chat-button relative" |
| | | @click="openChat" |
| | | > |
| | | <div class="breathing-dot absolute top-2 right-2 size-1.5 !bg-success rounded-full"></div> |
| | | </ArtIconButton> |
| | | |
| | | <!-- 设置按钮 --> |
| | | <div v-if="shouldShowSettings"> |
| | | <ElPopover :visible="showSettingGuide" placement="bottom-start" :width="190" :offset="0"> |
| | | <template #reference> |
| | | <div class="flex-cc"> |
| | | <ArtIconButton icon="ri:settings-line" class="setting-btn" @click="openSetting" /> |
| | | </div> |
| | | </template> |
| | | <template #default> |
| | | <p |
| | | >{{ $t('topBar.guide.title') |
| | | }}<span :style="{ color: systemThemeColor }"> {{ $t('topBar.guide.theme') }} </span |
| | | >、 <span :style="{ color: systemThemeColor }"> {{ $t('topBar.guide.menu') }} </span |
| | | >{{ $t('topBar.guide.description') }} |
| | | </p> |
| | | </template> |
| | | </ElPopover> |
| | | </div> |
| | | |
| | | <!-- 主题切换按钮 --> |
| | | <ArtIconButton |
| | | v-if="shouldShowThemeToggle" |
| | | @click="themeAnimation" |
| | | :icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'" |
| | | /> |
| | | |
| | | <!-- 用户头像、菜单 --> |
| | | <ArtUserMenu /> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 标签页 --> |
| | | <ArtWorkTab /> |
| | | |
| | | <!-- 通知 --> |
| | | <ArtNotification v-model:value="showNotice" ref="notice" /> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { languageOptions } from '@/locales' |
| | | |
| | | import { themeAnimation } from '@/utils/ui/animation' |
| | | |
| | | import { useI18n } from 'vue-i18n' |
| | | import { useRouter } from 'vue-router' |
| | | import { useFullscreen, useWindowSize } from '@vueuse/core' |
| | | import { MenuTypeEnum } from '@/enums/appEnum' |
| | | import { useSettingStore } from '@/store/modules/setting' |
| | | import { useUserStore } from '@/store/modules/user' |
| | | import { useMenuStore } from '@/store/modules/menu' |
| | | import { mittBus } from '@/utils/sys' |
| | | import { useCommon } from '@/hooks/core/useCommon' |
| | | import { useHeaderBar } from '@/hooks/core/useHeaderBar' |
| | | defineOptions({ name: 'ArtHeaderBar' }) |
| | | const isWindows = navigator.userAgent.includes('Windows') |
| | | const router = useRouter() |
| | | const { locale } = useI18n() |
| | | const { width } = useWindowSize() |
| | | const settingStore = useSettingStore() |
| | | const userStore = useUserStore() |
| | | const menuStore = useMenuStore() |
| | | const { |
| | | shouldShowMenuButton, |
| | | shouldShowRefreshButton, |
| | | shouldShowFastEnter, |
| | | shouldShowBreadcrumb, |
| | | shouldShowGlobalSearch, |
| | | shouldShowFullscreen, |
| | | shouldShowNotification, |
| | | shouldShowChat, |
| | | shouldShowLanguage, |
| | | shouldShowSettings, |
| | | shouldShowThemeToggle, |
| | | fastEnterMinWidth: headerBarFastEnterMinWidth |
| | | } = useHeaderBar() |
| | | const { menuOpen, systemThemeColor, showSettingGuide, menuType, isDark, tabStyle } = |
| | | storeToRefs(settingStore) |
| | | const { language } = storeToRefs(userStore) |
| | | const { menuList } = storeToRefs(menuStore) |
| | | const showNotice = ref(false) |
| | | const notice = ref(null) |
| | | const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT) |
| | | const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU) |
| | | const isTopMenu = computed(() => menuType.value === MenuTypeEnum.TOP) |
| | | const isTopLeftMenu = computed(() => menuType.value === MenuTypeEnum.TOP_LEFT) |
| | | const { isFullscreen, toggle: toggleFullscreen } = useFullscreen() |
| | | onMounted(() => { |
| | | initLanguage() |
| | | document.addEventListener('click', bodyCloseNotice) |
| | | }) |
| | | onUnmounted(() => { |
| | | document.removeEventListener('click', bodyCloseNotice) |
| | | }) |
| | | const toggleFullScreen = () => { |
| | | toggleFullscreen() |
| | | } |
| | | const visibleMenu = () => { |
| | | settingStore.setMenuOpen(!menuOpen.value) |
| | | } |
| | | const { homePath } = useCommon() |
| | | const { refresh } = useCommon() |
| | | const toHome = () => { |
| | | router.push(homePath.value) |
| | | } |
| | | const reload = (time = 0) => { |
| | | setTimeout(() => { |
| | | refresh() |
| | | }, time) |
| | | } |
| | | const initLanguage = () => { |
| | | locale.value = language.value |
| | | } |
| | | const changeLanguage = (lang) => { |
| | | if (locale.value === lang) return |
| | | locale.value = lang |
| | | userStore.setLanguage(lang) |
| | | reload(50) |
| | | } |
| | | const openSetting = () => { |
| | | mittBus.emit('openSetting') |
| | | if (showSettingGuide.value) { |
| | | settingStore.hideSettingGuide() |
| | | } |
| | | } |
| | | const openSearchDialog = () => { |
| | | mittBus.emit('openSearchDialog') |
| | | } |
| | | const bodyCloseNotice = (e) => { |
| | | if (!showNotice.value) return |
| | | const target = e.target |
| | | const isNoticeButton = target.closest('.notice-button') |
| | | const isNoticePanel = target.closest('.art-notification-panel') |
| | | if (!isNoticeButton && !isNoticePanel) { |
| | | showNotice.value = false |
| | | } |
| | | } |
| | | const visibleNotice = () => { |
| | | showNotice.value = !showNotice.value |
| | | } |
| | | const openChat = () => { |
| | | mittBus.emit('openChat') |
| | | } |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | /* Custom animations */ |
| | | @keyframes rotate180 { |
| | | 0% { |
| | | transform: rotate(0); |
| | | } |
| | | |
| | | 100% { |
| | | transform: rotate(180deg); |
| | | } |
| | | } |
| | | |
| | | @keyframes shake { |
| | | 0% { |
| | | transform: rotate(0); |
| | | } |
| | | |
| | | 25% { |
| | | transform: rotate(-5deg); |
| | | } |
| | | |
| | | 50% { |
| | | transform: rotate(5deg); |
| | | } |
| | | |
| | | 75% { |
| | | transform: rotate(-5deg); |
| | | } |
| | | |
| | | 100% { |
| | | transform: rotate(0); |
| | | } |
| | | } |
| | | |
| | | @keyframes expand { |
| | | 0% { |
| | | transform: scale(1); |
| | | } |
| | | |
| | | 50% { |
| | | transform: scale(1.1); |
| | | } |
| | | |
| | | 100% { |
| | | transform: scale(1); |
| | | } |
| | | } |
| | | |
| | | @keyframes shrink { |
| | | 0% { |
| | | transform: scale(1); |
| | | } |
| | | |
| | | 50% { |
| | | transform: scale(0.9); |
| | | } |
| | | |
| | | 100% { |
| | | transform: scale(1); |
| | | } |
| | | } |
| | | |
| | | @keyframes moveUp { |
| | | 0% { |
| | | transform: translateY(0); |
| | | } |
| | | |
| | | 50% { |
| | | transform: translateY(-3px); |
| | | } |
| | | |
| | | 100% { |
| | | transform: translateY(0); |
| | | } |
| | | } |
| | | |
| | | @keyframes breathing { |
| | | 0% { |
| | | opacity: 0.4; |
| | | transform: scale(0.9); |
| | | } |
| | | |
| | | 50% { |
| | | opacity: 1; |
| | | transform: scale(1.1); |
| | | } |
| | | |
| | | 100% { |
| | | opacity: 0.4; |
| | | transform: scale(0.9); |
| | | } |
| | | } |
| | | |
| | | /* Hover animation classes */ |
| | | .refresh-btn:hover :deep(.art-svg-icon) { |
| | | animation: rotate180 0.5s; |
| | | } |
| | | |
| | | .language-btn:hover :deep(.art-svg-icon) { |
| | | animation: moveUp 0.4s; |
| | | } |
| | | |
| | | .setting-btn:hover :deep(.art-svg-icon) { |
| | | animation: rotate180 0.5s; |
| | | } |
| | | |
| | | .full-screen-btn:hover :deep(.art-svg-icon) { |
| | | animation: expand 0.6s forwards; |
| | | } |
| | | |
| | | .exit-full-screen-btn:hover :deep(.art-svg-icon) { |
| | | animation: shrink 0.6s forwards; |
| | | } |
| | | |
| | | .notice-button:hover :deep(.art-svg-icon) { |
| | | animation: shake 0.5s ease-in-out; |
| | | } |
| | | |
| | | .chat-button:hover :deep(.art-svg-icon) { |
| | | animation: shake 0.5s ease-in-out; |
| | | } |
| | | |
| | | /* Breathing animation for chat dot */ |
| | | .breathing-dot { |
| | | animation: breathing 1.5s ease-in-out infinite; |
| | | } |
| | | |
| | | /* iPad breakpoint adjustments */ |
| | | @media screen and (width <= 768px) { |
| | | .logo2 { |
| | | display: block !important; |
| | | } |
| | | } |
| | | |
| | | @media screen and (width <= 640px) { |
| | | .btn-box { |
| | | width: 40px; |
| | | } |
| | | } |
| | | </style> |
| New file |
| | |
| | | <!-- 用户菜单 --> |
| | | <template> |
| | | <ElPopover |
| | | ref="userMenuPopover" |
| | | placement="bottom-end" |
| | | :width="240" |
| | | :hide-after="0" |
| | | :offset="10" |
| | | trigger="hover" |
| | | :show-arrow="false" |
| | | popper-class="user-menu-popover" |
| | | popper-style="padding: 5px 16px;" |
| | | > |
| | | <template #reference> |
| | | <img |
| | | class="size-8.5 mr-5 c-p rounded-full max-sm:w-6.5 max-sm:h-6.5 max-sm:mr-[16px]" |
| | | src="@imgs/user/avatar.webp" |
| | | alt="avatar" |
| | | /> |
| | | </template> |
| | | <template #default> |
| | | <div class="pt-3"> |
| | | <div class="flex-c pb-1 px-0"> |
| | | <img |
| | | class="w-10 h-10 mr-3 ml-0 overflow-hidden rounded-full float-left" |
| | | src="@imgs/user/avatar.webp" |
| | | /> |
| | | <div class="w-[calc(100%-60px)] h-full"> |
| | | <span class="block text-sm font-medium text-g-800 truncate">{{ |
| | | userInfo.userName |
| | | }}</span> |
| | | <span class="block mt-0.5 text-xs text-g-500 truncate">{{ userInfo.email }}</span> |
| | | </div> |
| | | </div> |
| | | <ul class="py-4 mt-3 border-t border-g-300/80"> |
| | | <li class="btn-item" @click="goPage('/system/user-center')"> |
| | | <ArtSvgIcon icon="ri:user-3-line" /> |
| | | <span>{{ $t('topBar.user.userCenter') }}</span> |
| | | </li> |
| | | <li class="btn-item" @click="toDocs()"> |
| | | <ArtSvgIcon icon="ri:book-2-line" /> |
| | | <span>{{ $t('topBar.user.docs') }}</span> |
| | | </li> |
| | | <li class="btn-item" @click="toGithub()"> |
| | | <ArtSvgIcon icon="ri:github-line" /> |
| | | <span>{{ $t('topBar.user.github') }}</span> |
| | | </li> |
| | | <li class="btn-item" @click="lockScreen()"> |
| | | <ArtSvgIcon icon="ri:lock-line" /> |
| | | <span>{{ $t('topBar.user.lockScreen') }}</span> |
| | | </li> |
| | | <div class="w-full h-px my-2 bg-g-300/80"></div> |
| | | <div class="log-out c-p" @click="loginOut"> |
| | | {{ $t('topBar.user.logout') }} |
| | | </div> |
| | | </ul> |
| | | </div> |
| | | </template> |
| | | </ElPopover> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useI18n } from 'vue-i18n' |
| | | import { useRouter } from 'vue-router' |
| | | import { ElMessageBox } from 'element-plus' |
| | | import { useUserStore } from '@/store/modules/user' |
| | | import { WEB_LINKS } from '@/utils/constants' |
| | | import { mittBus } from '@/utils/sys' |
| | | defineOptions({ name: 'ArtUserMenu' }) |
| | | const router = useRouter() |
| | | const { t } = useI18n() |
| | | const userStore = useUserStore() |
| | | const { getUserInfo: userInfo } = storeToRefs(userStore) |
| | | const userMenuPopover = ref() |
| | | const goPage = (path) => { |
| | | router.push(path) |
| | | } |
| | | const toDocs = () => { |
| | | window.open(WEB_LINKS.DOCS) |
| | | } |
| | | const toGithub = () => { |
| | | window.open(WEB_LINKS.GITHUB) |
| | | } |
| | | const lockScreen = () => { |
| | | mittBus.emit('openLockScreen') |
| | | } |
| | | const loginOut = () => { |
| | | closeUserMenu() |
| | | setTimeout(() => { |
| | | ElMessageBox.confirm(t('common.logOutTips'), t('common.tips'), { |
| | | confirmButtonText: t('common.confirm'), |
| | | cancelButtonText: t('common.cancel'), |
| | | customClass: 'login-out-dialog' |
| | | }).then(() => { |
| | | userStore.logOut() |
| | | }) |
| | | }, 200) |
| | | } |
| | | const closeUserMenu = () => { |
| | | setTimeout(() => { |
| | | userMenuPopover.value.hide() |
| | | }, 100) |
| | | } |
| | | </script> |
| | | |
| | | <style scoped> |
| | | @reference '@styles/core/tailwind.css'; |
| | | |
| | | @layer components { |
| | | .btn-item { |
| | | @apply flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0; |
| | | |
| | | span { |
| | | @apply text-sm; |
| | | } |
| | | |
| | | .art-svg-icon { |
| | | @apply mr-2 text-base; |
| | | } |
| | | |
| | | &:hover { |
| | | background-color: var(--art-gray-200); |
| | | } |
| | | } |
| | | } |
| | | |
| | | .log-out { |
| | | @apply py-1.5 |
| | | mt-5 |
| | | text-xs |
| | | text-center |
| | | border |
| | | border-g-400 |
| | | rounded-md |
| | | transition-all |
| | | duration-200 |
| | | hover:shadow-xl; |
| | | } |
| | | </style> |
| New file |
| | |
| | | <!-- 水平菜单 --> |
| | | <template> |
| | | <div class="flex-1 overflow-hidden"> |
| | | <ElMenu |
| | | :ellipsis="true" |
| | | mode="horizontal" |
| | | :default-active="routerPath" |
| | | :text-color="isDark ? 'var(--art-gray-800)' : 'var(--art-gray-700)'" |
| | | :popper-offset="-6" |
| | | background-color="transparent" |
| | | :show-timeout="50" |
| | | :hide-timeout="50" |
| | | popper-class="horizontal-menu-popper" |
| | | class="w-full border-none" |
| | | > |
| | | <HorizontalSubmenu |
| | | v-for="item in filteredMenuItems" |
| | | :key="item.path" |
| | | :item="item" |
| | | :isMobile="false" |
| | | :level="0" |
| | | /> |
| | | </ElMenu> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { useSettingStore } from '@/store/modules/setting' |
| | | defineOptions({ name: 'ArtHorizontalMenu' }) |
| | | const settingStore = useSettingStore() |
| | | const { isDark } = storeToRefs(settingStore) |
| | | const route = useRoute() |
| | | const props = defineProps({ |
| | | list: { required: false, default: () => [] } |
| | | }) |
| | | const filteredMenuItems = computed(() => { |
| | | return filterMenuItems(props.list) |
| | | }) |
| | | const routerPath = computed(() => String(route.meta.activePath || route.path)) |
| | | const filterMenuItems = (items) => { |
| | | return items |
| | | .filter((item) => { |
| | | if (item.meta.isHide) { |
| | | return false |
| | | } |
| | | if (item.children && item.children.length > 0) { |
| | | const filteredChildren = filterMenuItems(item.children) |
| | | return filteredChildren.length > 0 |
| | | } |
| | | return true |
| | | }) |
| | | .map((item) => ({ |
| | | ...item, |
| | | children: item.children ? filterMenuItems(item.children) : void 0 |
| | | })) |
| | | } |
| | | </script> |
| | | |
| | | <style scoped> |
| | | /* Remove el-menu bottom border */ |
| | | :deep(.el-menu) { |
| | | border-bottom: none !important; |
| | | } |
| | | |
| | | /* Remove default styles for first-level menu items */ |
| | | :deep(.el-menu-item[tabindex='0']) { |
| | | background-color: transparent !important; |
| | | border: none !important; |
| | | } |
| | | |
| | | /* Remove bottom border from submenu titles */ |
| | | :deep(.el-menu--horizontal .el-sub-menu__title) { |
| | | padding: 0 30px 0 10px !important; |
| | | border: 0 !important; |
| | | } |
| | | </style> |
| New file |
| | |
| | | <template> |
| | | <ElSubMenu v-if="hasChildren" :index="item.path || item.meta.title" class="!p-0"> |
| | | <template #title> |
| | | <ArtSvgIcon :icon="item.meta.icon" :color="theme?.iconColor" class="mr-1 text-lg" /> |
| | | <span class="text-md">{{ formatMenuTitle(item.meta.title) }}</span> |
| | | <div v-if="item.meta.showBadge" class="art-badge art-badge-horizontal" /> |
| | | <div v-if="item.meta.showTextBadge" class="art-text-badge"> |
| | | {{ item.meta.showTextBadge }} |
| | | </div> |
| | | </template> |
| | | |
| | | <!-- 递归调用自身处理子菜单 --> |
| | | <HorizontalSubmenu |
| | | v-for="child in filteredChildren" |
| | | :key="child.path" |
| | | :item="child" |
| | | :theme="theme" |
| | | :is-mobile="isMobile" |
| | | :level="level + 1" |
| | | @close="closeMenu" |
| | | /> |
| | | </ElSubMenu> |
| | | |
| | | <ElMenuItem |
| | | v-else-if="isNavigableRoute" |
| | | :index="item.path || item.meta.title" |
| | | @click="goPage(item)" |
| | | > |
| | | <ArtSvgIcon |
| | | :icon="item.meta.icon" |
| | | :color="theme?.iconColor" |
| | | class="mr-1 text-lg" |
| | | :style="{ color: theme.iconColor }" |
| | | /> |
| | | <span class="text-md">{{ formatMenuTitle(item.meta.title) }}</span> |
| | | <div |
| | | v-if="item.meta.showBadge" |
| | | class="art-badge" |
| | | :style="{ right: level === 0 ? '10px' : '20px' }" |
| | | /> |
| | | <div v-if="item.meta.showTextBadge && level !== 0" class="art-text-badge"> |
| | | {{ item.meta.showTextBadge }} |
| | | </div> |
| | | </ElMenuItem> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed } from 'vue' |
| | | import { handleMenuJump } from '@/utils/navigation' |
| | | const props = defineProps({ |
| | | item: { |
| | | type: Object, |
| | | required: true |
| | | }, |
| | | theme: { |
| | | type: Object, |
| | | default: () => ({}) |
| | | }, |
| | | isMobile: Boolean, |
| | | level: { |
| | | type: Number, |
| | | default: 0 |
| | | } |
| | | }) |
| | | const emit = defineEmits(['close']) |
| | | const filteredChildren = computed(() => { |
| | | return props.item.children?.filter((child) => !child.meta.isHide) || [] |
| | | }) |
| | | const isNavigableRoute = computed(() => { |
| | | return !!( |
| | | !props.item.meta.isHide && |
| | | ((props.item.path && props.item.path.trim()) || |
| | | props.item.meta.link || |
| | | props.item.meta.isIframe === true) && |
| | | (props.item.component || props.item.meta.link || props.item.meta.isIframe === true) |
| | | ) |
| | | }) |
| | | const hasChildren = computed(() => { |
| | | return filteredChildren.value.length > 0 |
| | | }) |
| | | const goPage = (item) => { |
| | | closeMenu() |
| | | handleMenuJump(item) |
| | | } |
| | | const closeMenu = () => { |
| | | emit('close') |
| | | } |
| | | </script> |
| | | |
| | | <style scoped> |
| | | :deep(.el-sub-menu__title .el-sub-menu__icon-arrow) { |
| | | right: 10px !important; |
| | | } |
| | | </style> |
| New file |
| | |
| | | <!-- 混合菜单 --> |
| | | <template> |
| | | <div class="relative box-border flex-c w-full overflow-hidden"> |
| | | <!-- 左侧滚动按钮 --> |
| | | <div v-show="showLeftArrow" class="button-arrow" @click="scroll('left')"> |
| | | <ElIcon> |
| | | <ArrowLeft /> |
| | | </ElIcon> |
| | | </div> |
| | | |
| | | <!-- 滚动容器 --> |
| | | <ElScrollbar |
| | | ref="scrollbarRef" |
| | | wrap-class="scrollbar-wrapper" |
| | | :horizontal="true" |
| | | @scroll="handleScroll" |
| | | @wheel="handleWheel" |
| | | > |
| | | <div class="box-border flex-c flex-shrink-0 flex-nowrap h-15 whitespace-nowrap"> |
| | | <template v-for="item in processedMenuList" :key="item.meta.title"> |
| | | <div |
| | | v-if="!item.meta.isHide" |
| | | class="menu-item relative flex-shrink-0 h-10 px-3 text-sm flex-c c-p hover:text-theme" |
| | | :class="{ |
| | | 'menu-item-active text-theme': item.isActive |
| | | }" |
| | | @click="handleMenuJump(item, true)" |
| | | > |
| | | <ArtSvgIcon |
| | | :icon="item.meta.icon" |
| | | class="text-lg text-g-700 dark:text-g-800 mr-1" |
| | | :class="item.isActive && '!text-theme'" |
| | | /> |
| | | <span |
| | | class="text-md text-g-700 dark:text-g-800" |
| | | :class="item.isActive && '!text-theme'" |
| | | > |
| | | {{ item.formattedTitle }} |
| | | </span> |
| | | <div v-if="item.meta.showBadge" class="art-badge art-badge-mixed" /> |
| | | </div> |
| | | </template> |
| | | </div> |
| | | </ElScrollbar> |
| | | |
| | | <!-- 右侧滚动按钮 --> |
| | | <div v-show="showRightArrow" class="button-arrow right-2" @click="scroll('right')"> |
| | | <ElIcon> |
| | | <ArrowRight /> |
| | | </ElIcon> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { handleMenuJump } from '@/utils/navigation' |
| | | |
| | | import { ref, computed, onMounted, nextTick } from 'vue' |
| | | import { useThrottleFn } from '@vueuse/core' |
| | | import { formatMenuTitle } from '@/utils/router' |
| | | defineOptions({ name: 'ArtMixedMenu' }) |
| | | const route = useRoute() |
| | | const props = defineProps({ |
| | | list: { required: false, default: () => [] } |
| | | }) |
| | | const scrollbarRef = ref() |
| | | const showLeftArrow = ref(false) |
| | | const showRightArrow = ref(false) |
| | | const SCROLL_CONFIG = { |
| | | /** 点击按钮时的滚动距离 */ |
| | | BUTTON_SCROLL_DISTANCE: 200, |
| | | /** 鼠标滚轮快速滚动时的步长 */ |
| | | WHEEL_FAST_STEP: 35, |
| | | /** 鼠标滚轮慢速滚动时的步长 */ |
| | | WHEEL_SLOW_STEP: 30, |
| | | /** 区分快慢滚动的阈值 */ |
| | | WHEEL_FAST_THRESHOLD: 100 |
| | | } |
| | | const currentActivePath = computed(() => { |
| | | return String(route.meta.activePath || route.path) |
| | | }) |
| | | const isMenuItemActive = (item) => { |
| | | const activePath = currentActivePath.value |
| | | if (item.children?.length) { |
| | | return item.children.some((child) => { |
| | | if (child.children?.length) { |
| | | return isMenuItemActive(child) |
| | | } |
| | | return child.path === activePath |
| | | }) |
| | | } |
| | | return item.path === activePath |
| | | } |
| | | const processedMenuList = computed(() => { |
| | | return props.list.map((item) => ({ |
| | | ...item, |
| | | isActive: isMenuItemActive(item), |
| | | formattedTitle: formatMenuTitle(item.meta.title) |
| | | })) |
| | | }) |
| | | const handleScrollCore = () => { |
| | | if (!scrollbarRef.value?.wrapRef) return |
| | | const { scrollLeft, scrollWidth, clientWidth } = scrollbarRef.value.wrapRef |
| | | showLeftArrow.value = scrollLeft > 0 |
| | | showRightArrow.value = scrollLeft + clientWidth < scrollWidth |
| | | } |
| | | const handleScroll = useThrottleFn(handleScrollCore, 16) |
| | | const scroll = (direction) => { |
| | | if (!scrollbarRef.value?.wrapRef) return |
| | | const currentScroll = scrollbarRef.value.wrapRef.scrollLeft |
| | | const targetScroll = |
| | | direction === 'left' |
| | | ? currentScroll - SCROLL_CONFIG.BUTTON_SCROLL_DISTANCE |
| | | : currentScroll + SCROLL_CONFIG.BUTTON_SCROLL_DISTANCE |
| | | scrollbarRef.value.wrapRef.scrollTo({ |
| | | left: targetScroll, |
| | | behavior: 'smooth' |
| | | }) |
| | | } |
| | | const handleWheel = (event) => { |
| | | event.preventDefault() |
| | | event.stopPropagation() |
| | | if (!scrollbarRef.value?.wrapRef) return |
| | | const { wrapRef } = scrollbarRef.value |
| | | const { scrollLeft, scrollWidth, clientWidth } = wrapRef |
| | | const scrollStep = |
| | | Math.abs(event.deltaY) > SCROLL_CONFIG.WHEEL_FAST_THRESHOLD |
| | | ? SCROLL_CONFIG.WHEEL_FAST_STEP |
| | | : SCROLL_CONFIG.WHEEL_SLOW_STEP |
| | | const scrollDelta = event.deltaY > 0 ? scrollStep : -scrollStep |
| | | const targetScroll = Math.max(0, Math.min(scrollLeft + scrollDelta, scrollWidth - clientWidth)) |
| | | wrapRef.scrollLeft = targetScroll |
| | | handleScrollCore() |
| | | } |
| | | const initScrollState = () => { |
| | | nextTick(() => { |
| | | handleScrollCore() |
| | | }) |
| | | } |
| | | onMounted(initScrollState) |
| | | </script> |
| | | |
| | | <style scoped> |
| | | @reference '@styles/core/tailwind.css'; |
| | | |
| | | .button-arrow { |
| | | @apply absolute |
| | | top-1/2 |
| | | z-2 |
| | | flex |
| | | items-center |
| | | justify-center |
| | | size-7.5 |
| | | text-g-600 |
| | | cursor-pointer |
| | | rounded |
| | | transition-all |
| | | duration-300 |
| | | -translate-y-1/2 |
| | | hover:text-g-900 |
| | | hover:bg-g-200; |
| | | } |
| | | </style> |
| | | |
| | | <style scoped> |
| | | :deep(.el-scrollbar__bar.is-horizontal) { |
| | | bottom: 5px; |
| | | display: none; |
| | | height: 2px; |
| | | } |
| | | |
| | | :deep(.scrollbar-wrapper) { |
| | | flex: 1; |
| | | min-width: 0; |
| | | margin: 0 50px 0 30px; |
| | | } |
| | | |
| | | .menu-item-active::after { |
| | | position: absolute; |
| | | right: 0; |
| | | bottom: 0; |
| | | left: 0; |
| | | width: 40px; |
| | | height: 2px; |
| | | margin: auto; |
| | | content: ''; |
| | | background-color: var(--theme-color); |
| | | } |
| | | |
| | | @media (width <= 1440px) { |
| | | :deep(.scrollbar-wrapper) { |
| | | margin: 0 45px; |
| | | } |
| | | } |
| | | </style> |
| New file |
| | |
| | | <!-- 左侧菜单 或 双列菜单 --> |
| | | <template> |
| | | <div |
| | | class="layout-sidebar" |
| | | v-if="showLeftMenu || isDualMenu" |
| | | :class="{ 'no-border': menuList.length === 0 }" |
| | | > |
| | | <!-- 双列菜单(左侧) --> |
| | | <div |
| | | v-if="isDualMenu" |
| | | class="dual-menu-left" |
| | | :style="{ width: dualMenuShowText ? '80px' : '64px', background: getMenuTheme.background }" |
| | | > |
| | | <ArtLogo class="logo" @click="navigateToHome" /> |
| | | |
| | | <ElScrollbar style="height: calc(100% - 135px)"> |
| | | <ul> |
| | | <li v-for="menu in firstLevelMenus" :key="menu.path" @click="handleMenuJump(menu, true)"> |
| | | <ElTooltip |
| | | class="box-item" |
| | | effect="dark" |
| | | :content="$t(menu.meta.title)" |
| | | placement="right" |
| | | :offset="15" |
| | | :hide-after="0" |
| | | :disabled="dualMenuShowText" |
| | | > |
| | | <div |
| | | :class="{ |
| | | 'is-active': menu.meta.isFirstLevel |
| | | ? menu.path === route.path |
| | | : menu.path === firstLevelMenuPath |
| | | }" |
| | | :style="{ |
| | | height: dualMenuShowText ? '60px' : '46px' |
| | | }" |
| | | > |
| | | <ArtSvgIcon |
| | | class="menu-icon text-g-700 dark:text-g-800" |
| | | :icon="menu.meta.icon" |
| | | :style="{ |
| | | marginBottom: dualMenuShowText ? '5px' : '0' |
| | | }" |
| | | /> |
| | | <span v-if="dualMenuShowText" class="text-md text-g-700"> |
| | | {{ $t(menu.meta.title) }} |
| | | </span> |
| | | <div v-if="menu.meta.showBadge" class="art-badge art-badge-dual" /> |
| | | </div> |
| | | </ElTooltip> |
| | | </li> |
| | | </ul> |
| | | </ElScrollbar> |
| | | |
| | | <ArtIconButton |
| | | class="switch-btn size-10" |
| | | icon="ri:arrow-left-right-fill" |
| | | @click="toggleDualMenuMode" |
| | | /> |
| | | </div> |
| | | |
| | | <!-- 左侧菜单 || 双列菜单(右侧) --> |
| | | <div |
| | | v-show="menuList.length > 0" |
| | | class="menu-left" |
| | | :class="`menu-left-${getMenuTheme.theme} menu-left-${!menuOpen ? 'close' : 'open'}`" |
| | | :style="{ background: getMenuTheme.background }" |
| | | > |
| | | <!-- Logo、系统名称 --> |
| | | <div |
| | | class="header" |
| | | @click="navigateToHome" |
| | | :style="{ |
| | | background: getMenuTheme.background |
| | | }" |
| | | > |
| | | <ArtLogo v-if="!isDualMenu" class="logo" /> |
| | | |
| | | <p |
| | | :class="{ 'is-dual-menu-name': isDualMenu }" |
| | | :style="{ |
| | | color: getMenuTheme.systemNameColor, |
| | | opacity: !menuOpen ? 0 : 1 |
| | | }" |
| | | > |
| | | {{ AppConfig.systemInfo.name }} |
| | | </p> |
| | | </div> |
| | | <ElScrollbar :style="scrollbarStyle"> |
| | | <ElMenu |
| | | :class="'el-menu-' + getMenuTheme.theme" |
| | | :collapse="!menuOpen" |
| | | :default-active="routerPath" |
| | | :text-color="getMenuTheme.textColor" |
| | | :unique-opened="uniqueOpened" |
| | | :background-color="getMenuTheme.background" |
| | | :default-openeds="defaultOpenedMenus" |
| | | :popper-class="`menu-left-popper menu-left-${getMenuTheme.theme}-popper`" |
| | | :show-timeout="50" |
| | | :hide-timeout="50" |
| | | > |
| | | <SidebarSubmenu |
| | | :list="menuList" |
| | | :isMobile="isMobileMode" |
| | | :theme="getMenuTheme" |
| | | @close="handleMenuClose" |
| | | /> |
| | | </ElMenu> |
| | | </ElScrollbar> |
| | | |
| | | <!-- 双列菜单右侧折叠按钮 --> |
| | | <div class="dual-menu-collapse-btn" v-if="isDualMenu" @click="toggleMenuVisibility"> |
| | | <ArtSvgIcon |
| | | class="text-g-500/70" |
| | | :icon="menuOpen ? 'ri:arrow-left-wide-fill' : 'ri:arrow-right-wide-fill'" |
| | | /> |
| | | </div> |
| | | |
| | | <div |
| | | class="menu-model" |
| | | @click="toggleMenuVisibility" |
| | | :style="{ |
| | | opacity: !menuOpen ? 0 : 1, |
| | | transform: showMobileModal ? 'scale(1)' : 'scale(0)' |
| | | }" |
| | | /> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import AppConfig from '@/config' |
| | | import { handleMenuJump } from '@/utils/navigation' |
| | | |
| | | import SidebarSubmenu from './widget/SidebarSubmenu.vue' |
| | | |
| | | import { useSettingStore } from '@/store/modules/setting' |
| | | import { MenuTypeEnum, MenuWidth } from '@/enums/appEnum' |
| | | import { useMenuStore } from '@/store/modules/menu' |
| | | import { isIframe } from '@/utils/navigation' |
| | | import { useCommon } from '@/hooks/core/useCommon' |
| | | import { useWindowSize, useTimeoutFn } from '@vueuse/core' |
| | | defineOptions({ name: 'ArtSidebarMenu' }) |
| | | const MOBILE_BREAKPOINT = 800 |
| | | const ANIMATION_DELAY = 350 |
| | | const MENU_CLOSE_WIDTH = MenuWidth.CLOSE |
| | | const route = useRoute() |
| | | const router = useRouter() |
| | | const settingStore = useSettingStore() |
| | | const { getMenuOpenWidth, menuType, uniqueOpened, dualMenuShowText, menuOpen, getMenuTheme } = |
| | | storeToRefs(settingStore) |
| | | const defaultOpenedMenus = ref([]) |
| | | const isMobileMode = ref(false) |
| | | const showMobileModal = ref(false) |
| | | const { width } = useWindowSize() |
| | | const menuopenwidth = computed(() => getMenuOpenWidth.value) |
| | | const menuclosewidth = computed(() => MENU_CLOSE_WIDTH) |
| | | const isTopLeftMenu = computed(() => menuType.value === MenuTypeEnum.TOP_LEFT) |
| | | const showLeftMenu = computed( |
| | | () => menuType.value === MenuTypeEnum.LEFT || menuType.value === MenuTypeEnum.TOP_LEFT |
| | | ) |
| | | const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU) |
| | | const isMobileScreen = computed(() => width.value < MOBILE_BREAKPOINT) |
| | | const firstLevelMenuPath = computed(() => route.matched[0]?.path) |
| | | const routerPath = computed(() => String(route.meta.activePath || route.path)) |
| | | const firstLevelMenus = computed(() => { |
| | | return useMenuStore().menuList.filter((menu) => !menu.meta.isHide) |
| | | }) |
| | | const menuList = computed(() => { |
| | | const menuStore = useMenuStore() |
| | | const allMenus = menuStore.menuList |
| | | if (!isTopLeftMenu.value && !isDualMenu.value) { |
| | | return allMenus |
| | | } |
| | | if (isIframe(route.path)) { |
| | | return findIframeMenuList(route.path, allMenus) |
| | | } |
| | | if (route.meta.isFirstLevel) { |
| | | return [] |
| | | } |
| | | const currentTopPath = `/${route.path.split('/')[1]}` |
| | | const currentMenu = allMenus.find((menu) => menu.path === currentTopPath) |
| | | return currentMenu?.children ?? [] |
| | | }) |
| | | const scrollbarStyle = computed(() => { |
| | | const isCollapsed = isDualMenu.value && !menuOpen.value |
| | | return { |
| | | transform: isCollapsed ? 'translateY(-50px)' : 'translateY(0)', |
| | | height: isCollapsed ? 'calc(100% + 50px)' : 'calc(100% - 60px)', |
| | | transition: 'transform 0.3s ease' |
| | | } |
| | | }) |
| | | const { start: delayHideMobileModal } = useTimeoutFn( |
| | | () => { |
| | | showMobileModal.value = false |
| | | }, |
| | | ANIMATION_DELAY, |
| | | { immediate: false } |
| | | ) |
| | | const findIframeMenuList = (currentPath, menuList2) => { |
| | | const hasPath = (items) => { |
| | | for (const item of items) { |
| | | if (item.path === currentPath) { |
| | | return true |
| | | } |
| | | if (item.children && hasPath(item.children)) { |
| | | return true |
| | | } |
| | | } |
| | | return false |
| | | } |
| | | for (const menu of menuList2) { |
| | | if (menu.children && hasPath(menu.children)) { |
| | | return menu.children |
| | | } |
| | | } |
| | | return [] |
| | | } |
| | | const { homePath } = useCommon() |
| | | const navigateToHome = () => { |
| | | router.push(homePath.value) |
| | | } |
| | | const toggleMenuVisibility = () => { |
| | | settingStore.setMenuOpen(!menuOpen.value) |
| | | if (isMobileScreen.value) { |
| | | if (!menuOpen.value) { |
| | | showMobileModal.value = true |
| | | } else { |
| | | delayHideMobileModal() |
| | | } |
| | | } |
| | | } |
| | | const handleMenuClose = () => { |
| | | if (isMobileScreen.value) { |
| | | settingStore.setMenuOpen(false) |
| | | delayHideMobileModal() |
| | | } |
| | | } |
| | | const toggleDualMenuMode = () => { |
| | | settingStore.setDualMenuShowText(!dualMenuShowText.value) |
| | | } |
| | | watch(width, (newWidth) => { |
| | | if (newWidth < MOBILE_BREAKPOINT) { |
| | | settingStore.setMenuOpen(false) |
| | | if (!menuOpen.value) { |
| | | showMobileModal.value = false |
| | | } |
| | | } else { |
| | | showMobileModal.value = false |
| | | } |
| | | }) |
| | | watch(menuOpen, (isMenuOpen) => { |
| | | if (!isMobileScreen.value) { |
| | | showMobileModal.value = false |
| | | } else { |
| | | if (isMenuOpen) { |
| | | showMobileModal.value = true |
| | | } else { |
| | | delayHideMobileModal() |
| | | } |
| | | } |
| | | }) |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | @use './style'; |
| | | </style> |
| | | |
| | | <style lang="scss"> |
| | | @use './theme'; |
| | | |
| | | .layout-sidebar { |
| | | // 展开的宽度 |
| | | .el-menu:not(.el-menu--collapse) { |
| | | width: v-bind(menuopenwidth); |
| | | } |
| | | |
| | | // 折叠后宽度 |
| | | .el-menu--collapse { |
| | | width: v-bind(menuclosewidth); |
| | | } |
| | | } |
| | | </style> |
| New file |
| | |
| | | .layout-sidebar { |
| | | display: flex; |
| | | height: 100vh; |
| | | user-select: none; |
| | | scrollbar-width: none; |
| | | border-right: 1px solid var(--art-card-border); |
| | | |
| | | &.no-border { |
| | | border-right: none !important; |
| | | } |
| | | |
| | | // 自定义滚动条宽度 |
| | | :deep(.el-scrollbar__bar.is-vertical) { |
| | | width: 4px; |
| | | } |
| | | |
| | | :deep(.el-scrollbar__thumb) { |
| | | right: -2px; |
| | | background-color: #ccc; |
| | | border-radius: 2px; |
| | | } |
| | | |
| | | .dual-menu-left { |
| | | position: relative; |
| | | width: 80px; |
| | | height: 100%; |
| | | border-right: 1px solid var(--art-card-border) !important; |
| | | transition: width 0.25s; |
| | | |
| | | .logo { |
| | | margin: auto; |
| | | margin-top: 12px; |
| | | margin-bottom: 3px; |
| | | cursor: pointer; |
| | | } |
| | | |
| | | ul { |
| | | li { |
| | | > div { |
| | | position: relative; |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | justify-content: center; |
| | | margin: 8px; |
| | | overflow: hidden; |
| | | text-align: center; |
| | | cursor: pointer; |
| | | border-radius: 5px; |
| | | |
| | | .art-svg-icon { |
| | | display: block; |
| | | margin: 0 auto; |
| | | font-size: 20px; |
| | | } |
| | | |
| | | span { |
| | | display: -webkit-box; |
| | | width: 100%; |
| | | overflow: hidden; |
| | | font-size: 12px; |
| | | text-overflow: ellipsis; |
| | | -webkit-line-clamp: 1; |
| | | line-clamp: 1; |
| | | -webkit-box-orient: vertical; |
| | | } |
| | | |
| | | &.is-active { |
| | | background: var(--el-color-primary-light-9); |
| | | |
| | | .art-svg-icon, |
| | | span { |
| | | color: var(--theme-color) !important; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | .switch-btn { |
| | | position: absolute; |
| | | right: 0; |
| | | bottom: 15px; |
| | | left: 0; |
| | | margin: auto; |
| | | } |
| | | } |
| | | |
| | | .menu-left { |
| | | position: relative; |
| | | box-sizing: border-box; |
| | | height: 100vh; |
| | | |
| | | @media only screen and (width <= 640px) { |
| | | height: 100dvh; |
| | | } |
| | | |
| | | .el-menu { |
| | | height: 100%; |
| | | } |
| | | |
| | | &:hover { |
| | | .dual-menu-collapse-btn { |
| | | opacity: 1 !important; |
| | | } |
| | | } |
| | | |
| | | .dual-menu-collapse-btn { |
| | | position: absolute; |
| | | top: 50%; |
| | | right: -11px; |
| | | z-index: 10; |
| | | width: 11px; |
| | | height: 50px; |
| | | cursor: pointer; |
| | | background-color: var(--default-box-color); |
| | | border: 1px solid var(--art-card-border); |
| | | border-radius: 0 15px 15px 0; |
| | | opacity: 0; |
| | | transition: opacity 0.2s; |
| | | transform: translateY(-50%); |
| | | |
| | | &:hover { |
| | | .art-svg-icon { |
| | | color: var(--art-gray-800) !important; |
| | | } |
| | | } |
| | | |
| | | .art-svg-icon { |
| | | position: absolute; |
| | | top: 0; |
| | | bottom: 0; |
| | | left: -4px; |
| | | margin: auto; |
| | | transition: all 0.3s; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .header { |
| | | position: relative; |
| | | box-sizing: border-box; |
| | | display: flex; |
| | | align-items: center; |
| | | width: 100%; |
| | | height: 60px; |
| | | overflow: hidden; |
| | | line-height: 60px; |
| | | cursor: pointer; |
| | | |
| | | .logo { |
| | | margin-left: 22px; |
| | | } |
| | | |
| | | p { |
| | | position: absolute; |
| | | top: 0; |
| | | bottom: 0; |
| | | left: 58px; |
| | | box-sizing: border-box; |
| | | margin-left: 10px; |
| | | font-size: 18px; |
| | | |
| | | &.is-dual-menu-name { |
| | | left: 25px; |
| | | margin: auto; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .el-menu { |
| | | box-sizing: border-box; |
| | | // 防止菜单内的滚动影响整个页面滚动 |
| | | overscroll-behavior: contain; |
| | | border-right: 0; |
| | | scrollbar-width: none; |
| | | -ms-scroll-chaining: contain; |
| | | |
| | | &::-webkit-scrollbar { |
| | | width: 0 !important; |
| | | } |
| | | } |
| | | |
| | | .menu-model { |
| | | display: none; |
| | | } |
| | | } |
| | | |
| | | @media only screen and (width <= 800px) { |
| | | .layout-sidebar { |
| | | width: 0; |
| | | |
| | | .header { |
| | | height: 50px; |
| | | line-height: 50px; |
| | | } |
| | | |
| | | .el-menu { |
| | | height: calc(100vh - 60px); |
| | | } |
| | | |
| | | .el-menu--collapse { |
| | | width: 0; |
| | | } |
| | | |
| | | // 折叠状态下的header样式 |
| | | .menu-left-close .header { |
| | | .logo { |
| | | display: none; |
| | | } |
| | | |
| | | p { |
| | | left: 16px; |
| | | font-size: 0; |
| | | opacity: 0 !important; |
| | | } |
| | | } |
| | | |
| | | .menu-model { |
| | | position: fixed; |
| | | top: 0; |
| | | left: 0; |
| | | z-index: -1; |
| | | display: block; |
| | | width: 100%; |
| | | height: 100vh; |
| | | background: rgba($color: #000, $alpha: 50%); |
| | | transition: opacity 0.2s ease-in-out; |
| | | } |
| | | } |
| | | } |
| | | |
| | | @media only screen and (width <= 640px) { |
| | | .layout-sidebar { |
| | | border-right: 0 !important; |
| | | } |
| | | } |
| | | |
| | | .dark { |
| | | .layout-sidebar { |
| | | border-right: 1px solid rgb(255 255 255 / 13%); |
| | | |
| | | :deep(.el-scrollbar__thumb) { |
| | | background-color: #777; |
| | | } |
| | | |
| | | .dual-menu-left { |
| | | border-right: 1px solid rgb(255 255 255 / 9%) !important; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | @use '@styles/core/mixin.scss' as *; |
| | | |
| | | // 菜单样式变量 |
| | | $menu-height: 42px; |
| | | $menu-icon-size: 20px; |
| | | $menu-font-size: 14px; |
| | | $hover-bg-color: var(--art-gray-200); |
| | | $popup-menu-height: 40px; |
| | | $popup-menu-padding: 8px; |
| | | $popup-menu-margin: 5px; |
| | | $popup-menu-radius: 6px; |
| | | |
| | | // 通用菜单项样式 |
| | | @mixin menu-item-base { |
| | | width: calc(100% - 16px); |
| | | margin-left: 8px; |
| | | border-radius: 6px; |
| | | |
| | | .menu-icon { |
| | | margin-left: -7px; |
| | | } |
| | | } |
| | | |
| | | // 通用 hover 样式 |
| | | @mixin menu-hover($bg-color) { |
| | | .el-sub-menu__title:hover, |
| | | .el-menu-item:not(.is-active):hover { |
| | | background: $bg-color !important; |
| | | } |
| | | } |
| | | |
| | | // 通用选中样式 |
| | | @mixin menu-active($color, $bg-color, $icon-color: var(--theme-color)) { |
| | | .el-menu-item.is-active { |
| | | color: $color !important; |
| | | background-color: $bg-color; |
| | | |
| | | .menu-icon { |
| | | .art-svg-icon { |
| | | color: $icon-color !important; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 弹窗菜单项样式 |
| | | @mixin popup-menu-item { |
| | | height: $popup-menu-height; |
| | | margin-bottom: $popup-menu-margin; |
| | | border-radius: $popup-menu-radius; |
| | | |
| | | .menu-icon { |
| | | margin-right: 5px; |
| | | } |
| | | |
| | | &:last-of-type { |
| | | margin-bottom: 0; |
| | | } |
| | | } |
| | | |
| | | // 主题菜单通用样式(合并 design 和 dark 主题的共同逻辑) |
| | | @mixin theme-menu-base { |
| | | .el-sub-menu__title, |
| | | .el-menu-item { |
| | | @include menu-item-base; |
| | | } |
| | | } |
| | | |
| | | // 弹窗菜单通用样式 |
| | | @mixin popup-menu-base($hover-bg, $active-color, $active-bg) { |
| | | .el-menu--popup { |
| | | padding: $popup-menu-padding; |
| | | |
| | | .el-sub-menu__title:hover, |
| | | .el-menu-item:hover { |
| | | background-color: $hover-bg !important; |
| | | border-radius: $popup-menu-radius; |
| | | } |
| | | |
| | | .el-menu-item { |
| | | @include popup-menu-item; |
| | | |
| | | &.is-active { |
| | | color: $active-color !important; |
| | | background-color: $active-bg !important; |
| | | } |
| | | } |
| | | |
| | | .el-sub-menu { |
| | | @include popup-menu-item; |
| | | |
| | | height: $popup-menu-height !important; |
| | | |
| | | .el-sub-menu__title { |
| | | height: $popup-menu-height !important; |
| | | border-radius: $popup-menu-radius; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | .layout-sidebar { |
| | | // ---------------------- Modify default style ---------------------- |
| | | |
| | | // 菜单折叠样式 |
| | | .menu-left-close { |
| | | .header { |
| | | .logo { |
| | | margin: 0 auto; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 菜单图标 |
| | | .menu-icon { |
| | | margin-right: 8px; |
| | | font-size: $menu-icon-size; |
| | | } |
| | | |
| | | // 菜单高度 |
| | | .el-sub-menu__title, |
| | | .el-menu-item { |
| | | height: $menu-height !important; |
| | | margin-bottom: 4px; |
| | | line-height: $menu-height !important; |
| | | |
| | | span { |
| | | font-size: $menu-font-size !important; |
| | | |
| | | @include ellipsis(); |
| | | } |
| | | } |
| | | |
| | | // 右侧箭头 |
| | | .el-sub-menu__icon-arrow { |
| | | width: 13px !important; |
| | | font-size: 13px !important; |
| | | } |
| | | |
| | | // 菜单折叠 |
| | | .el-menu--collapse { |
| | | .el-sub-menu.is-active { |
| | | .el-sub-menu__title { |
| | | .menu-icon { |
| | | .art-svg-icon { |
| | | // 选中菜单图标颜色 |
| | | color: var(--theme-color) !important; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | // ---------------------- Design theme menu ---------------------- |
| | | .el-menu-design { |
| | | @include theme-menu-base; |
| | | @include menu-active(var(--theme-color), var(--el-color-primary-light-9)); |
| | | @include menu-hover($hover-bg-color); |
| | | |
| | | .el-sub-menu__icon-arrow { |
| | | color: var(--art-gray-600); |
| | | } |
| | | } |
| | | |
| | | // ---------------------- Dark theme menu ---------------------- |
| | | .el-menu-dark { |
| | | @include theme-menu-base; |
| | | @include menu-active(#fff, #27282d, #fff); |
| | | @include menu-hover(#0f1015); |
| | | |
| | | .el-sub-menu__icon-arrow { |
| | | color: var(--art-gray-400); |
| | | } |
| | | } |
| | | |
| | | // ---------------------- Light theme menu ---------------------- |
| | | .el-menu-light { |
| | | .el-sub-menu__title, |
| | | .el-menu-item { |
| | | .menu-icon { |
| | | margin-left: 1px; |
| | | } |
| | | } |
| | | |
| | | .el-menu-item.is-active { |
| | | background-color: var(--el-color-primary-light-9); |
| | | |
| | | .art-svg-icon { |
| | | color: var(--theme-color) !important; |
| | | } |
| | | |
| | | &::before { |
| | | position: absolute; |
| | | top: 0; |
| | | left: 0; |
| | | width: 4px; |
| | | height: 100%; |
| | | content: ''; |
| | | background: var(--theme-color); |
| | | } |
| | | } |
| | | |
| | | @include menu-hover($hover-bg-color); |
| | | |
| | | .el-sub-menu__icon-arrow { |
| | | color: var(--art-gray-600); |
| | | } |
| | | } |
| | | } |
| | | |
| | | @media only screen and (width <= 640px) { |
| | | .layout-sidebar { |
| | | .el-menu-design { |
| | | > .el-sub-menu { |
| | | margin-left: 0; |
| | | } |
| | | |
| | | .el-sub-menu { |
| | | width: 100% !important; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 菜单折叠 hover 弹窗样式(浅色主题) |
| | | .el-menu--vertical, |
| | | .el-menu--popup-container { |
| | | @include popup-menu-base(var(--art-gray-200), var(--art-gray-900), var(--art-gray-200)); |
| | | } |
| | | |
| | | // 暗黑模式菜单样式 |
| | | .dark { |
| | | .el-menu--vertical, |
| | | .el-menu--popup-container { |
| | | @include popup-menu-base(var(--art-gray-200), var(--art-gray-900), #292a2e); |
| | | } |
| | | |
| | | .layout-sidebar { |
| | | // 图标颜色、文字颜色 |
| | | .menu-icon .art-svg-icon, |
| | | .menu-name { |
| | | color: var(--art-gray-800) !important; |
| | | } |
| | | |
| | | // 选中的文字颜色跟图标颜色 |
| | | .el-menu-item.is-active { |
| | | span, |
| | | .menu-icon .art-svg-icon { |
| | | color: var(--theme-color) !important; |
| | | } |
| | | } |
| | | |
| | | // 右侧箭头颜色 |
| | | .el-sub-menu__icon-arrow { |
| | | color: #fff; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | <template> |
| | | <template v-for="(item, index) in filteredMenuItems" :key="getUniqueKey(item, index)"> |
| | | <ElSubMenu v-if="hasChildren(item)" :index="item.path || item.meta.title" :level="level"> |
| | | <template #title> |
| | | <div class="menu-icon flex-cc"> |
| | | <ArtSvgIcon |
| | | :icon="item.meta.icon" |
| | | :color="theme?.iconColor" |
| | | :style="{ color: theme.iconColor }" |
| | | /> |
| | | </div> |
| | | <span class="menu-name"> |
| | | {{ formatMenuTitle(item.meta.title) }} |
| | | </span> |
| | | <div v-if="item.meta.showBadge" class="art-badge" style="right: 10px" /> |
| | | </template> |
| | | |
| | | <SidebarSubmenu |
| | | :list="item.children" |
| | | :is-mobile="isMobile" |
| | | :level="level + 1" |
| | | :theme="theme" |
| | | @close="closeMenu" |
| | | /> |
| | | </ElSubMenu> |
| | | |
| | | <ElMenuItem |
| | | v-else |
| | | :index="isExternalLink(item) ? undefined : item.path || item.meta.title" |
| | | :level-item="level + 1" |
| | | @click="goPage(item)" |
| | | > |
| | | <div class="menu-icon flex-cc"> |
| | | <ArtSvgIcon |
| | | :icon="item.meta.icon" |
| | | :color="theme?.iconColor" |
| | | :style="{ color: theme.iconColor }" |
| | | /> |
| | | </div> |
| | | <div |
| | | v-show="item.meta.showBadge && level === 0 && !menuOpen" |
| | | class="art-badge" |
| | | style="right: 5px" |
| | | /> |
| | | |
| | | <template #title> |
| | | <span class="menu-name"> |
| | | {{ formatMenuTitle(item.meta.title) }} |
| | | </span> |
| | | <div v-if="item.meta.showBadge" class="art-badge" /> |
| | | <div v-if="item.meta.showTextBadge && (level > 0 || menuOpen)" class="art-text-badge"> |
| | | {{ item.meta.showTextBadge }} |
| | | </div> |
| | | </template> |
| | | </ElMenuItem> |
| | | </template> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { formatMenuTitle } from '@/utils/router' |
| | | |
| | | import { computed } from 'vue' |
| | | import { handleMenuJump } from '@/utils/navigation' |
| | | import { useSettingStore } from '@/store/modules/setting' |
| | | const props = defineProps({ |
| | | title: { required: false, default: '' }, |
| | | list: { required: false, default: () => [] }, |
| | | theme: { required: false, default: () => ({}) }, |
| | | isMobile: { required: false, default: false }, |
| | | level: { required: false, default: 0 } |
| | | }) |
| | | const emit = defineEmits(['close']) |
| | | const settingStore = useSettingStore() |
| | | const { menuOpen } = storeToRefs(settingStore) |
| | | const filteredMenuItems = computed(() => filterRoutes(props.list)) |
| | | const goPage = (item) => { |
| | | closeMenu() |
| | | handleMenuJump(item) |
| | | } |
| | | const closeMenu = () => { |
| | | emit('close') |
| | | } |
| | | const isNavigableRoute = (item) => { |
| | | return !!( |
| | | !item.meta.isHide && |
| | | ((item.path && item.path.trim()) || item.meta.link || item.meta.isIframe === true) && |
| | | (item.component || item.meta.link || item.meta.isIframe === true) |
| | | ) |
| | | } |
| | | const filterRoutes = (items) => { |
| | | return items |
| | | .filter((item) => { |
| | | if (item.meta.isHide) { |
| | | return false |
| | | } |
| | | if (item.children && item.children.length > 0) { |
| | | const filteredChildren = filterRoutes(item.children) |
| | | return filteredChildren.length > 0 || isNavigableRoute(item) |
| | | } |
| | | return isNavigableRoute(item) |
| | | }) |
| | | .map((item) => ({ |
| | | ...item, |
| | | children: item.children ? filterRoutes(item.children) : void 0 |
| | | })) |
| | | } |
| | | const hasChildren = (item) => { |
| | | if (!item.children || item.children.length === 0) { |
| | | return false |
| | | } |
| | | const filteredChildren = filterRoutes(item.children) |
| | | return filteredChildren.length > 0 |
| | | } |
| | | const isExternalLink = (item) => { |
| | | return !!(item.meta.link && !item.meta.isIframe) |
| | | } |
| | | const getUniqueKey = (item, index) => { |
| | | return `${item.path || item.meta.title || 'menu'}-${props.level}-${index}` |
| | | } |
| | | </script> |
| New file |
| | |
| | | <!-- 通知组件 --> |
| | | <template> |
| | | <div |
| | | class="art-notification-panel art-card-sm !shadow-xl" |
| | | :style="{ |
| | | transform: show ? 'scaleY(1)' : 'scaleY(0.9)', |
| | | opacity: show ? 1 : 0 |
| | | }" |
| | | v-show="visible" |
| | | @click.stop |
| | | > |
| | | <div class="flex-cb px-3.5 mt-3.5"> |
| | | <span class="text-base font-medium text-g-800">{{ $t('notice.title') }}</span> |
| | | <span class="text-xs text-g-800 px-1.5 py-1 c-p select-none rounded hover:bg-g-200"> |
| | | {{ $t('notice.btnRead') }} |
| | | </span> |
| | | </div> |
| | | |
| | | <ul class="box-border flex items-end w-full h-12.5 px-3.5 border-b-d"> |
| | | <li |
| | | v-for="(item, index) in barList" |
| | | :key="index" |
| | | class="h-12 leading-12 mr-5 overflow-hidden text-[13px] text-g-700 c-p select-none" |
| | | :class="{ 'bar-active': barActiveIndex === index }" |
| | | @click="changeBar(index)" |
| | | > |
| | | {{ item.name }} ({{ item.num }}) |
| | | </li> |
| | | </ul> |
| | | |
| | | <div class="w-full h-[calc(100%-95px)]"> |
| | | <div class="h-[calc(100%-60px)] overflow-y-scroll scrollbar-thin"> |
| | | <!-- 通知 --> |
| | | <ul v-show="barActiveIndex === 0"> |
| | | <li |
| | | v-for="(item, index) in noticeList" |
| | | :key="index" |
| | | class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60" |
| | | > |
| | | <div |
| | | class="size-9 leading-9 text-center rounded-lg flex-cc" |
| | | :class="[getNoticeStyle(item.type).iconClass]" |
| | | > |
| | | <ArtSvgIcon class="text-lg !bg-transparent" :icon="getNoticeStyle(item.type).icon" /> |
| | | </div> |
| | | <div class="w-[calc(100%-45px)] ml-3.5"> |
| | | <h4 class="text-sm font-normal leading-5.5 text-g-900">{{ item.title }}</h4> |
| | | <p class="mt-1.5 text-xs text-g-500">{{ item.time }}</p> |
| | | </div> |
| | | </li> |
| | | </ul> |
| | | |
| | | <!-- 消息 --> |
| | | <ul v-show="barActiveIndex === 1"> |
| | | <li |
| | | v-for="(item, index) in msgList" |
| | | :key="index" |
| | | class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60" |
| | | > |
| | | <div class="w-9 h-9"> |
| | | <img :src="item.avatar" class="w-full h-full rounded-lg" /> |
| | | </div> |
| | | <div class="w-[calc(100%-45px)] ml-3.5"> |
| | | <h4 class="text-xs font-normal leading-5.5">{{ item.title }}</h4> |
| | | <p class="mt-1.5 text-xs text-g-500">{{ item.time }}</p> |
| | | </div> |
| | | </li> |
| | | </ul> |
| | | |
| | | <!-- 待办 --> |
| | | <ul v-show="barActiveIndex === 2"> |
| | | <li |
| | | v-for="(item, index) in pendingList" |
| | | :key="index" |
| | | class="box-border px-5 py-3.5 last:border-b-0" |
| | | > |
| | | <h4>{{ item.title }}</h4> |
| | | <p class="text-xs text-g-500">{{ item.time }}</p> |
| | | </li> |
| | | </ul> |
| | | |
| | | <!-- 空状态 --> |
| | | <div |
| | | v-show="currentTabIsEmpty" |
| | | class="relative top-25 h-full text-g-500 text-center !bg-transparent" |
| | | > |
| | | <ArtSvgIcon icon="system-uicons:inbox" class="text-5xl" /> |
| | | <p class="mt-3.5 text-xs !bg-transparent" |
| | | >{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p |
| | | > |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="relative box-border w-full px-3.5"> |
| | | <ElButton class="w-full mt-3" @click="handleViewAll" v-ripple> |
| | | {{ $t('notice.viewAll') }} |
| | | </ElButton> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="h-25"></div> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, ref, watch } from 'vue' |
| | | import { useI18n } from 'vue-i18n' |
| | | import avatar1 from '@/assets/images/avatar/avatar1.webp' |
| | | import avatar2 from '@/assets/images/avatar/avatar2.webp' |
| | | import avatar3 from '@/assets/images/avatar/avatar3.webp' |
| | | import avatar4 from '@/assets/images/avatar/avatar4.webp' |
| | | import avatar5 from '@/assets/images/avatar/avatar5.webp' |
| | | import avatar6 from '@/assets/images/avatar/avatar6.webp' |
| | | defineOptions({ name: 'ArtNotification' }) |
| | | const { t } = useI18n() |
| | | const props = defineProps({ |
| | | value: { required: true } |
| | | }) |
| | | const emit = defineEmits(['update:value']) |
| | | const show = ref(false) |
| | | const visible = ref(false) |
| | | const barActiveIndex = ref(0) |
| | | const useNotificationData = () => { |
| | | const noticeList2 = ref([ |
| | | { |
| | | title: '新增国际化', |
| | | time: '2024-6-13 0:10', |
| | | type: 'notice' |
| | | }, |
| | | { |
| | | title: '冷月呆呆给你发了一条消息', |
| | | time: '2024-4-21 8:05', |
| | | type: 'message' |
| | | }, |
| | | { |
| | | title: '小肥猪关注了你', |
| | | time: '2020-3-17 21:12', |
| | | type: 'collection' |
| | | }, |
| | | { |
| | | title: '新增使用文档', |
| | | time: '2024-02-14 0:20', |
| | | type: 'notice' |
| | | }, |
| | | { |
| | | title: '小肥猪给你发了一封邮件', |
| | | time: '2024-1-20 0:15', |
| | | type: 'email' |
| | | }, |
| | | { |
| | | title: '菜单mock本地真实数据', |
| | | time: '2024-1-17 22:06', |
| | | type: 'notice' |
| | | } |
| | | ]) |
| | | const msgList2 = ref([ |
| | | { |
| | | title: '池不胖 关注了你', |
| | | time: '2021-2-26 23:50', |
| | | avatar: avatar1 |
| | | }, |
| | | { |
| | | title: '唐不苦 关注了你', |
| | | time: '2021-2-21 8:05', |
| | | avatar: avatar2 |
| | | }, |
| | | { |
| | | title: '中小鱼 关注了你', |
| | | time: '2020-1-17 21:12', |
| | | avatar: avatar3 |
| | | }, |
| | | { |
| | | title: '何小荷 关注了你', |
| | | time: '2021-01-14 0:20', |
| | | avatar: avatar4 |
| | | }, |
| | | { |
| | | title: '誶誶淰 关注了你', |
| | | time: '2020-12-20 0:15', |
| | | avatar: avatar5 |
| | | }, |
| | | { |
| | | title: '冷月呆呆 关注了你', |
| | | time: '2020-12-17 22:06', |
| | | avatar: avatar6 |
| | | } |
| | | ]) |
| | | const pendingList2 = ref([]) |
| | | const barList2 = computed(() => [ |
| | | { |
| | | name: computed(() => t('notice.bar[0]')), |
| | | num: noticeList2.value.length |
| | | }, |
| | | { |
| | | name: computed(() => t('notice.bar[1]')), |
| | | num: msgList2.value.length |
| | | }, |
| | | { |
| | | name: computed(() => t('notice.bar[2]')), |
| | | num: pendingList2.value.length |
| | | } |
| | | ]) |
| | | return { |
| | | noticeList: noticeList2, |
| | | msgList: msgList2, |
| | | pendingList: pendingList2, |
| | | barList: barList2 |
| | | } |
| | | } |
| | | const useNotificationStyles = () => { |
| | | const noticeStyleMap = { |
| | | email: { |
| | | icon: 'ri:mail-line', |
| | | iconClass: 'bg-warning/12 text-warning' |
| | | }, |
| | | message: { |
| | | icon: 'ri:volume-down-line', |
| | | iconClass: 'bg-success/12 text-success' |
| | | }, |
| | | collection: { |
| | | icon: 'ri:heart-3-line', |
| | | iconClass: 'bg-danger/12 text-danger' |
| | | }, |
| | | user: { |
| | | icon: 'ri:volume-down-line', |
| | | iconClass: 'bg-info/12 text-info' |
| | | }, |
| | | notice: { |
| | | icon: 'ri:notification-3-line', |
| | | iconClass: 'bg-theme/12 text-theme' |
| | | } |
| | | } |
| | | const getNoticeStyle2 = (type) => { |
| | | const defaultStyle = { |
| | | icon: 'ri:arrow-right-circle-line', |
| | | iconClass: 'bg-theme/12 text-theme' |
| | | } |
| | | return noticeStyleMap[type] || defaultStyle |
| | | } |
| | | return { |
| | | getNoticeStyle: getNoticeStyle2 |
| | | } |
| | | } |
| | | const useNotificationAnimation = () => { |
| | | const showNotice2 = (open) => { |
| | | if (open) { |
| | | visible.value = true |
| | | setTimeout(() => { |
| | | show.value = true |
| | | }, 5) |
| | | } else { |
| | | show.value = false |
| | | setTimeout(() => { |
| | | visible.value = false |
| | | }, 350) |
| | | } |
| | | } |
| | | return { |
| | | showNotice: showNotice2 |
| | | } |
| | | } |
| | | const useTabManagement = (noticeList2, msgList2, pendingList2, businessHandlers) => { |
| | | const changeBar2 = (index) => { |
| | | barActiveIndex.value = index |
| | | } |
| | | const currentTabIsEmpty2 = computed(() => { |
| | | const tabDataMap = [noticeList2.value, msgList2.value, pendingList2.value] |
| | | const currentData = tabDataMap[barActiveIndex.value] |
| | | return currentData && currentData.length === 0 |
| | | }) |
| | | const handleViewAll2 = () => { |
| | | const viewAllHandlers = { |
| | | 0: businessHandlers.handleNoticeAll, |
| | | 1: businessHandlers.handleMsgAll, |
| | | 2: businessHandlers.handlePendingAll |
| | | } |
| | | const handler = viewAllHandlers[barActiveIndex.value] |
| | | handler?.() |
| | | emit('update:value', false) |
| | | } |
| | | return { |
| | | changeBar: changeBar2, |
| | | currentTabIsEmpty: currentTabIsEmpty2, |
| | | handleViewAll: handleViewAll2 |
| | | } |
| | | } |
| | | const useBusinessLogic = () => { |
| | | const handleNoticeAll2 = () => { |
| | | console.log('查看全部通知') |
| | | } |
| | | const handleMsgAll2 = () => { |
| | | console.log('查看全部消息') |
| | | } |
| | | const handlePendingAll2 = () => { |
| | | console.log('查看全部待办') |
| | | } |
| | | return { |
| | | handleNoticeAll: handleNoticeAll2, |
| | | handleMsgAll: handleMsgAll2, |
| | | handlePendingAll: handlePendingAll2 |
| | | } |
| | | } |
| | | const { noticeList, msgList, pendingList, barList } = useNotificationData() |
| | | const { getNoticeStyle } = useNotificationStyles() |
| | | const { showNotice } = useNotificationAnimation() |
| | | const { handleNoticeAll, handleMsgAll, handlePendingAll } = useBusinessLogic() |
| | | const { changeBar, currentTabIsEmpty, handleViewAll } = useTabManagement( |
| | | noticeList, |
| | | msgList, |
| | | pendingList, |
| | | { handleNoticeAll, handleMsgAll, handlePendingAll } |
| | | ) |
| | | watch( |
| | | () => props.value, |
| | | (newValue) => { |
| | | showNotice(newValue) |
| | | } |
| | | ) |
| | | </script> |
| | | |
| | | <style scoped> |
| | | @reference '@styles/core/tailwind.css'; |
| | | |
| | | .art-notification-panel { |
| | | @apply absolute |
| | | top-14.5 |
| | | right-5 |
| | | w-90 |
| | | h-125 |
| | | overflow-hidden |
| | | transition-all |
| | | duration-300 |
| | | origin-top |
| | | will-change-[top,left] |
| | | max-[640px]:top-[65px] |
| | | max-[640px]:right-0 |
| | | max-[640px]:w-full |
| | | max-[640px]:h-[80vh]; |
| | | } |
| | | |
| | | .bar-active { |
| | | color: var(--theme-color) !important; |
| | | border-bottom: 2px solid var(--theme-color); |
| | | } |
| | | |
| | | .scrollbar-thin::-webkit-scrollbar { |
| | | width: 5px !important; |
| | | } |
| | | |
| | | .dark .scrollbar-thin::-webkit-scrollbar-track { |
| | | background-color: var(--default-box-color); |
| | | } |
| | | |
| | | .dark .scrollbar-thin::-webkit-scrollbar-thumb { |
| | | background-color: #222 !important; |
| | | } |
| | | </style> |
| New file |
| | |
| | | <!-- 布局内容 --> |
| | | <template> |
| | | <div class="layout-content" :class="{ 'overflow-auto': isFullPage }" :style="containerStyle"> |
| | | <div id="app-content-header"> |
| | | <!-- 节日滚动 --> |
| | | <ArtFestivalTextScroll v-if="!isFullPage" /> |
| | | |
| | | <!-- 路由信息调试 --> |
| | | <div |
| | | v-if="isOpenRouteInfo === 'true'" |
| | | class="px-2 py-1.5 mb-3 text-sm text-g-500 bg-g-200 border-full-d rounded-md" |
| | | > |
| | | router meta:{{ route.meta }} |
| | | </div> |
| | | </div> |
| | | |
| | | <RouterView v-if="isRefresh" v-slot="{ Component, route }" :style="contentStyle"> |
| | | <!-- 缓存路由动画 --> |
| | | <Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear> |
| | | <KeepAlive :max="10" :exclude="keepAliveExclude"> |
| | | <component |
| | | class="art-page-view" |
| | | :is="Component" |
| | | :key="route.path" |
| | | v-if="route.meta.keepAlive" |
| | | /> |
| | | </KeepAlive> |
| | | </Transition> |
| | | |
| | | <!-- 非缓存路由动画 --> |
| | | <Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear> |
| | | <component |
| | | class="art-page-view" |
| | | :is="Component" |
| | | :key="route.path" |
| | | v-if="!route.meta.keepAlive" |
| | | /> |
| | | </Transition> |
| | | </RouterView> |
| | | |
| | | <!-- 全屏页面切换过渡遮罩(用于提升页面切换视觉体验) --> |
| | | <Teleport to="body"> |
| | | <div |
| | | v-show="showTransitionMask" |
| | | class="fixed top-0 left-0 z-[2000] w-screen h-screen pointer-events-none bg-box" |
| | | /> |
| | | </Teleport> |
| | | </div> |
| | | </template> |
| | | <script setup> |
| | | import { useRoute } from 'vue-router' |
| | | import { useAutoLayoutHeight } from '@/hooks/core/useLayoutHeight' |
| | | import { useSettingStore } from '@/store/modules/setting' |
| | | import { useWorktabStore } from '@/store/modules/worktab' |
| | | defineOptions({ name: 'ArtPageContent' }) |
| | | const route = useRoute() |
| | | const { containerMinHeight } = useAutoLayoutHeight() |
| | | const { pageTransition, containerWidth, refresh } = storeToRefs(useSettingStore()) |
| | | const { keepAliveExclude } = storeToRefs(useWorktabStore()) |
| | | const isRefresh = shallowRef(true) |
| | | const isOpenRouteInfo = import.meta.env.VITE_OPEN_ROUTE_INFO |
| | | const showTransitionMask = ref(false) |
| | | const isFirstLoad = ref(true) |
| | | const isFullPage = computed(() => route.matched.some((r) => r.meta?.isFullPage)) |
| | | const prevIsFullPage = ref(isFullPage.value) |
| | | const actualTransition = computed(() => { |
| | | if (isFirstLoad.value) return '' |
| | | if (prevIsFullPage.value && !isFullPage.value) return '' |
| | | return pageTransition.value |
| | | }) |
| | | watch(isFullPage, (val, oldVal) => { |
| | | if (val !== oldVal) { |
| | | showTransitionMask.value = true |
| | | setTimeout(() => { |
| | | showTransitionMask.value = false |
| | | }, 50) |
| | | } |
| | | nextTick(() => { |
| | | prevIsFullPage.value = val |
| | | }) |
| | | }) |
| | | const containerStyle = computed(() => |
| | | isFullPage.value |
| | | ? { |
| | | position: 'fixed', |
| | | top: 0, |
| | | left: 0, |
| | | width: '100%', |
| | | height: '100vh', |
| | | zIndex: 2500, |
| | | background: 'var(--default-bg-color)' |
| | | } |
| | | : { |
| | | maxWidth: containerWidth.value |
| | | } |
| | | ) |
| | | const contentStyle = computed(() => ({ |
| | | minHeight: containerMinHeight.value |
| | | })) |
| | | const reload = () => { |
| | | isRefresh.value = false |
| | | nextTick(() => { |
| | | isRefresh.value = true |
| | | }) |
| | | } |
| | | watch(refresh, reload, { flush: 'post' }) |
| | | onMounted(() => { |
| | | nextTick(() => { |
| | | isFirstLoad.value = false |
| | | }) |
| | | }) |
| | | </script> |
| New file |
| | |
| | | <!-- 锁屏 --> |
| | | <template> |
| | | <div class="layout-lock-screen"> |
| | | <!-- 开发者工具警告覆盖层 --> |
| | | <div |
| | | v-if="showDevToolsWarning" |
| | | class="fixed top-0 left-0 z-[999999] flex-cc w-full h-full text-white bg-gradient-to-br from-[#1e1e1e] to-black animate-fade-in" |
| | | > |
| | | <div class="p-5 text-center select-none"> |
| | | <div class="mb-7.5 text-5xl">🔒</div> |
| | | <h1 class="m-0 mb-5 text-3xl font-semibold text-danger">系统已锁定</h1> |
| | | <p class="max-w-125 m-0 text-lg leading-relaxed text-white"> |
| | | 检测到开发者工具已打开<br /> |
| | | 为了系统安全,请关闭开发者工具后继续使用 |
| | | </p> |
| | | <div class="mt-7.5 text-sm text-gray-400">Security Lock Activated</div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 锁屏弹窗 --> |
| | | <div v-if="!isLock"> |
| | | <ElDialog v-model="visible" :width="370" :show-close="false" @open="handleDialogOpen"> |
| | | <div class="flex-c flex-col"> |
| | | <img class="w-16 h-16 rounded-full" src="@imgs/user/avatar.webp" alt="用户头像" /> |
| | | <div class="mt-7.5 mb-3.5 text-base font-medium">{{ userInfo.userName }}</div> |
| | | <ElForm |
| | | ref="formRef" |
| | | :model="formData" |
| | | :rules="rules" |
| | | class="w-[90%]" |
| | | @submit.prevent="handleLock" |
| | | > |
| | | <ElFormItem prop="password"> |
| | | <ElInput |
| | | v-model="formData.password" |
| | | type="password" |
| | | :placeholder="$t('lockScreen.lock.inputPlaceholder')" |
| | | :show-password="true" |
| | | autocomplete="new-password" |
| | | ref="lockInputRef" |
| | | class="w-full mt-9" |
| | | @keyup.enter="handleLock" |
| | | > |
| | | <template #suffix> |
| | | <ElIcon class="c-p" @click="handleLock"> |
| | | <Lock /> |
| | | </ElIcon> |
| | | </template> |
| | | </ElInput> |
| | | </ElFormItem> |
| | | <ElButton type="primary" class="w-full mt-0.5" @click="handleLock" v-ripple> |
| | | {{ $t('lockScreen.lock.btnText') }} |
| | | </ElButton> |
| | | </ElForm> |
| | | </div> |
| | | </ElDialog> |
| | | </div> |
| | | |
| | | <!-- 解锁界面 --> |
| | | <div v-else class="unlock-content"> |
| | | <div class="flex-c flex-col w-80"> |
| | | <img class="w-16 h-16 mt-5 rounded-full" src="@imgs/user/avatar.webp" alt="用户头像" /> |
| | | <div class="mt-3 mb-3.5 text-base font-medium"> |
| | | {{ userInfo.userName }} |
| | | </div> |
| | | <ElForm |
| | | ref="unlockFormRef" |
| | | :model="unlockForm" |
| | | :rules="rules" |
| | | class="w-full !px-2.5" |
| | | @submit.prevent="handleUnlock" |
| | | > |
| | | <ElFormItem prop="password"> |
| | | <ElInput |
| | | v-model="unlockForm.password" |
| | | type="password" |
| | | :placeholder="$t('lockScreen.unlock.inputPlaceholder')" |
| | | :show-password="true" |
| | | autocomplete="new-password" |
| | | ref="unlockInputRef" |
| | | class="mt-5" |
| | | > |
| | | <template #suffix> |
| | | <ElIcon class="c-p" @click="handleUnlock"> |
| | | <Unlock /> |
| | | </ElIcon> |
| | | </template> |
| | | </ElInput> |
| | | </ElFormItem> |
| | | |
| | | <ElButton type="primary" class="w-full mt-2" @click="handleUnlock" v-ripple> |
| | | {{ $t('lockScreen.unlock.btnText') }} |
| | | </ElButton> |
| | | <div class="w-full text-center"> |
| | | <ElButton |
| | | text |
| | | class="mt-2.5 !text-g-600 hover:!text-theme hover:!bg-transparent" |
| | | @click="toLogin" |
| | | > |
| | | {{ $t('lockScreen.unlock.backBtnText') }} |
| | | </ElButton> |
| | | </div> |
| | | </ElForm> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { Lock, Unlock } from '@element-plus/icons-vue' |
| | | |
| | | import { useI18n } from 'vue-i18n' |
| | | import CryptoJS from 'crypto-js' |
| | | import { useUserStore } from '@/store/modules/user' |
| | | import { mittBus } from '@/utils/sys' |
| | | const { t } = useI18n() |
| | | const ENCRYPT_KEY = import.meta.env.VITE_LOCK_ENCRYPT_KEY |
| | | const userStore = useUserStore() |
| | | const { info: userInfo, lockPassword, isLock } = storeToRefs(userStore) |
| | | const visible = ref(false) |
| | | const lockInputRef = ref(null) |
| | | const unlockInputRef = ref(null) |
| | | const showDevToolsWarning = ref(false) |
| | | const formRef = ref() |
| | | const unlockFormRef = ref() |
| | | const formData = reactive({ |
| | | password: '' |
| | | }) |
| | | const unlockForm = reactive({ |
| | | password: '' |
| | | }) |
| | | const rules = computed(() => ({ |
| | | password: [ |
| | | { |
| | | required: true, |
| | | message: t('lockScreen.lock.inputPlaceholder'), |
| | | trigger: 'blur' |
| | | } |
| | | ] |
| | | })) |
| | | const isMobile = () => { |
| | | return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( |
| | | navigator.userAgent |
| | | ) |
| | | } |
| | | const disableDevTools = () => { |
| | | const handleContextMenu = (e) => { |
| | | if (isLock.value) { |
| | | e.preventDefault() |
| | | e.stopPropagation() |
| | | return false |
| | | } |
| | | } |
| | | document.addEventListener('contextmenu', handleContextMenu, true) |
| | | const handleKeyDown = (e) => { |
| | | if (!isLock.value) return |
| | | if (e.key === 'F12') { |
| | | e.preventDefault() |
| | | e.stopPropagation() |
| | | return false |
| | | } |
| | | if (e.ctrlKey && e.shiftKey) { |
| | | const key = e.key.toLowerCase() |
| | | if (['i', 'j', 'c', 'k'].includes(key)) { |
| | | e.preventDefault() |
| | | e.stopPropagation() |
| | | return false |
| | | } |
| | | } |
| | | if (e.ctrlKey && e.key.toLowerCase() === 'u') { |
| | | e.preventDefault() |
| | | e.stopPropagation() |
| | | return false |
| | | } |
| | | if (e.ctrlKey && e.key.toLowerCase() === 's') { |
| | | e.preventDefault() |
| | | e.stopPropagation() |
| | | return false |
| | | } |
| | | if (e.ctrlKey && e.key.toLowerCase() === 'a') { |
| | | e.preventDefault() |
| | | e.stopPropagation() |
| | | return false |
| | | } |
| | | if (e.ctrlKey && e.key.toLowerCase() === 'p') { |
| | | e.preventDefault() |
| | | e.stopPropagation() |
| | | return false |
| | | } |
| | | if (e.ctrlKey && e.key.toLowerCase() === 'f') { |
| | | e.preventDefault() |
| | | e.stopPropagation() |
| | | return false |
| | | } |
| | | if (e.altKey && e.key === 'Tab') { |
| | | e.preventDefault() |
| | | e.stopPropagation() |
| | | return false |
| | | } |
| | | if (e.ctrlKey && e.key === 'Tab') { |
| | | e.preventDefault() |
| | | e.stopPropagation() |
| | | return false |
| | | } |
| | | if (e.ctrlKey && e.key.toLowerCase() === 'w') { |
| | | e.preventDefault() |
| | | e.stopPropagation() |
| | | return false |
| | | } |
| | | if ((e.ctrlKey && e.key.toLowerCase() === 'r') || e.key === 'F5') { |
| | | e.preventDefault() |
| | | e.stopPropagation() |
| | | return false |
| | | } |
| | | if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'r') { |
| | | e.preventDefault() |
| | | e.stopPropagation() |
| | | return false |
| | | } |
| | | } |
| | | document.addEventListener('keydown', handleKeyDown, true) |
| | | const handleSelectStart = (e) => { |
| | | if (isLock.value) { |
| | | e.preventDefault() |
| | | return false |
| | | } |
| | | } |
| | | document.addEventListener('selectstart', handleSelectStart, true) |
| | | const handleDragStart = (e) => { |
| | | if (isLock.value) { |
| | | e.preventDefault() |
| | | return false |
| | | } |
| | | } |
| | | document.addEventListener('dragstart', handleDragStart, true) |
| | | let devtools = { open: false } |
| | | const threshold = 160 |
| | | let devToolsInterval = null |
| | | const checkDevTools = () => { |
| | | if (!isLock.value || isMobile()) return |
| | | const isDevToolsOpen = |
| | | window.outerHeight - window.innerHeight > threshold || |
| | | window.outerWidth - window.innerWidth > threshold |
| | | if (isDevToolsOpen && !devtools.open) { |
| | | devtools.open = true |
| | | showDevToolsWarning.value = true |
| | | } else if (!isDevToolsOpen && devtools.open) { |
| | | devtools.open = false |
| | | showDevToolsWarning.value = false |
| | | } |
| | | } |
| | | if (!isMobile()) { |
| | | devToolsInterval = setInterval(checkDevTools, 500) |
| | | } |
| | | return () => { |
| | | document.removeEventListener('contextmenu', handleContextMenu, true) |
| | | document.removeEventListener('keydown', handleKeyDown, true) |
| | | document.removeEventListener('selectstart', handleSelectStart, true) |
| | | document.removeEventListener('dragstart', handleDragStart, true) |
| | | if (devToolsInterval) { |
| | | clearInterval(devToolsInterval) |
| | | } |
| | | } |
| | | } |
| | | const verifyPassword = (inputPassword, storedPassword) => { |
| | | try { |
| | | const decryptedPassword = CryptoJS.AES.decrypt(storedPassword, ENCRYPT_KEY).toString( |
| | | CryptoJS.enc.Utf8 |
| | | ) |
| | | return inputPassword === decryptedPassword |
| | | } catch (error) { |
| | | console.error('密码解密失败:', error) |
| | | return false |
| | | } |
| | | } |
| | | const handleKeydown = (event) => { |
| | | if (event.altKey && event.key.toLowerCase() === '¬') { |
| | | event.preventDefault() |
| | | visible.value = true |
| | | } |
| | | } |
| | | const handleDialogOpen = () => { |
| | | setTimeout(() => { |
| | | lockInputRef.value?.input?.focus() |
| | | }, 100) |
| | | } |
| | | const handleLock = async () => { |
| | | if (!formRef.value) return |
| | | await formRef.value.validate((valid, fields) => { |
| | | if (valid) { |
| | | const encryptedPassword = CryptoJS.AES.encrypt(formData.password, ENCRYPT_KEY).toString() |
| | | userStore.setLockStatus(true) |
| | | userStore.setLockPassword(encryptedPassword) |
| | | visible.value = false |
| | | formData.password = '' |
| | | } else { |
| | | console.error('表单验证失败:', fields) |
| | | } |
| | | }) |
| | | } |
| | | const handleUnlock = async () => { |
| | | if (!unlockFormRef.value) return |
| | | await unlockFormRef.value.validate((valid, fields) => { |
| | | if (valid) { |
| | | const isValid = verifyPassword(unlockForm.password, lockPassword.value) |
| | | if (isValid) { |
| | | try { |
| | | userStore.setLockStatus(false) |
| | | userStore.setLockPassword('') |
| | | unlockForm.password = '' |
| | | visible.value = false |
| | | showDevToolsWarning.value = false |
| | | } catch (error) { |
| | | console.error('更新store失败:', error) |
| | | } |
| | | } else { |
| | | const inputElement = unlockInputRef.value?.$el |
| | | if (inputElement) { |
| | | inputElement.classList.add('shake-animation') |
| | | setTimeout(() => { |
| | | inputElement.classList.remove('shake-animation') |
| | | }, 300) |
| | | } |
| | | ElMessage.error(t('lockScreen.pwdError')) |
| | | unlockForm.password = '' |
| | | } |
| | | } else { |
| | | console.error('表单验证失败:', fields) |
| | | } |
| | | }) |
| | | } |
| | | const toLogin = () => { |
| | | userStore.logOut() |
| | | } |
| | | const openLockScreen = () => { |
| | | visible.value = true |
| | | } |
| | | watch(isLock, (newValue) => { |
| | | if (newValue) { |
| | | document.body.style.overflow = 'hidden' |
| | | setTimeout(() => { |
| | | unlockInputRef.value?.input?.focus() |
| | | }, 100) |
| | | } else { |
| | | document.body.style.overflow = 'auto' |
| | | showDevToolsWarning.value = false |
| | | } |
| | | }) |
| | | let cleanupDevTools = null |
| | | onMounted(() => { |
| | | mittBus.on('openLockScreen', openLockScreen) |
| | | document.addEventListener('keydown', handleKeydown) |
| | | if (isLock.value) { |
| | | visible.value = true |
| | | setTimeout(() => { |
| | | unlockInputRef.value?.input?.focus() |
| | | }, 100) |
| | | } |
| | | cleanupDevTools = disableDevTools() |
| | | }) |
| | | onUnmounted(() => { |
| | | document.removeEventListener('keydown', handleKeydown) |
| | | document.body.style.overflow = 'auto' |
| | | if (cleanupDevTools) { |
| | | cleanupDevTools() |
| | | cleanupDevTools = null |
| | | } |
| | | }) |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | | .layout-lock-screen :deep(.el-dialog) { |
| | | border-radius: 10px; |
| | | } |
| | | |
| | | .unlock-content { |
| | | position: fixed; |
| | | inset: 0; |
| | | z-index: 2500; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | overflow: hidden; |
| | | background-color: #fff; |
| | | background-image: url('@imgs/lock/bg_light.webp'); |
| | | background-size: cover; |
| | | transition: transform 0.3s ease-in-out; |
| | | } |
| | | |
| | | .dark { |
| | | .unlock-content { |
| | | background-image: url('@imgs/lock/bg_dark.webp'); |
| | | } |
| | | } |
| | | |
| | | @keyframes fade-in { |
| | | from { |
| | | opacity: 0; |
| | | transform: scale(0.9); |
| | | } |
| | | |
| | | to { |
| | | opacity: 1; |
| | | transform: scale(1); |
| | | } |
| | | } |
| | | |
| | | .animate-fade-in { |
| | | animation: fade-in 0.3s ease-in-out; |
| | | } |
| | | |
| | | @keyframes shake { |
| | | 0%, |
| | | 100% { |
| | | transform: translateX(0); |
| | | } |
| | | |
| | | 10%, |
| | | 30%, |
| | | 50%, |
| | | 70%, |
| | | 90% { |
| | | transform: translateX(-10px); |
| | | } |
| | | |
| | | 20%, |
| | | 40%, |
| | | 60%, |
| | | 80% { |
| | | transform: translateX(10px); |
| | | } |
| | | } |
| | | |
| | | .shake-animation { |
| | | animation: shake 0.5s ease-in-out; |
| | | } |
| | | </style> |
| rsf-design/src/components/core/layouts/art-settings-panel/composables/useSettingsConfig.js
rsf-design/src/components/core/layouts/art-settings-panel/composables/useSettingsHandlers.js
rsf-design/src/components/core/layouts/art-settings-panel/composables/useSettingsPanel.js
rsf-design/src/components/core/layouts/art-settings-panel/composables/useSettingsState.js
rsf-design/src/components/core/layouts/art-settings-panel/index.vue
rsf-design/src/components/core/layouts/art-settings-panel/style.scss
rsf-design/src/components/core/layouts/art-settings-panel/widget/BasicSettings.vue
rsf-design/src/components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue
rsf-design/src/components/core/layouts/art-settings-panel/widget/ColorSettings.vue
rsf-design/src/components/core/layouts/art-settings-panel/widget/ContainerSettings.vue
rsf-design/src/components/core/layouts/art-settings-panel/widget/MenuLayoutSettings.vue
rsf-design/src/components/core/layouts/art-settings-panel/widget/MenuStyleSettings.vue
rsf-design/src/components/core/layouts/art-settings-panel/widget/SectionTitle.vue
rsf-design/src/components/core/layouts/art-settings-panel/widget/SettingActions.vue
rsf-design/src/components/core/layouts/art-settings-panel/widget/SettingDrawer.vue
rsf-design/src/components/core/layouts/art-settings-panel/widget/SettingHeader.vue
rsf-design/src/components/core/layouts/art-settings-panel/widget/SettingItem.vue
rsf-design/src/components/core/layouts/art-settings-panel/widget/ThemeSettings.vue
rsf-design/src/components/core/layouts/art-work-tab/index.vue
rsf-design/src/components/core/media/art-cutter-img/index.vue
rsf-design/src/components/core/media/art-video-player/index.vue
rsf-design/src/components/core/others/art-menu-right/index.vue
rsf-design/src/components/core/others/art-watermark/index.vue
rsf-design/src/components/core/tables/art-table-header/index.vue
rsf-design/src/components/core/tables/art-table/index.vue
rsf-design/src/components/core/tables/art-table/style.scss
rsf-design/src/components/core/text-effect/art-count-to/index.vue
rsf-design/src/components/core/text-effect/art-festival-text-scroll/index.vue
rsf-design/src/components/core/text-effect/art-text-scroll/index.vue
rsf-design/src/components/core/theme/theme-svg/index.vue
rsf-design/src/components/core/views/exception/ArtException.vue
rsf-design/src/components/core/views/login/AuthTopBar.vue
rsf-design/src/components/core/views/login/LoginLeftView.vue
rsf-design/src/components/core/views/result/ArtResultPage.vue
rsf-design/src/components/core/widget/art-icon-button/index.vue
rsf-design/src/config/assets/images.js
rsf-design/src/config/index.js
rsf-design/src/config/modules/component.js
rsf-design/src/config/modules/fastEnter.js
rsf-design/src/config/modules/festival.js
rsf-design/src/config/modules/headerBar.js
rsf-design/src/config/setting.js
rsf-design/src/directives/business/highlight.js
rsf-design/src/directives/business/ripple.js
rsf-design/src/directives/core/auth.js
rsf-design/src/directives/core/roles.js
rsf-design/src/directives/index.js
rsf-design/src/enums/appEnum.js
rsf-design/src/enums/formEnum.js
rsf-design/src/hooks/core/useAppMode.js
rsf-design/src/hooks/core/useAuth.js
rsf-design/src/hooks/core/useCeremony.js
rsf-design/src/hooks/core/useChart.js
rsf-design/src/hooks/core/useCommon.js
rsf-design/src/hooks/core/useFastEnter.js
rsf-design/src/hooks/core/useHeaderBar.js
rsf-design/src/hooks/core/useLayoutHeight.js
rsf-design/src/hooks/core/useTable.js
rsf-design/src/hooks/core/useTableColumns.js
rsf-design/src/hooks/core/useTableHeight.js
rsf-design/src/hooks/core/useTheme.js
rsf-design/src/hooks/index.js
rsf-design/src/locales/index.js
rsf-design/src/locales/langs/en.json
rsf-design/src/locales/langs/zh.json
rsf-design/src/main.js
rsf-design/src/mock/temp/formData.js
rsf-design/src/mock/upgrade/changeLog.js
rsf-design/src/plugins/echarts.js
rsf-design/src/plugins/iconify.collections.js
rsf-design/src/plugins/iconify.js
rsf-design/src/plugins/index.js
rsf-design/src/router/core/ComponentLoader.js
rsf-design/src/router/core/IframeRouteManager.js
rsf-design/src/router/core/MenuProcessor.js
rsf-design/src/router/core/RoutePermissionValidator.js
rsf-design/src/router/core/RouteRegistry.js
rsf-design/src/router/core/RouteTransformer.js
rsf-design/src/router/core/RouteValidator.js
rsf-design/src/router/core/index.js
rsf-design/src/router/guards/afterEach.js
rsf-design/src/router/guards/beforeEach.js
rsf-design/src/router/index.js
rsf-design/src/router/modules/dashboard.js
rsf-design/src/router/modules/exception.js
rsf-design/src/router/modules/index.js
rsf-design/src/router/modules/result.js
rsf-design/src/router/modules/system.js
rsf-design/src/router/routes/asyncRoutes.js
rsf-design/src/router/routes/staticRoutes.js
rsf-design/src/router/routesAlias.js
rsf-design/src/store/index.js
rsf-design/src/store/modules/menu.js
rsf-design/src/store/modules/setting.js
rsf-design/src/store/modules/table.js
rsf-design/src/store/modules/user.js
rsf-design/src/store/modules/worktab.js
rsf-design/src/utils/constants/index.js
rsf-design/src/utils/constants/links.js
rsf-design/src/utils/form/index.js
rsf-design/src/utils/form/responsive.js
rsf-design/src/utils/form/validator.js
rsf-design/src/utils/http/error.js
rsf-design/src/utils/http/index.js
rsf-design/src/utils/http/status.js
rsf-design/src/utils/index.js
rsf-design/src/utils/navigation/index.js
rsf-design/src/utils/navigation/jump.js
rsf-design/src/utils/navigation/route.js
rsf-design/src/utils/navigation/worktab.js
rsf-design/src/utils/router.js
rsf-design/src/utils/socket/index.js
rsf-design/src/utils/storage/index.js
rsf-design/src/utils/storage/storage-config.js
rsf-design/src/utils/storage/storage-key-manager.js
rsf-design/src/utils/storage/storage.js
rsf-design/src/utils/sys/console.js
rsf-design/src/utils/sys/error-handle.js
rsf-design/src/utils/sys/index.js
rsf-design/src/utils/sys/mittBus.js
rsf-design/src/utils/sys/upgrade.js
rsf-design/src/utils/table/tableCache.js
rsf-design/src/utils/table/tableConfig.js
rsf-design/src/utils/table/tableUtils.js
rsf-design/src/utils/ui/animation.js
rsf-design/src/utils/ui/colors.js
rsf-design/src/utils/ui/emojo.js
rsf-design/src/utils/ui/iconify-loader.js
rsf-design/src/utils/ui/index.js
rsf-design/src/utils/ui/loading.js
rsf-design/src/utils/ui/tabs.js
rsf-design/src/views/auth/forget-password/index.vue
rsf-design/src/views/auth/login/index.vue
rsf-design/src/views/auth/login/style.css
rsf-design/src/views/auth/register/index.vue
rsf-design/src/views/dashboard/console/index.vue
rsf-design/src/views/dashboard/console/modules/about-project.vue
rsf-design/src/views/dashboard/console/modules/active-user.vue
rsf-design/src/views/dashboard/console/modules/card-list.vue
rsf-design/src/views/dashboard/console/modules/dynamic-stats.vue
rsf-design/src/views/dashboard/console/modules/new-user.vue
rsf-design/src/views/dashboard/console/modules/sales-overview.vue
rsf-design/src/views/dashboard/console/modules/todo-list.vue
rsf-design/src/views/exception/403/index.vue
rsf-design/src/views/exception/404/index.vue
rsf-design/src/views/exception/500/index.vue
rsf-design/src/views/index/index.vue
rsf-design/src/views/index/style.scss
rsf-design/src/views/outside/Iframe.vue
rsf-design/src/views/result/fail/index.vue
rsf-design/src/views/result/success/index.vue
rsf-design/src/views/system/menu/index.vue
rsf-design/src/views/system/menu/modules/menu-dialog.vue
rsf-design/src/views/system/role/index.vue
rsf-design/src/views/system/role/modules/role-edit-dialog.vue
rsf-design/src/views/system/role/modules/role-permission-dialog.vue
rsf-design/src/views/system/role/modules/role-search.vue
rsf-design/src/views/system/user-center/index.vue
rsf-design/src/views/system/user/index.vue
rsf-design/src/views/system/user/modules/user-dialog.vue
rsf-design/src/views/system/user/modules/user-search.vue
rsf-design/tests/clean-dev-helpers.test.mjs
rsf-design/tests/iconify-local-minimal.test.mjs
rsf-design/tests/iconify-local-prefixes.test.mjs
rsf-design/tests/manual-chunks.test.mjs
rsf-design/tests/repo-hygiene.test.mjs
rsf-design/vite.config.js |