fix: 自定义标签

This commit is contained in:
2026-03-03 15:40:21 +08:00
parent fe4e742551
commit 13fe5d9124
41 changed files with 2643 additions and 0 deletions

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*.{js,ts,vue}]
charset = utf-8 # 设置文件字符集为 utf-8
end_of_line = lf # 控制换行类型(lf | cr | crlf)
insert_final_newline = true # 始终在文件末尾插入一个新行
indent_style = space # 缩进风格tab | space
indent_size = 2 # 缩进大小
max_line_length = 100 # 最大行长度
[*.md] # 仅 md 文件适用以下规则
max_line_length = off # 关闭最大行长度限制
trim_trailing_whitespace = false # 关闭末尾空格修剪

37
.env Normal file
View File

@@ -0,0 +1,37 @@
# 标题
VITE_APP_TITLE=衣朵云管理系统
# 项目本地运行端口号
VITE_PORT=80
# open 运行 npm run dev 时自动打开浏览器
VITE_OPEN=true
# 租户开关
VITE_APP_TENANT_ENABLE=true
# 验证码的开关
VITE_APP_CAPTCHA_ENABLE=true
# 文档地址的开关
VITE_APP_DOCALERT_ENABLE=false
# 百度统计
VITE_APP_BAIDU_CODE = a1ff8825baa73c3a78eb96aa40325abc
# 默认账户密码
VITE_APP_DEFAULT_LOGIN_TENANT = 衣朵云源码
VITE_APP_DEFAULT_LOGIN_USERNAME =
VITE_APP_DEFAULT_LOGIN_PASSWORD =
# API 加解密
VITE_APP_API_ENCRYPT_ENABLE = true
VITE_APP_API_ENCRYPT_HEADER = X-Api-Encrypt
VITE_APP_API_ENCRYPT_ALGORITHM = AES
VITE_APP_API_ENCRYPT_REQUEST_KEY = 52549111389893486934626385991395
VITE_APP_API_ENCRYPT_RESPONSE_KEY = 96103715984234343991809655248883
# VITE_APP_API_ENCRYPT_REQUEST_KEY = MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCls2rIpnGdYnLFgz1XU13GbNQ5DloyPpvW00FPGjqn5Z6JpK+kDtVlnkhwR87iRrE5Vf2WNqRX6vzbLSgveIQY8e8oqGCb829myjf1MuI+ZzN4ghf/7tEYhZJGPI9AbfxFqBUzm+kR3/HByAI22GLT96WM26QiMK8n3tIP/yiLswIDAQAB
# VITE_APP_API_ENCRYPT_RESPONSE_KEY = MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAOH8IfIFxL/MR9XIg1UDv5z1fGXQI93E8wrU4iPFovL/sEt9uSgSkjyidC2O7N+m7EKtoN6b1u7cEwXSkwf3kfK0jdWLSQaNpX5YshqXCBzbDfugDaxuyYrNA4/tIMs7mzZAk0APuRXB35Dmupou7Yw7TFW/7QhQmGfzeEKULQvnAgMBAAECgYAw8LqlQGyQoPv5p3gRxEMOCfgL0JzD3XBJKztiRd35RDh40Nx1ejgjW4dPioFwGiVWd2W8cAGHLzALdcQT2KDJh+T/tsd4SPmI6uSBBK6Ff2DkO+kFFcuYvfclQQKqxma5CaZOSqhgenacmgTMFeg2eKlY3symV6JlFNu/IKU42QJBAOhxAK/Eq3e61aYQV2JSguhMR3b8NXJJRroRs/QHEanksJtl+M+2qhkC9nQVXBmBkndnkU/l2tYcHfSBlAyFySMCQQD445tgm/J2b6qMQmuUGQAYDN8FIkHjeKmha+l/fv0igWm8NDlBAem91lNDIPBUzHL1X1+pcts5bjmq99YdOnhtAkAg2J8dN3B3idpZDiQbC8fd5bGPmdI/pSUudAP27uzLEjr2qrE/QPPGdwm2m7IZFJtK7kK1hKio6u48t/bg0iL7AkEAuUUs94h+v702Fnym+jJ2CHEkXvz2US8UDs52nWrZYiM1o1y4tfSHm8H8bv8JCAa9GHyriEawfBraILOmllFdLQJAQSRZy4wmlaG48MhVXodB85X+VZ9krGXZ2TLhz7kz9iuToy53l9jTkESt6L5BfBDCVdIwcXLYgK+8KFdHN5W7HQ==
# 百度地图
VITE_BAIDU_MAP_KEY = 'efHIw2qmH8RzHPxK0z0rbCgzDVLup9LD'

38
.env.dev Normal file
View File

@@ -0,0 +1,38 @@
# 开发环境本地只启动前端项目依赖开发环境后端、APP
NODE_ENV=production
VITE_DEV=true
# 请求路径
VITE_BASE_URL='http://localhost:48080'
#VITE_BASE_URL='http://118.253.178.8:48080'
# 文件上传类型server - 后端上传, client - 前端直连上传仅支持S3服务
VITE_UPLOAD_TYPE=server
# 接口地址
VITE_API_URL=/admin-api
# 是否删除debugger
VITE_DROP_DEBUGGER=false
# 是否删除console.log
VITE_DROP_CONSOLE=false
# 是否sourcemap
VITE_SOURCEMAP=true
# 打包路径
VITE_BASE_PATH=/
# 输出路径
VITE_OUT_DIR=dist
# 商城H5会员端域名
VITE_MALL_H5_DOMAIN='http://mall.yudao.iocoder.cn'
# 验证码的开关
VITE_APP_CAPTCHA_ENABLE=true
# GoView域名
VITE_GOVIEW_URL='http://118.253.178.8:3030'

35
.env.local Normal file
View File

@@ -0,0 +1,35 @@
# 本地开发环境本地启动所有项目前端、后端、APP时使用不依赖外部环境
NODE_ENV=development
VITE_DEV=true
# 请求路径
#VITE_BASE_URL='http://118.253.178.8:48080'
VITE_BASE_URL='http://localhost:48080'
# 文件上传类型server - 后端上传, client - 前端直连上传,仅支持 S3 服务
VITE_UPLOAD_TYPE=server
# 接口地址
VITE_API_URL=/admin-api
# 是否删除debugger
VITE_DROP_DEBUGGER=false
# 是否删除console.log
VITE_DROP_CONSOLE=false
# 是否sourcemap
VITE_SOURCEMAP=false
# 打包路径
VITE_BASE_PATH=/
# 商城H5会员端域名
VITE_MALL_H5_DOMAIN='http://localhost:3000'
# 验证码的开关
VITE_APP_CAPTCHA_ENABLE=false
# GoView域名
VITE_GOVIEW_URL='http://127.0.0.1:3000'

34
.env.prod Normal file
View File

@@ -0,0 +1,34 @@
# 生产环境:只在打包时使用
NODE_ENV=production
VITE_DEV=true
# 请求路径
VITE_BASE_URL='http://118.253.178.8:48080'
# 文件上传类型server - 后端上传, client - 前端直连上传仅支持S3服务
VITE_UPLOAD_TYPE=server
# 接口地址
VITE_API_URL=/admin-api
# 是否删除debugger
VITE_DROP_DEBUGGER=true
# 是否删除console.log
VITE_DROP_CONSOLE=true
# 是否sourcemap
VITE_SOURCEMAP=false
# 打包路径
VITE_BASE_PATH=/
# 输出路径
VITE_OUT_DIR=dist-prod
# 商城H5会员端域名
VITE_MALL_H5_DOMAIN='http://mall.yudao.iocoder.cn'
# GoView域名
VITE_GOVIEW_URL='http://118.253.178.8:3030'

34
.env.stage Normal file
View File

@@ -0,0 +1,34 @@
# 预发布环境:只在打包时使用
NODE_ENV=production
VITE_DEV=false
# 请求路径
VITE_BASE_URL='http://118.253.178.8:48080'
# 文件上传类型server - 后端上传, client - 前端直连上传仅支持S3服务
VITE_UPLOAD_TYPE=server
# 接口地址
VITE_API_URL=/admin-api
# 是否删除debugger
VITE_DROP_DEBUGGER=true
# 是否删除console.log
VITE_DROP_CONSOLE=true
# 是否sourcemap
VITE_SOURCEMAP=false
# 打包路径
VITE_BASE_PATH='http://static-vue3.yudao.iocoder.cn/'
# 输出路径
VITE_OUT_DIR=dist-stage
# 商城H5会员端域名
VITE_MALL_H5_DOMAIN='http://mall.yudao.iocoder.cn'
# GoView域名
VITE_GOVIEW_URL='http://127.0.0.1:3000'

34
.env.test Normal file
View File

@@ -0,0 +1,34 @@
# 测试环境:只在打包时使用
NODE_ENV=production
VITE_DEV=false
# 请求路径
VITE_BASE_URL='http://localhost:48080'
# 文件上传类型server - 后端上传, client - 前端直连上传仅支持S3服务
VITE_UPLOAD_TYPE=server
# 接口地址
VITE_API_URL=/admin-api
# 是否删除debugger
VITE_DROP_DEBUGGER=true
# 是否删除console.log
VITE_DROP_CONSOLE=true
# 是否sourcemap
VITE_SOURCEMAP=false
# 打包路径
VITE_BASE_PATH=/admin-ui-vue3/
# 输出路径
VITE_OUT_DIR=dist-test
# 商城H5会员端域名
VITE_MALL_H5_DOMAIN='http://mall.yudao.iocoder.cn'
# GoView域名
VITE_GOVIEW_URL='http://127.0.0.1:3000'

8
.eslintignore Normal file
View File

@@ -0,0 +1,8 @@
/build/
/config/
/dist/
/*.js
/test/unit/coverage/
/node_modules/*
/dist*
/src/main.ts

259
.eslintrc-auto-import.json Normal file
View File

@@ -0,0 +1,259 @@
{
"globals": {
"EffectScope": true,
"ElMessage": true,
"ElMessageBox": true,
"ElTag": 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,
"createReactiveFn": true,
"createSharedComposable": true,
"createUnrefFn": true,
"customRef": true,
"debouncedRef": true,
"debouncedWatch": true,
"defineAsyncComponent": true,
"defineComponent": true,
"eagerComputed": true,
"effectScope": true,
"extendRef": true,
"getCurrentInstance": true,
"getCurrentScope": true,
"h": true,
"ignorableWatch": true,
"inject": true,
"isDefined": true,
"isProxy": true,
"isReactive": true,
"isReadonly": true,
"isRef": true,
"makeDestructurable": true,
"markRaw": true,
"nextTick": true,
"onActivated": true,
"onBeforeMount": true,
"onBeforeUnmount": true,
"onBeforeUpdate": true,
"onClickOutside": true,
"onDeactivated": true,
"onErrorCaptured": true,
"onKeyStroke": true,
"onLongPress": true,
"onMounted": true,
"onRenderTracked": true,
"onRenderTriggered": true,
"onScopeDispose": true,
"onServerPrefetch": true,
"onStartTyping": true,
"onUnmounted": true,
"onUpdated": true,
"pausableWatch": true,
"provide": 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,
"shallowReactive": true,
"shallowReadonly": true,
"shallowRef": true,
"syncRef": true,
"syncRefs": true,
"templateRef": true,
"throttledRef": true,
"throttledWatch": true,
"toRaw": true,
"toReactive": true,
"toRef": true,
"toRefs": true,
"triggerRef": true,
"tryOnBeforeMount": true,
"tryOnBeforeUnmount": true,
"tryOnMounted": true,
"tryOnScopeDispose": true,
"tryOnUnmounted": true,
"unref": true,
"unrefElement": true,
"until": true,
"useActiveElement": true,
"useArrayEvery": true,
"useArrayFilter": true,
"useArrayFind": true,
"useArrayFindIndex": true,
"useArrayJoin": true,
"useArrayMap": true,
"useArrayReduce": true,
"useArraySome": true,
"useAsyncQueue": true,
"useAsyncState": true,
"useAttrs": true,
"useBase64": true,
"useBattery": true,
"useBluetooth": true,
"useBreakpoints": true,
"useBroadcastChannel": true,
"useBrowserLocation": true,
"useCached": true,
"useClipboard": true,
"useColorMode": true,
"useConfirmDialog": 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,
"useIdle": true,
"useImage": true,
"useInfiniteScroll": true,
"useIntersectionObserver": true,
"useInterval": true,
"useIntervalFn": true,
"useKeyModifier": true,
"useLastChanged": true,
"useLocalStorage": true,
"useMagicKeys": true,
"useManualRefHistory": true,
"useMediaControls": true,
"useMediaQuery": true,
"useMemoize": true,
"useMemory": 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,
"usePermission": true,
"usePointer": true,
"usePointerSwipe": true,
"usePreferredColorScheme": true,
"usePreferredDark": true,
"usePreferredLanguages": true,
"useRafFn": true,
"useRefHistory": true,
"useResizeObserver": true,
"useRoute": true,
"useRouter": true,
"useScreenOrientation": true,
"useScreenSafeArea": true,
"useScriptTag": true,
"useScroll": true,
"useScrollLock": true,
"useSessionStorage": true,
"useShare": true,
"useSlots": true,
"useSpeechRecognition": true,
"useSpeechSynthesis": true,
"useStepper": true,
"useStorage": true,
"useStorageAsync": true,
"useStyleTag": true,
"useSupported": true,
"useSwipe": true,
"useTemplateRefsList": true,
"useTextDirection": true,
"useTextSelection": true,
"useTextareaAutosize": true,
"useThrottle": true,
"useThrottleFn": true,
"useThrottledRefHistory": true,
"useTimeAgo": true,
"useTimeout": true,
"useTimeoutFn": true,
"useTimeoutPoll": true,
"useTimestamp": true,
"useTitle": 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,
"watchEffect": true,
"watchIgnorable": true,
"watchOnce": true,
"watchPausable": true,
"watchPostEffect": true,
"watchSyncEffect": true,
"watchThrottled": true,
"watchTriggerable": true,
"watchWithFilter": true,
"whenever": true
}
}

75
.eslintrc.js Normal file
View File

@@ -0,0 +1,75 @@
// @ts-check
const { defineConfig } = require('eslint-define-config')
module.exports = defineConfig({
root: true,
env: {
browser: true,
node: true,
es6: true
},
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
ecmaVersion: 2020,
sourceType: 'module',
jsxPragma: 'React',
ecmaFeatures: {
jsx: true
}
},
extends: [
'plugin:vue/vue3-recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
'plugin:prettier/recommended',
'@unocss'
],
rules: {
'vue/no-setup-props-destructure': 'off',
'vue/script-setup-uses-vars': 'error',
'vue/no-reserved-component-names': 'off',
'@typescript-eslint/ban-ts-ignore': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-empty-function': 'off',
'vue/custom-event-name-casing': 'off',
'no-use-before-define': 'off',
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'no-unused-vars': 'off',
'space-before-function-paren': 'off',
'vue/attributes-order': 'off',
'vue/one-component-per-file': 'off',
'vue/html-closing-bracket-newline': 'off',
'vue/max-attributes-per-line': 'off',
'vue/multiline-html-element-content-newline': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/attribute-hyphenation': 'off',
'vue/require-default-prop': 'off',
'vue/require-explicit-emits': 'off',
'vue/require-toggle-inside-transition': 'off',
'vue/html-self-closing': [
'error',
{
html: {
void: 'always',
normal: 'never',
component: 'always'
},
svg: 'always',
math: 'always'
}
],
'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'off',
'prettier/prettier': 'off', // 芋艿:默认关闭 prettier 的 ESLint 校验,因为我们使用的是 IDE 的 Prettier 插件
'@unocss/order': 'off', // 芋艿:禁用 unocss 【css】顺序的提示因为暂时不需要这么严格警告也有点繁琐
'@unocss/order-attributify': 'off' // 芋艿:禁用 unocss 【属性】顺序的提示,因为暂时不需要这么严格,警告也有点繁琐
}
})

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules
.DS_Store
dist
dist-ssr
/dist*
pnpm-debug
auto-*.d.ts
.idea
.history

11
.prettierignore Normal file
View File

@@ -0,0 +1,11 @@
/node_modules/**
/dist/
/dist*
/public/*
/docs/*
/vite.config.ts
/src/types/env.d.ts
/src/types/auto-components.d.ts
/src/types/auto-imports.d.ts
/docs/**/*
CHANGELOG

6
.stylelintignore Normal file
View File

@@ -0,0 +1,6 @@
/dist/*
/public/*
public/*
/dist*
/src/types/env.d.ts
/docs/**/*

18
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,18 @@
{
"recommendations": [
"christian-kohler.path-intellisense",
"vscode-icons-team.vscode-icons",
"davidanson.vscode-markdownlint",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"mrmlnc.vscode-less",
"lokalise.i18n-ally",
"redhat.vscode-yaml",
"csstools.postcss",
"mikestead.dotenv",
"eamodio.gitlens",
"antfu.iconify",
"antfu.unocss",
"Vue.volar"
]
}

16
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,16 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "msedge",
"request": "launch",
"name": "Launch Edge against localhost",
"url": "http://localhost",
"webRoot": "${workspaceFolder}/src",
"sourceMaps": true
}
]
}

146
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,146 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"npm.packageManager": "pnpm",
"editor.tabSize": 2,
"prettier.printWidth": 100, // 超过最大值换行
"editor.defaultFormatter": "esbenp.prettier-vscode",
"files.eol": "\n",
"search.exclude": {
"**/node_modules": true,
"**/*.log": true,
"**/*.log*": true,
"**/bower_components": true,
"**/dist": true,
"**/elehukouben": true,
"**/.git": true,
"**/.gitignore": true,
"**/.svn": true,
"**/.DS_Store": true,
"**/.idea": true,
"**/.vscode": false,
"**/yarn.lock": true,
"**/tmp": true,
"out": true,
"dist": true,
"node_modules": true,
"CHANGELOG.md": true,
"examples": true,
"res": true,
"screenshots": true,
"yarn-error.log": true,
"**/.yarn": true
},
"files.exclude": {
"**/.cache": true,
"**/.editorconfig": true,
"**/.eslintcache": true,
"**/bower_components": true,
"**/.idea": true,
"**/tmp": true,
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/CVS": true,
"**/.DS_Store": true
},
"files.watcherExclude": {
"**/.git/objects/**": true,
"**/.git/subtree-cache/**": true,
"**/.vscode/**": true,
"**/node_modules/**": true,
"**/tmp/**": true,
"**/bower_components/**": true,
"**/dist/**": true,
"**/yarn.lock": true
},
"stylelint.enable": true,
"stylelint.validate": ["css", "less", "postcss", "scss", "vue", "sass"],
"path-intellisense.mappings": {
"@/": "${workspaceRoot}/src"
},
"[javascriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[less]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[scss]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[markdown]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.fixAll.stylelint": "explicit"
},
"editor.formatOnSave": true,
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"i18n-ally.localesPaths": ["src/locales"],
"i18n-ally.keystyle": "nested",
"i18n-ally.sortKeys": true,
"i18n-ally.namespace": false,
"i18n-ally.enabledParsers": ["ts"],
"i18n-ally.sourceLanguage": "en",
"i18n-ally.displayLanguage": "zh-CN",
"i18n-ally.enabledFrameworks": ["vue", "react"],
"cSpell.words": [
"brotli",
"browserslist",
"codemirror",
"commitlint",
"cropperjs",
"echart",
"echarts",
"esnext",
"esno",
"iconify",
"INTLIFY",
"lintstagedrc",
"logicflow",
"nprogress",
"pinia",
"pnpm",
"qrcode",
"sider",
"sortablejs",
"stylelint",
"svgs",
"unocss",
"unplugin",
"unref",
"videojs",
"VITE",
"vitejs",
"vueuse",
"wangeditor",
"xingyu",
"yudao",
"zxcvbn"
],
// 控制相关文件嵌套展示
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.expand": false,
"explorer.fileNesting.patterns": {
"*.ts": "$(capture).test.ts, $(capture).test.tsx",
"*.tsx": "$(capture).test.ts, $(capture).test.tsx",
"*.env": "$(capture).env.*",
"package.json": "pnpm-lock.yaml,yarn.lock,LICENSE,README*,CHANGELOG*,CNAME,.gitattributes,.eslintrc-auto-import.json,.gitignore,prettier.config.js,stylelint.config.js,commitlint.config.js,.stylelintignore,.prettierignore,.gitpod.yml,.eslintrc.js,.eslintignore"
},
"terminal.integrated.scrollback": 10000,
"nuxt.isNuxtApp": false
}

51
backend-idcard/README.md Normal file
View File

@@ -0,0 +1,51 @@
# 身份证识别(百度 OCR + AES
调用百度身份证 OCR 接口,对图片做 AES 加密上传,对返回的 `result` 密文做 Base64 解码 + AES 解密后解析出所需字段。
## 依赖
- JDK 8+
- [fastjson](https://github.com/alibaba/fastjson)(解析返回 JSON
## 配置
从百度控制台获取:
- **access_token**OAuth2 获取(或使用 API Key + Secret Key 换 token
- **aesKey**16 位 hex 字符串(控制台身份证 OCR 安全设置里)
## 使用示例
```java
// 构造识别器accessToken、aesKey 建议从配置文件或环境变量读取)
IdcardRecognizer recognizer = new IdcardRecognizer(accessToken, aesKey);
// 方式一:本地文件
IdcardRecognizer.IdcardResult result = recognizer.recognize("/path/to/idcard_front.jpg", "front");
if (result != null) {
System.out.println("姓名: " + result.getName());
System.out.println("身份证号: " + result.getIdNumber());
System.out.println("出生: " + result.getBirth());
System.out.println("住址: " + result.getAddress());
// ...
}
// 方式二:上传的图片字节(如 MultipartFile.getBytes()
byte[] imgBytes = ...;
IdcardRecognizer.IdcardResult back = recognizer.recognize(imgBytes, "back");
if (back != null) {
System.out.println("签发机关: " + back.getIssueAuthority());
System.out.println("有效期限: " + back.getValidDate());
}
```
## 返回字段说明
- **正面 (side=front)**`name` 姓名、`gender` 性别、`nation` 民族、`birth` 出生、`address` 住址、`idNumber` 公民身份号码
- **反面 (side=back)**`issueAuthority` 签发机关、`validDate` 有效期限
按百度接口约定,请求时 `id_card_side``front``back`
## 集成到现有后端
`com.ydoyun.ocr` 包拷贝到你的项目中,并加入 fastjson 依赖即可;若项目已使用 Spring可把 `IdcardRecognizer` 做成 Bean`accessToken``aesKey``@Value` 或配置类注入。

27
backend-idcard/pom.xml Normal file
View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.ydoyun</groupId>
<artifactId>idcard-ocr</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>Idcard OCR (Baidu)</name>
<description>百度身份证 OCR 调用AES 加解密)</description>
<properties>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.43</version>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,316 @@
package cn.iocoder.yudao.module.car.baidu;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
import java.util.List;
import java.util.Map;
/**
* 获取 token + 身份证识别(百度 OCRAES 加解密)。
*/
public class IdcardRecognizer {
private static final String IDCARD_OCR_URL = "https://aip.baidubce.com/rest/2.0/ocr/v1/idcard";
private final String accessToken;
private byte[] originAesKey;
public IdcardRecognizer(String accessToken, String aesKey) {
this.accessToken = accessToken;
try {
this.originAesKey = AesKeyUtil.parseAesKey(aesKey);
} catch (Exception e) {
throw new IllegalArgumentException("aesKey 非法,需 16 位 hex", e);
}
}
// ===================== 获取 token你原来的逻辑 =====================
/**
* 获取权限token
* @return 返回示例:
* {
* "access_token": "24.460da4889caad24cccdb1fea17221975.2592000.1491995545.282335-1234567",
* "expires_in": 2592000
* }
*/
public static String getAuth() {
String clientId = "mYchXvYyrwXbTscZ0HWfR88s";
String clientSecret = "AD4QSVg7OMP0GGkcqxkDSwrn7V7rkShN";
return getAuth(clientId, clientSecret);
}
/**
* 获取API访问token
* @param ak - 百度云官网获取的 API Key
* @param sk - 百度云官网获取的 Securet Key
* @return assess_token
*/
public static String getAuth(String ak, String sk) {
String authHost = "https://aip.baidubce.com/oauth/2.0/token?";
String getAccessTokenUrl = authHost
+ "grant_type=client_credentials"
+ "&client_id=" + ak
+ "&client_secret=" + sk;
try {
URL realUrl = new URL(getAccessTokenUrl);
HttpURLConnection connection = (HttpURLConnection) realUrl.openConnection();
connection.setRequestMethod("GET");
connection.connect();
Map<String, List<String>> map = connection.getHeaderFields();
for (String key : map.keySet()) {
System.err.println(key + "--->" + map.get(key));
}
BufferedReader in = new BufferedReader(new InputStreamReader(connection.getInputStream()));
String result = "";
String line;
while ((line = in.readLine()) != null) {
result += line;
}
System.err.println("result:" + result);
JSONObject jsonObject = JSONObject.parseObject(result);
String access_token = jsonObject.getString("access_token");
return access_token;
} catch (Exception e) {
System.err.printf("获取token失败");
e.printStackTrace(System.err);
}
return null;
}
// ===================== 身份证识别AES 加解密) =====================
/**
* 识别身份证图片(本地文件路径)
* @param filePath 图片本地路径
* @param side 正面 "front" 或反面 "back"
* @return 解析后的身份证字段,识别失败返回 null
*/
public IdcardResult recognize(String filePath, String side) throws Exception {
byte[] imgData = Files.readAllBytes(Paths.get(filePath));
return recognize(imgData, side);
}
/**
* 识别身份证图片(字节数组,适合上传场景)
* @param imgData 图片字节
* @param side 正面 "front" 或反面 "back"
* @return 解析后的身份证字段,识别失败返回 null
*/
public IdcardResult recognize(byte[] imgData, String side) throws Exception {
String plainJson = recognizePlainJson(imgData, side);
return (plainJson == null || plainJson.isEmpty()) ? null : parseToResult(plainJson, side);
}
/**
* 识别并返回解密后的明文 JSON便于调试/落库)。
*/
public String recognizePlainJson(byte[] imgData, String side) throws Exception {
String imgStr = encryptImg(imgData);
String imgParam = URLEncoder.encode(imgStr, "UTF-8");
String body = "id_card_side=" + side + "&image=" + imgParam + "&AESEncry=true";
String rawResponse = HttpUtil.post(IDCARD_OCR_URL, accessToken, body);
if (rawResponse == null || rawResponse.isEmpty()) {
return null;
}
return decryptResult(rawResponse);
}
private String encryptImg(byte[] imgData) throws Exception {
byte[] encImgBytes = AesUtil.encrypt(imgData, originAesKey);
return Base64.getEncoder().encodeToString(encImgBytes);
}
private String decryptResult(String encryptResponse) throws Exception {
JSONObject obj = JSON.parseObject(encryptResponse);
String result = obj.getString("result");
if (result == null) return null;
byte[] arr = Base64.getDecoder().decode(result);
return new String(AesUtil.decrypt(arr, originAesKey), "UTF-8");
}
private IdcardResult parseToResult(String plainJson, String side) {
JSONObject root = JSON.parseObject(plainJson);
JSONObject wordsResult = root.getJSONObject("words_result");
if (wordsResult == null) return null;
IdcardResult result = new IdcardResult();
result.setSide("front".equalsIgnoreCase(side) ? "front" : "back");
if ("front".equalsIgnoreCase(side)) {
result.setName(getWords(wordsResult, "姓名"));
result.setGender(getWords(wordsResult, "性别"));
result.setNation(getWords(wordsResult, "民族"));
result.setBirth(getWords(wordsResult, "出生"));
result.setAddress(getWords(wordsResult, "住址"));
result.setIdNumber(getWords(wordsResult, "公民身份号码"));
} else {
result.setIssueAuthority(getWords(wordsResult, "签发机关"));
result.setValidDate(getWords(wordsResult, "有效期限"));
}
return result;
}
private static String getWords(JSONObject wordsResult, String key) {
if (wordsResult == null) return null;
JSONObject item = wordsResult.getJSONObject(key);
return item == null ? null : item.getString("words");
}
// --------------- 结果 DTO ---------------
public static class IdcardResult {
private String side;
private String name;
private String gender;
private String nation;
private String birth;
private String address;
private String idNumber;
private String issueAuthority;
private String validDate;
public String getSide() { return side; }
public void setSide(String side) { this.side = side; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getGender() { return gender; }
public void setGender(String gender) { this.gender = gender; }
public String getNation() { return nation; }
public void setNation(String nation) { this.nation = nation; }
public String getBirth() { return birth; }
public void setBirth(String birth) { this.birth = birth; }
public String getAddress() { return address; }
public void setAddress(String address) { this.address = address; }
public String getIdNumber() { return idNumber; }
public void setIdNumber(String idNumber) { this.idNumber = idNumber; }
public String getIssueAuthority() { return issueAuthority; }
public void setIssueAuthority(String issueAuthority) { this.issueAuthority = issueAuthority; }
public String getValidDate() { return validDate; }
public void setValidDate(String validDate) { this.validDate = validDate; }
}
// --------------- 工具类 ---------------
static class HttpUtil {
static String post(String url, String accessToken, String formBody) throws IOException {
String fullUrl = url + "?access_token=" + URLEncoder.encode(accessToken, "UTF-8");
byte[] bodyBytes = formBody.getBytes(StandardCharsets.UTF_8);
HttpURLConnection conn = (HttpURLConnection) new URL(fullUrl).openConnection();
conn.setRequestMethod("POST");
conn.setConnectTimeout(15000);
conn.setReadTimeout(30000);
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
conn.setRequestProperty("Accept", "application/json");
OutputStream os = conn.getOutputStream();
try {
os.write(bodyBytes);
} finally {
os.close();
}
int code = conn.getResponseCode();
InputStream is = (code >= 200 && code < 300) ? conn.getInputStream() : conn.getErrorStream();
if (is == null) return null;
try {
byte[] bytes = readAllBytes(is);
return new String(bytes, "UTF-8");
} finally {
conn.disconnect();
}
}
private static byte[] readAllBytes(InputStream in) throws IOException {
byte[] buf = new byte[4096];
int n;
ByteArrayOutputStream bos = new ByteArrayOutputStream();
while ((n = in.read(buf)) >= 0) {
if (n > 0) bos.write(buf, 0, n);
}
return bos.toByteArray();
}
}
static class AesKeyUtil {
private static final String HEX = "0123456789abcdef";
static byte[] parseAesKey(String hex) throws Exception {
if (hex == null || hex.length() != 16) {
throw new Exception("aes key 需为 16 位");
}
char[] data = hex.toCharArray();
byte[] out = new byte[data.length];
for (int i = 0; i < data.length; i++) {
int f = HEX.indexOf(data[i]);
if (f < 0) throw new Exception("aes key 需为 hex 字符");
out[i] = (byte) f;
}
return out;
}
}
static class AesUtil {
private static final String ALGORITHM = "AES";
private static final String ALGORITHM_STR = "AES/ECB/PKCS5Padding";
static byte[] encrypt(byte[] src, byte[] aesKey) throws Exception {
Cipher cipher = getCipher(aesKey, Cipher.ENCRYPT_MODE);
return cipher.doFinal(src);
}
static byte[] decrypt(byte[] src, byte[] aesKey) throws Exception {
Cipher cipher = getCipher(aesKey, Cipher.DECRYPT_MODE);
return cipher.doFinal(src);
}
private static Cipher getCipher(byte[] aesKey, int mode) throws Exception {
SecretKeySpec spec = new SecretKeySpec(aesKey, ALGORITHM);
Cipher cipher = Cipher.getInstance(ALGORITHM_STR);
cipher.init(mode, spec);
return cipher;
}
}
// --------------- 测试 main改下面三个变量后直接运行 ---------------
public static void main(String[] args) throws Exception {
// 1. 用你原来的 getAuth 拿 token
String accessToken = getAuth();
if (accessToken == null) {
System.out.println("获取 access_token 失败");
return;
}
// 2. 从控制台拿 16 位 aesKeyhex 字符串)
String aesKey = "填写16位aesKey";
String filePath = "填写身份证图片路径";
String side = "front"; // front 正面 / back 反面
IdcardRecognizer recognizer = new IdcardRecognizer(accessToken, aesKey);
IdcardResult result = recognizer.recognize(filePath, side);
System.out.println("========== 识别结果 ==========");
System.out.println(JSON.toJSONString(result, true));
}
}

View File

@@ -0,0 +1,254 @@
package com.ydoyun.ocr;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
/**
* 身份证识别器:调用百度 OCR 身份证接口AES 加密传输),解析并返回所需字段。
* 需配置accessToken、aesKey16 位 hex可从百度控制台获取。
*/
public class IdcardRecognizer {
private static final String IDCARD_OCR_URL = "https://aip.baidubce.com/rest/2.0/ocr/v1/idcard";
private final String accessToken;
private byte[] originAesKey;
public IdcardRecognizer(String accessToken, String aesKey) {
this.accessToken = accessToken;
try {
this.originAesKey = AesKeyUtil.parseAesKey(aesKey);
} catch (Exception e) {
throw new IllegalArgumentException("aesKey 非法,需 16 位 hex", e);
}
}
/**
* 识别身份证图片(本地文件路径)
*
* @param filePath 图片本地路径
* @param side 正面 "front" 或反面 "back"
* @return 解析后的身份证字段,识别失败返回 null
*/
public IdcardResult recognize(String filePath, String side) throws IOException, Exception {
byte[] imgData = Files.readAllBytes(Paths.get(filePath));
return recognize(imgData, side);
}
/**
* 识别身份证图片(字节数组,适合上传场景)
*
* @param imgData 图片字节
* @param side 正面 "front" 或反面 "back"
* @return 解析后的身份证字段,识别失败返回 null
*/
public IdcardResult recognize(byte[] imgData, String side) throws Exception {
String plainJson = recognizePlainJson(imgData, side);
return (plainJson == null || plainJson.isEmpty()) ? null : parseToResult(plainJson, side);
}
/**
* 识别并返回解密后的明文 JSON便于调试/落库/自行解析)。
*/
public String recognizePlainJson(byte[] imgData, String side) throws Exception {
String imgStr = encryptImg(imgData);
String imgParam = URLEncoder.encode(imgStr, StandardCharsets.UTF_8.name());
String body = "id_card_side=" + side + "&image=" + imgParam + "&AESEncry=true";
String rawResponse = HttpUtil.post(IDCARD_OCR_URL, accessToken, body);
if (rawResponse == null || rawResponse.isEmpty()) {
return null;
}
return decryptResult(rawResponse);
}
private String encryptImg(byte[] imgData) throws Exception {
byte[] encImgBytes = AesUtil.encrypt(imgData, originAesKey);
return Base64.getEncoder().encodeToString(encImgBytes);
}
private String decryptResult(String encryptResponse) throws Exception {
JSONObject obj = JSON.parseObject(encryptResponse);
String result = obj.getString("result");
if (result == null) return null;
byte[] arr = Base64.getDecoder().decode(result);
return new String(AesUtil.decrypt(arr, originAesKey), StandardCharsets.UTF_8);
}
private IdcardResult parseToResult(String plainJson, String side) {
JSONObject root = JSON.parseObject(plainJson);
JSONObject wordsResult = root.getJSONObject("words_result");
if (wordsResult == null) return null;
IdcardResult result = new IdcardResult();
result.setSide("front".equalsIgnoreCase(side) ? "front" : "back");
if ("front".equalsIgnoreCase(side)) {
result.setName(getWords(wordsResult, "姓名"));
result.setGender(getWords(wordsResult, "性别"));
result.setNation(getWords(wordsResult, "民族"));
result.setBirth(getWords(wordsResult, "出生"));
result.setAddress(getWords(wordsResult, "住址"));
result.setIdNumber(getWords(wordsResult, "公民身份号码"));
} else {
result.setIssueAuthority(getWords(wordsResult, "签发机关"));
result.setValidDate(getWords(wordsResult, "有效期限"));
}
return result;
}
private static String getWords(JSONObject wordsResult, String key) {
if (wordsResult == null) return null;
JSONObject item = wordsResult.getJSONObject(key);
return item == null ? null : item.getString("words");
}
// --------------- 结果 DTO ---------------
public static class IdcardResult {
private String side;
private String name;
private String gender;
private String nation;
private String birth;
private String address;
private String idNumber;
private String issueAuthority;
private String validDate;
public String getSide() { return side; }
public void setSide(String side) { this.side = side; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getGender() { return gender; }
public void setGender(String gender) { this.gender = gender; }
public String getNation() { return nation; }
public void setNation(String nation) { this.nation = nation; }
public String getBirth() { return birth; }
public void setBirth(String birth) { this.birth = birth; }
public String getAddress() { return address; }
public void setAddress(String address) { this.address = address; }
public String getIdNumber() { return idNumber; }
public void setIdNumber(String idNumber) { this.idNumber = idNumber; }
public String getIssueAuthority() { return issueAuthority; }
public void setIssueAuthority(String issueAuthority) { this.issueAuthority = issueAuthority; }
public String getValidDate() { return validDate; }
public void setValidDate(String validDate) { this.validDate = validDate; }
}
// --------------- 工具类 ---------------
static class HttpUtil {
static String post(String url, String accessToken, String formBody) throws IOException {
String fullUrl = url + "?access_token=" + URLEncoder.encode(accessToken, "UTF-8");
byte[] bodyBytes = formBody.getBytes(StandardCharsets.UTF_8);
HttpURLConnection conn = (HttpURLConnection) new URL(fullUrl).openConnection();
conn.setRequestMethod("POST");
conn.setConnectTimeout(15_000);
conn.setReadTimeout(30_000);
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
conn.setRequestProperty("Accept", "application/json");
try (OutputStream os = conn.getOutputStream()) {
os.write(bodyBytes);
}
int code = conn.getResponseCode();
InputStream is = (code >= 200 && code < 300) ? conn.getInputStream() : conn.getErrorStream();
if (is == null) return null;
try (InputStream in = is) {
byte[] bytes = readAllBytes(in);
return new String(bytes, StandardCharsets.UTF_8);
} finally {
conn.disconnect();
}
}
private static byte[] readAllBytes(InputStream in) throws IOException {
byte[] buf = new byte[4096];
int n;
ByteArrayOutputStream bos = new ByteArrayOutputStream();
while ((n = in.read(buf)) >= 0) {
if (n > 0) bos.write(buf, 0, n);
}
return bos.toByteArray();
}
}
static class AesKeyUtil {
private static final String HEX = "0123456789abcdef";
static byte[] parseAesKey(String hex) throws Exception {
if (hex == null || hex.length() != 16) {
throw new Exception("aes key 需为 16 位");
}
char[] data = hex.toCharArray();
byte[] out = new byte[data.length];
for (int i = 0; i < data.length; i++) {
int f = HEX.indexOf(data[i]);
if (f < 0) throw new Exception("aes key 需为 hex 字符");
out[i] = (byte) f;
}
return out;
}
}
static class AesUtil {
private static final String ALGORITHM = "AES";
private static final String ALGORITHM_STR = "AES/ECB/PKCS5Padding";
static byte[] encrypt(byte[] src, byte[] aesKey) throws Exception {
Cipher cipher = getCipher(aesKey, Cipher.ENCRYPT_MODE);
return cipher.doFinal(src);
}
static byte[] decrypt(byte[] src, byte[] aesKey) throws Exception {
Cipher cipher = getCipher(aesKey, Cipher.DECRYPT_MODE);
return cipher.doFinal(src);
}
private static Cipher getCipher(byte[] aesKey, int mode) throws Exception {
SecretKeySpec spec = new SecretKeySpec(aesKey, ALGORITHM);
Cipher cipher = Cipher.getInstance(ALGORITHM_STR);
cipher.init(mode, spec);
return cipher;
}
}
/**
* 本地快速测试入口JDK8 可运行,直接改下面四个变量再运行)。
*/
public static void main(String[] args) throws Exception {
// TODO替换成你自己的配置
String accessToken = "填写你的 access_token";
String aesKey = "填写 16 位 aesKeyhex 字符串)";
String side = "front"; // front 正面 / back 反面
String filePath = "填写身份证图片本地路径";
IdcardRecognizer recognizer = new IdcardRecognizer(accessToken, aesKey);
IdcardResult result = recognizer.recognize(filePath, side);
System.out.println(JSON.toJSONString(result, true));
// 如果你要看解密后的明文 JSON排查字段/定位问题),放开下面三行:
// byte[] img = Files.readAllBytes(Paths.get(filePath));
// String plain = recognizer.recognizePlainJson(img, side);
// System.out.println(plain);
}
}

Binary file not shown.

View File

@@ -0,0 +1,3 @@
artifactId=idcard-ocr
groupId=com.ydoyun
version=1.0.0

View File

@@ -0,0 +1,5 @@
com/ydoyun/ocr/IdcardRecognizer$HttpUtil.class
com/ydoyun/ocr/IdcardRecognizer$AesKeyUtil.class
com/ydoyun/ocr/IdcardRecognizer$IdcardResult.class
com/ydoyun/ocr/IdcardRecognizer$AesUtil.class
com/ydoyun/ocr/IdcardRecognizer.class

View File

@@ -0,0 +1 @@
/Users/ouhaolan/project/百盛科技/报表/yudao-ui-admin-vue3/backend-idcard/src/main/java/com/ydoyun/ocr/IdcardRecognizer.java

100
build/vite/index.ts Normal file
View File

@@ -0,0 +1,100 @@
import { resolve } from 'path'
import Vue from '@vitejs/plugin-vue'
import VueJsx from '@vitejs/plugin-vue-jsx'
import progress from 'vite-plugin-progress'
import EslintPlugin from 'vite-plugin-eslint'
import PurgeIcons from 'vite-plugin-purge-icons'
import { ViteEjsPlugin } from 'vite-plugin-ejs'
// @ts-ignore
import ElementPlus from 'unplugin-element-plus/vite'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import viteCompression from 'vite-plugin-compression'
import topLevelAwait from 'vite-plugin-top-level-await'
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
import UnoCSS from 'unocss/vite'
export function createVitePlugins() {
const root = process.cwd()
// 路径查找
function pathResolve(dir: string) {
return resolve(root, '.', dir)
}
return [
Vue(),
VueJsx(),
UnoCSS(),
progress(),
PurgeIcons(),
ElementPlus({}),
AutoImport({
include: [
/\.[tj]sx?$/, // .ts, .tsx, .js, .jsx
/\.vue$/,
/\.vue\?vue/, // .vue
/\.md$/ // .md
],
imports: [
'vue',
'vue-router',
// 可额外添加需要 autoImport 的组件
{
'@/hooks/web/useI18n': ['useI18n'],
'@/hooks/web/useMessage': ['useMessage'],
'@/hooks/web/useTable': ['useTable'],
'@/hooks/web/useCrudSchemas': ['useCrudSchemas'],
'@/utils/formRules': ['required'],
'@/utils/dict': ['DICT_TYPE']
}
],
dts: 'src/types/auto-imports.d.ts',
resolvers: [ElementPlusResolver()],
eslintrc: {
enabled: false, // Default `false`
filepath: './.eslintrc-auto-import.json', // Default `./.eslintrc-auto-import.json`
globalsPropValue: true // Default `true`, (true | false | 'readonly' | 'readable' | 'writable' | 'writeable')
}
}),
Components({
// 生成自定义 `auto-components.d.ts` 全局声明
dts: 'src/types/auto-components.d.ts',
// 自定义组件的解析器
resolvers: [ElementPlusResolver()],
globs: ["src/components/**/**.{vue, md}", '!src/components/DiyEditor/components/mobile/**']
}),
EslintPlugin({
cache: false,
include: ['src/**/*.vue', 'src/**/*.ts', 'src/**/*.tsx'] // 检查的文件
}),
VueI18nPlugin({
runtimeOnly: true,
compositionOnly: true,
include: [resolve(__dirname, 'src/locales/**')]
}),
createSvgIconsPlugin({
iconDirs: [pathResolve('src/assets/svgs')],
symbolId: 'icon-[dir]-[name]',
svgoOptions: true
}),
viteCompression({
verbose: true, // 是否在控制台输出压缩结果
disable: false, // 是否禁用
threshold: 10240, // 体积大于 threshold 才会被压缩,单位 b
algorithm: 'gzip', // 压缩算法,可选 [ 'gzip' , 'brotliCompress' ,'deflate' , 'deflateRaw']
ext: '.gz', // 生成的压缩包后缀
deleteOriginFile: false //压缩后是否删除源文件
}),
ViteEjsPlugin(),
topLevelAwait({
// https://juejin.cn/post/7152191742513512485
// The export name of top-level await promise for each chunk module
promiseExportName: '__tla',
// The function to generate import names of top-level await promise in each chunk module
promiseImportName: (i) => `__tla_${i}`
})
]
}

122
build/vite/optimize.ts Normal file
View File

@@ -0,0 +1,122 @@
const include = [
'qs',
'url',
'vue',
'sass',
'mitt',
'axios',
'pinia',
'dayjs',
'qrcode',
'unocss',
'vue-router',
'vue-types',
'vue-i18n',
'crypto-js',
'cropperjs',
'lodash-es',
'nprogress',
'web-storage-cache',
'@iconify/iconify',
'@vueuse/core',
'@zxcvbn-ts/core',
'echarts/core',
'echarts/charts',
'echarts/components',
'echarts/renderers',
'echarts-wordcloud',
'@wangeditor/editor',
'@wangeditor/editor-for-vue',
'@microsoft/fetch-event-source',
'markdown-it',
'markmap-view',
'markmap-lib',
'markmap-toolbar',
'highlight.js',
'element-plus',
'element-plus/es',
'element-plus/es/locale/lang/zh-cn',
'element-plus/es/locale/lang/en',
'element-plus/es/components/avatar/style/css',
'element-plus/es/components/space/style/css',
'element-plus/es/components/backtop/style/css',
'element-plus/es/components/form/style/css',
'element-plus/es/components/radio-group/style/css',
'element-plus/es/components/radio/style/css',
'element-plus/es/components/checkbox/style/css',
'element-plus/es/components/checkbox-group/style/css',
'element-plus/es/components/switch/style/css',
'element-plus/es/components/time-picker/style/css',
'element-plus/es/components/date-picker/style/css',
'element-plus/es/components/descriptions/style/css',
'element-plus/es/components/descriptions-item/style/css',
'element-plus/es/components/link/style/css',
'element-plus/es/components/tooltip/style/css',
'element-plus/es/components/drawer/style/css',
'element-plus/es/components/dialog/style/css',
'element-plus/es/components/checkbox-button/style/css',
'element-plus/es/components/option-group/style/css',
'element-plus/es/components/radio-button/style/css',
'element-plus/es/components/cascader/style/css',
'element-plus/es/components/color-picker/style/css',
'element-plus/es/components/input-number/style/css',
'element-plus/es/components/rate/style/css',
'element-plus/es/components/select-v2/style/css',
'element-plus/es/components/tree-select/style/css',
'element-plus/es/components/slider/style/css',
'element-plus/es/components/time-select/style/css',
'element-plus/es/components/autocomplete/style/css',
'element-plus/es/components/image-viewer/style/css',
'element-plus/es/components/upload/style/css',
'element-plus/es/components/col/style/css',
'element-plus/es/components/form-item/style/css',
'element-plus/es/components/alert/style/css',
'element-plus/es/components/breadcrumb/style/css',
'element-plus/es/components/select/style/css',
'element-plus/es/components/input/style/css',
'element-plus/es/components/breadcrumb-item/style/css',
'element-plus/es/components/tag/style/css',
'element-plus/es/components/pagination/style/css',
'element-plus/es/components/table/style/css',
'element-plus/es/components/table-v2/style/css',
'element-plus/es/components/table-column/style/css',
'element-plus/es/components/card/style/css',
'element-plus/es/components/row/style/css',
'element-plus/es/components/button/style/css',
'element-plus/es/components/menu/style/css',
'element-plus/es/components/sub-menu/style/css',
'element-plus/es/components/menu-item/style/css',
'element-plus/es/components/option/style/css',
'element-plus/es/components/dropdown/style/css',
'element-plus/es/components/dropdown-menu/style/css',
'element-plus/es/components/dropdown-item/style/css',
'element-plus/es/components/skeleton/style/css',
'element-plus/es/components/skeleton/style/css',
'element-plus/es/components/backtop/style/css',
'element-plus/es/components/menu/style/css',
'element-plus/es/components/sub-menu/style/css',
'element-plus/es/components/menu-item/style/css',
'element-plus/es/components/dropdown/style/css',
'element-plus/es/components/tree/style/css',
'element-plus/es/components/dropdown-menu/style/css',
'element-plus/es/components/dropdown-item/style/css',
'element-plus/es/components/badge/style/css',
'element-plus/es/components/breadcrumb/style/css',
'element-plus/es/components/breadcrumb-item/style/css',
'element-plus/es/components/image/style/css',
'element-plus/es/components/collapse-transition/style/css',
'element-plus/es/components/timeline/style/css',
'element-plus/es/components/timeline-item/style/css',
'element-plus/es/components/collapse/style/css',
'element-plus/es/components/collapse-item/style/css',
'element-plus/es/components/button-group/style/css',
'element-plus/es/components/text/style/css',
'element-plus/es/components/segmented/style/css',
'@element-plus/icons-vue',
'element-plus/es/components/footer/style/css',
'element-plus/es/components/empty/style/css'
]
const exclude = ['@iconify/json']
export { include, exclude }

View File

@@ -0,0 +1,311 @@
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible" width="560px">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="110px"
v-loading="formLoading"
>
<el-form-item label="标签名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入标签名称" maxlength="50" show-word-limit />
<div class="form-tip">用于报表中展示的标签名称</div>
</el-form-item>
<el-form-item label="公式描述" prop="expression">
<el-input
v-model="formData.expression"
type="textarea"
:rows="2"
placeholder="请输入公式描述,如:${region_name}"
/>
<div class="form-tip">报表中引用的表达式支持 ${字段名} 格式</div>
</el-form-item>
<el-form-item label="标签颜色" prop="color">
<div class="color-field">
<div class="color-presets">
<span
v-for="c in presetColors"
:key="c"
class="color-chip"
:class="{ active: formData.color === c }"
:style="{ backgroundColor: c }"
@click="formData.color = c"
></span>
</div>
<div class="color-picker-wrap">
<el-color-picker v-model="formData.color" :predefine="presetColors" />
<el-input v-model="formData.color" placeholder="或输入颜色值" class="color-input" maxlength="20" />
</div>
</div>
<div class="form-tip">用于报表展示时的颜色标识</div>
</el-form-item>
<el-form-item label="代入参数" prop="useParams">
<el-radio-group v-model="formData.useParams">
<el-radio :value="1"></el-radio>
<el-radio :value="0"></el-radio>
</el-radio-group>
<div class="form-tip">是否将报表参数代入 SQL 中执行</div>
</el-form-item>
<el-form-item v-if="formData.useParams === 1" label="参数列表" prop="params">
<el-input v-model="formData.params" placeholder="如startDate,endDate" />
<div class="form-tip">需代入的参数名多个用逗号分隔</div>
</el-form-item>
<el-form-item label="SQL 脚本" prop="sqlScript">
<div class="sql-script-field">
<div v-if="formData.useParams === 1" class="param-insert-bar">
<span class="param-label">插入参数变量</span>
<el-select
v-model="selectedParamIndex"
:placeholder="paramList.length > 0 ? '选择参数后点击插入' : '请先填写上方参数列表'"
clearable
class="param-select"
:disabled="paramList.length === 0"
>
<el-option
v-for="(p, idx) in paramList"
:key="idx"
:label="`参数${idx + 1}: ${p}`"
:value="idx"
/>
</el-select>
<el-button
type="primary"
size="small"
:disabled="paramList.length === 0 || selectedParamIndex === null"
@click="selectedParamIndex != null ? insertParamVar(selectedParamIndex) : null"
>
插入
</el-button>
</div>
<el-input
ref="sqlScriptInputRef"
v-model="formData.sqlScript"
type="textarea"
:rows="4"
:placeholder="formData.useParams === 1 ? '请输入 SQL参数变量请通过上方下拉选择并插入' : '请输入 SQL 脚本'"
@blur="saveSqlCursor"
@focus="saveSqlCursor"
/>
<div v-if="formData.useParams === 1 && formData.sqlScript" class="sql-preview">
<span class="preview-label">预览参数以块显示</span>
<div class="sql-preview-content">
<template v-for="(seg, i) in sqlSegments" :key="i">
<el-tag v-if="seg.type === 'var'" type="primary" size="small" class="sql-var-tag">
{{ seg.text }}
</el-tag>
<span v-else class="sql-text">{{ seg.text }}</span>
</template>
</div>
</div>
</div>
<div class="form-tip">
<template v-if="formData.useParams === 1">参数变量通过下拉选择插入执行时 ${参数1}${参数2} 等将替换为对应参数值</template>
<template v-else>执行 SQL 获取标签选项第一列作为显示值</template>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { CustomTagApi, CustomTag, CustomTagType } from '@/api/ydoyun/customtag'
defineOptions({ name: 'CustomTagFormWithParams' })
const props = defineProps<{
tagType: CustomTagType
}>()
const { t } = useI18n()
const message = useMessage()
const dialogVisible = ref(false)
const dialogTitle = ref('')
const formLoading = ref(false)
const formType = ref('')
const formData = ref<Partial<CustomTag>>({
id: undefined,
type: undefined,
name: undefined,
expression: undefined,
color: undefined,
sqlScript: undefined,
useParams: 0,
params: undefined
})
const formRules = reactive({
name: [{ required: true, message: '标签名称不能为空', trigger: 'blur' }]
})
const formRef = ref()
const sqlScriptInputRef = ref()
const sqlCursorPos = ref({ start: 0, end: 0 })
const selectedParamIndex = ref<number | null>(null)
const paramList = computed(() => {
const p = formData.value.params
if (!p || typeof p !== 'string') return []
return p.split(',').map((s) => s.trim()).filter(Boolean)
})
const saveSqlCursor = () => {
const textarea = sqlScriptInputRef.value?.$el?.querySelector('textarea')
if (textarea) {
sqlCursorPos.value = { start: textarea.selectionStart, end: textarea.selectionEnd }
}
}
const insertParamVar = (index: number) => {
const variable = `\${参数${index + 1}}`
const current = formData.value.sqlScript || ''
const { start, end } = sqlCursorPos.value
const before = current.slice(0, start)
const after = current.slice(end)
formData.value.sqlScript = before + variable + after
selectedParamIndex.value = null
nextTick(() => {
sqlCursorPos.value = { start: start + variable.length, end: start + variable.length }
const textarea = sqlScriptInputRef.value?.$el?.querySelector('textarea')
if (textarea) {
textarea.focus()
textarea.setSelectionRange(start + variable.length, start + variable.length)
}
})
}
const sqlSegments = computed(() => {
const sql = formData.value.sqlScript || ''
const regex = /\$\{([^}]+)\}/g
const segments: { type: 'text' | 'var'; text: string }[] = []
let lastIndex = 0
let m
while ((m = regex.exec(sql)) !== null) {
if (m.index > lastIndex) {
segments.push({ type: 'text', text: sql.slice(lastIndex, m.index) })
}
segments.push({ type: 'var', text: m[0] })
lastIndex = regex.lastIndex
}
if (lastIndex < sql.length) {
segments.push({ type: 'text', text: sql.slice(lastIndex) })
}
return segments
})
const validateSqlParams = () => {
if (formData.value.useParams !== 1) return true
const sql = formData.value.sqlScript || ''
const maxParam = paramList.value.length
const regex = /\$\{参数(\d+)\}/g
let m
const invalid: string[] = []
while ((m = regex.exec(sql)) !== null) {
const n = parseInt(m[1], 10)
if (n < 1 || n > maxParam) {
invalid.push(m[0])
}
}
if (invalid.length > 0) {
message.error(`SQL 中的参数变量 ${invalid.join('、')} 超出参数列表范围参数1~参数${maxParam}`)
return false
}
return true
}
const presetColors = [
'#1890ff', '#52c41a', '#faad14', '#f5222d',
'#722ed1', '#eb2f96', '#13c2c2', '#fa8c16'
]
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
if (type === 'create') {
formData.value.type = props.tagType
}
if (id) {
formLoading.value = true
try {
const data = await CustomTagApi.getCustomTag(id)
formData.value = {
...data,
useParams: data.useParams === true || data.useParams === 1 ? 1 : 0
}
} finally {
formLoading.value = false
}
}
}
defineExpose({ open })
const emit = defineEmits(['success'])
const submitForm = async () => {
await formRef.value.validate()
if (!validateSqlParams()) return
formLoading.value = true
try {
const data = formData.value as CustomTag
if (formType.value === 'create') {
await CustomTagApi.createCustomTag(data)
message.success(t('common.createSuccess'))
} else {
await CustomTagApi.updateCustomTag(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
emit('success')
} finally {
formLoading.value = false
}
}
const resetForm = () => {
formData.value = {
id: undefined,
type: props.tagType,
name: undefined,
expression: undefined,
color: undefined,
sqlScript: undefined,
useParams: 0,
params: undefined
}
formRef.value?.resetFields()
}
</script>
<style scoped>
.form-tip { font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.4; margin-top: 4px; }
.color-field { width: 100%; }
.color-presets { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.color-chip {
width: 28px; height: 28px; border-radius: 6px; cursor: pointer; border: 2px solid transparent; transition: all 0.2s;
}
.color-chip:hover { transform: scale(1.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); }
.color-chip.active { border-color: var(--el-color-primary); box-shadow: 0 0 0 2px var(--el-color-primary-light-7); }
.color-picker-wrap { display: flex; align-items: center; gap: 12px; }
.color-picker-wrap .color-input { flex: 1; min-width: 120px; }
.sql-script-field { width: 100%; }
.param-insert-bar {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 8px;
padding: 8px 12px; background: var(--el-fill-color-light); border-radius: 6px;
}
.param-label { font-size: 13px; color: var(--el-text-color-secondary); margin-right: 4px; }
.param-select { width: 180px; }
.sql-preview {
margin-top: 10px; padding: 10px 12px; background: var(--el-fill-color-lighter);
border-radius: 6px; font-family: monospace; font-size: 13px;
}
.preview-label { display: block; font-size: 12px; color: var(--el-text-color-secondary); margin-bottom: 8px; }
.sql-preview-content { line-height: 1.8; word-break: break-all; }
.sql-var-tag { margin: 0 2px; font-family: monospace; }
.sql-text { white-space: pre-wrap; }
</style>

View File

@@ -0,0 +1,190 @@
<template>
<ContentWrap>
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="90px"
>
<el-form-item label="标签名称" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入标签名称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="公式描述" prop="expression">
<el-input
v-model="queryParams.expression"
placeholder="请输入公式描述"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="创建时间" prop="createTime">
<el-date-picker
v-model="queryParams.createTime"
value-format="YYYY-MM-DD HH:mm:ss"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
class="!w-220px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" />搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" />重置</el-button>
<el-button type="primary" plain @click="openForm('create')" v-hasPermi="['ydoyun:custom-tag:create']">
<Icon icon="ep:plus" /> 新增
</el-button>
<el-button type="success" plain @click="handleExport" :loading="exportLoading" v-hasPermi="['ydoyun:custom-tag:export']">
<Icon icon="ep:download" /> 导出
</el-button>
<el-button type="danger" plain :disabled="isEmpty(checkedIds)" @click="handleDeleteBatch" v-hasPermi="['ydoyun:custom-tag:delete']">
<Icon icon="ep:delete" /> 批量删除
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<el-table
row-key="id"
v-loading="loading"
:data="list"
:stripe="true"
:show-overflow-tooltip="true"
@selection-change="handleRowCheckboxChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="标签名称" align="center" prop="name" min-width="120" :show-overflow-tooltip="true" />
<el-table-column label="公式描述" align="center" prop="expression" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="标签颜色" align="center" prop="color" width="100">
<template #default="scope">
<el-tag v-if="scope.row.color" :color="scope.row.color" effect="dark" size="small">{{ scope.row.color }}</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="代入参数" align="center" prop="useParams" width="90">
<template #default="scope">
<el-tag v-if="scope.row.useParams === true || scope.row.useParams === 1" type="success" size="small"></el-tag>
<el-tag v-else type="info" size="small"></el-tag>
</template>
</el-table-column>
<el-table-column label="参数列表" align="center" prop="params" min-width="120" :show-overflow-tooltip="true" />
<el-table-column label="创建时间" align="center" prop="createTime" :formatter="dateFormatter" width="180" />
<el-table-column label="操作" align="center" width="140" fixed="right">
<template #default="scope">
<el-button link type="primary" @click="openForm('update', scope.row.id)" v-hasPermi="['ydoyun:custom-tag:update']">
<Icon icon="ep:edit" /> 编辑
</el-button>
<el-button link type="danger" @click="handleDelete(scope.row.id)" v-hasPermi="['ydoyun:custom-tag:delete']">
<Icon icon="ep:delete" /> 删除
</el-button>
</template>
</el-table-column>
</el-table>
<Pagination :total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize" @pagination="getList" />
</ContentWrap>
<CustomTagFormWithParams ref="formRef" tag-type="product" @success="getList" />
</template>
<script setup lang="ts">
import { isEmpty } from '@/utils/is'
import { dateFormatter } from '@/utils/formatTime'
import download from '@/utils/download'
import { CustomTagApi, CustomTag } from '@/api/ydoyun/customtag'
import CustomTagFormWithParams from '@/views/ydoyun/customtag/CustomTagFormWithParams.vue'
defineOptions({ name: 'ProductCustomTag' })
const message = useMessage()
const { t } = useI18n()
const loading = ref(true)
const list = ref<CustomTag[]>([])
const total = ref(0)
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
type: 'product' as const,
name: undefined,
expression: undefined,
createTime: [] as string[]
})
const queryFormRef = ref()
const exportLoading = ref(false)
const getList = async () => {
loading.value = true
try {
const data = await CustomTagApi.getCustomTagPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
const resetQuery = () => {
queryFormRef.value?.resetFields()
queryParams.type = 'product'
handleQuery()
}
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
const handleDelete = async (id: number) => {
try {
await message.delConfirm()
await CustomTagApi.deleteCustomTag(id)
message.success(t('common.delSuccess'))
await getList()
} catch {}
}
const checkedIds = ref<number[]>([])
const handleRowCheckboxChange = (records: CustomTag[]) => {
checkedIds.value = records.map((item) => item.id!).filter(Boolean)
}
const handleDeleteBatch = async () => {
try {
await message.delConfirm()
await CustomTagApi.deleteCustomTagList(checkedIds.value)
checkedIds.value = []
message.success(t('common.delSuccess'))
await getList()
} catch {}
}
const handleExport = async () => {
try {
await message.exportConfirm()
exportLoading.value = true
const data = await CustomTagApi.exportCustomTag(queryParams)
download.excel(data, '产品自定义标签.xls')
} catch {
} finally {
exportLoading.value = false
}
}
onMounted(() => getList())
</script>

View File

@@ -0,0 +1,111 @@
<template>
<div class="head-container">
<el-input
v-model="dbName"
class="mb-20px"
clearable
placeholder="请输入数据源名称"
@keyup.enter="handleSearch"
>
<template #prefix>
<Icon icon="ep:search" />
</template>
</el-input>
<el-button type="primary" @click="handleSearch" style="width: 100%">
<Icon icon="ep:search" />查询
</el-button>
</div>
<div class="head-container">
<el-scrollbar height="calc(100vh - 200px)">
<el-table
v-loading="loading"
:data="databaseList"
highlight-current-row
@current-change="handleDatabaseChange"
style="width: 100%"
>
<el-table-column label="名称" prop="dbName" :show-overflow-tooltip="true" min-width="100" />
<el-table-column label="类型" prop="dbType" width="70">
<template #default="scope">
<el-tag v-if="scope.row.dbType === 'mysql'" type="success" size="small">MySQL</el-tag>
<el-tag v-else-if="scope.row.dbType === 'pgsql'" type="primary" size="small">PG</el-tag>
<el-tag v-else-if="scope.row.dbType === 'oracle'" type="warning" size="small">Oracle</el-tag>
<el-tag v-else-if="scope.row.dbType === 'sqlserver'" type="danger" size="small">SQL</el-tag>
<span v-else>{{ scope.row.dbType }}</span>
</template>
</el-table-column>
</el-table>
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
:small="true"
layout="prev, pager, next"
/>
</el-scrollbar>
</div>
</template>
<script lang="ts" setup>
import { ReportDatabaseApi, ReportDatabase } from '@/api/ydoyun/reportdatabase'
defineOptions({ name: 'DatabaseList' })
const dbName = ref('')
const databaseList = ref<ReportDatabase[]>([])
const loading = ref(false)
const total = ref(0)
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
dbName: undefined,
dbType: undefined
})
const emits = defineEmits(['node-click'])
/** 查询数据源列表 */
const getList = async () => {
loading.value = true
try {
const params = {
...queryParams,
dbName: dbName.value || undefined
}
const data = await ReportDatabaseApi.getReportDatabasePage(params)
databaseList.value = data.list || []
total.value = data.total || 0
} catch (error) {
console.error('查询数据源列表失败:', error)
databaseList.value = []
total.value = 0
} finally {
loading.value = false
}
}
/** 处理搜索 */
const handleSearch = () => {
queryParams.pageNo = 1
getList()
}
/** 处理数据源选择 */
const handleDatabaseChange = (row: ReportDatabase | null) => {
if (row) {
emits('node-click', row)
} else {
emits('node-click', undefined)
}
}
/** 初始化 */
onMounted(async () => {
await getList()
})
defineExpose({
getList
})
</script>

View File

@@ -0,0 +1,190 @@
<template>
<ContentWrap>
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="90px"
>
<el-form-item label="标签名称" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入标签名称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="公式描述" prop="expression">
<el-input
v-model="queryParams.expression"
placeholder="请输入公式描述"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="创建时间" prop="createTime">
<el-date-picker
v-model="queryParams.createTime"
value-format="YYYY-MM-DD HH:mm:ss"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
class="!w-220px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" />搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" />重置</el-button>
<el-button type="primary" plain @click="openForm('create')" v-hasPermi="['ydoyun:custom-tag:create']">
<Icon icon="ep:plus" /> 新增
</el-button>
<el-button type="success" plain @click="handleExport" :loading="exportLoading" v-hasPermi="['ydoyun:custom-tag:export']">
<Icon icon="ep:download" /> 导出
</el-button>
<el-button type="danger" plain :disabled="isEmpty(checkedIds)" @click="handleDeleteBatch" v-hasPermi="['ydoyun:custom-tag:delete']">
<Icon icon="ep:delete" /> 批量删除
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<el-table
row-key="id"
v-loading="loading"
:data="list"
:stripe="true"
:show-overflow-tooltip="true"
@selection-change="handleRowCheckboxChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="标签名称" align="center" prop="name" min-width="120" :show-overflow-tooltip="true" />
<el-table-column label="公式描述" align="center" prop="expression" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="标签颜色" align="center" prop="color" width="100">
<template #default="scope">
<el-tag v-if="scope.row.color" :color="scope.row.color" effect="dark" size="small">{{ scope.row.color }}</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="代入参数" align="center" prop="useParams" width="90">
<template #default="scope">
<el-tag v-if="scope.row.useParams === true || scope.row.useParams === 1" type="success" size="small"></el-tag>
<el-tag v-else type="info" size="small"></el-tag>
</template>
</el-table-column>
<el-table-column label="参数列表" align="center" prop="params" min-width="120" :show-overflow-tooltip="true" />
<el-table-column label="创建时间" align="center" prop="createTime" :formatter="dateFormatter" width="180" />
<el-table-column label="操作" align="center" width="140" fixed="right">
<template #default="scope">
<el-button link type="primary" @click="openForm('update', scope.row.id)" v-hasPermi="['ydoyun:custom-tag:update']">
<Icon icon="ep:edit" /> 编辑
</el-button>
<el-button link type="danger" @click="handleDelete(scope.row.id)" v-hasPermi="['ydoyun:custom-tag:delete']">
<Icon icon="ep:delete" /> 删除
</el-button>
</template>
</el-table-column>
</el-table>
<Pagination :total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize" @pagination="getList" />
</ContentWrap>
<CustomTagFormWithParams ref="formRef" tag-type="store" @success="getList" />
</template>
<script setup lang="ts">
import { isEmpty } from '@/utils/is'
import { dateFormatter } from '@/utils/formatTime'
import download from '@/utils/download'
import { CustomTagApi, CustomTag } from '@/api/ydoyun/customtag'
import CustomTagFormWithParams from '@/views/ydoyun/customtag/CustomTagFormWithParams.vue'
defineOptions({ name: 'StoreCustomTag' })
const message = useMessage()
const { t } = useI18n()
const loading = ref(true)
const list = ref<CustomTag[]>([])
const total = ref(0)
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
type: 'store' as const,
name: undefined,
expression: undefined,
createTime: [] as string[]
})
const queryFormRef = ref()
const exportLoading = ref(false)
const getList = async () => {
loading.value = true
try {
const data = await CustomTagApi.getCustomTagPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
const resetQuery = () => {
queryFormRef.value?.resetFields()
queryParams.type = 'store'
handleQuery()
}
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
const handleDelete = async (id: number) => {
try {
await message.delConfirm()
await CustomTagApi.deleteCustomTag(id)
message.success(t('common.delSuccess'))
await getList()
} catch {}
}
const checkedIds = ref<number[]>([])
const handleRowCheckboxChange = (records: CustomTag[]) => {
checkedIds.value = records.map((item) => item.id!).filter(Boolean)
}
const handleDeleteBatch = async () => {
try {
await message.delConfirm()
await CustomTagApi.deleteCustomTagList(checkedIds.value)
checkedIds.value = []
message.success(t('common.delSuccess'))
await getList()
} catch {}
}
const handleExport = async () => {
try {
await message.exportConfirm()
exportLoading.value = true
const data = await CustomTagApi.exportCustomTag(queryParams)
download.excel(data, '店铺自定义标签.xls')
} catch {
} finally {
exportLoading.value = false
}
}
onMounted(() => getList())
</script>

View File

@@ -0,0 +1,190 @@
<template>
<ContentWrap>
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="90px"
>
<el-form-item label="标签名称" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入标签名称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="公式描述" prop="expression">
<el-input
v-model="queryParams.expression"
placeholder="请输入公式描述"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="创建时间" prop="createTime">
<el-date-picker
v-model="queryParams.createTime"
value-format="YYYY-MM-DD HH:mm:ss"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
class="!w-220px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" />搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" />重置</el-button>
<el-button type="primary" plain @click="openForm('create')" v-hasPermi="['ydoyun:custom-tag:create']">
<Icon icon="ep:plus" /> 新增
</el-button>
<el-button type="success" plain @click="handleExport" :loading="exportLoading" v-hasPermi="['ydoyun:custom-tag:export']">
<Icon icon="ep:download" /> 导出
</el-button>
<el-button type="danger" plain :disabled="isEmpty(checkedIds)" @click="handleDeleteBatch" v-hasPermi="['ydoyun:custom-tag:delete']">
<Icon icon="ep:delete" /> 批量删除
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<el-table
row-key="id"
v-loading="loading"
:data="list"
:stripe="true"
:show-overflow-tooltip="true"
@selection-change="handleRowCheckboxChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="标签名称" align="center" prop="name" min-width="120" :show-overflow-tooltip="true" />
<el-table-column label="公式描述" align="center" prop="expression" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="标签颜色" align="center" prop="color" width="100">
<template #default="scope">
<el-tag v-if="scope.row.color" :color="scope.row.color" effect="dark" size="small">{{ scope.row.color }}</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="代入参数" align="center" prop="useParams" width="90">
<template #default="scope">
<el-tag v-if="scope.row.useParams === true || scope.row.useParams === 1" type="success" size="small"></el-tag>
<el-tag v-else type="info" size="small"></el-tag>
</template>
</el-table-column>
<el-table-column label="参数列表" align="center" prop="params" min-width="120" :show-overflow-tooltip="true" />
<el-table-column label="创建时间" align="center" prop="createTime" :formatter="dateFormatter" width="180" />
<el-table-column label="操作" align="center" width="140" fixed="right">
<template #default="scope">
<el-button link type="primary" @click="openForm('update', scope.row.id)" v-hasPermi="['ydoyun:custom-tag:update']">
<Icon icon="ep:edit" /> 编辑
</el-button>
<el-button link type="danger" @click="handleDelete(scope.row.id)" v-hasPermi="['ydoyun:custom-tag:delete']">
<Icon icon="ep:delete" /> 删除
</el-button>
</template>
</el-table-column>
</el-table>
<Pagination :total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize" @pagination="getList" />
</ContentWrap>
<CustomTagFormWithParams ref="formRef" tag-type="supplier" @success="getList" />
</template>
<script setup lang="ts">
import { isEmpty } from '@/utils/is'
import { dateFormatter } from '@/utils/formatTime'
import download from '@/utils/download'
import { CustomTagApi, CustomTag } from '@/api/ydoyun/customtag'
import CustomTagFormWithParams from '@/views/ydoyun/customtag/CustomTagFormWithParams.vue'
defineOptions({ name: 'SupplierCustomTag' })
const message = useMessage()
const { t } = useI18n()
const loading = ref(true)
const list = ref<CustomTag[]>([])
const total = ref(0)
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
type: 'supplier' as const,
name: undefined,
expression: undefined,
createTime: [] as string[]
})
const queryFormRef = ref()
const exportLoading = ref(false)
const getList = async () => {
loading.value = true
try {
const data = await CustomTagApi.getCustomTagPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
const resetQuery = () => {
queryFormRef.value?.resetFields()
queryParams.type = 'supplier'
handleQuery()
}
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
const handleDelete = async (id: number) => {
try {
await message.delConfirm()
await CustomTagApi.deleteCustomTag(id)
message.success(t('common.delSuccess'))
await getList()
} catch {}
}
const checkedIds = ref<number[]>([])
const handleRowCheckboxChange = (records: CustomTag[]) => {
checkedIds.value = records.map((item) => item.id!).filter(Boolean)
}
const handleDeleteBatch = async () => {
try {
await message.delConfirm()
await CustomTagApi.deleteCustomTagList(checkedIds.value)
checkedIds.value = []
message.success(t('common.delSuccess'))
await getList()
} catch {}
}
const handleExport = async () => {
try {
await message.exportConfirm()
exportLoading.value = true
const data = await CustomTagApi.exportCustomTag(queryParams)
download.excel(data, '供货商自定义标签.xls')
} catch {
} finally {
exportLoading.value = false
}
}
onMounted(() => getList())
</script>