Compare commits
2 Commits
f935832c9e
...
13fe5d9124
| Author | SHA1 | Date | |
|---|---|---|---|
| 13fe5d9124 | |||
| fe4e742551 |
12
.editorconfig
Normal file
12
.editorconfig
Normal 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
37
.env
Normal 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
38
.env.dev
Normal 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
35
.env.local
Normal 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
34
.env.prod
Normal 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
34
.env.stage
Normal 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
34
.env.test
Normal 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
8
.eslintignore
Normal 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
259
.eslintrc-auto-import.json
Normal 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
75
.eslintrc.js
Normal 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
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
/dist*
|
||||
pnpm-debug
|
||||
auto-*.d.ts
|
||||
.idea
|
||||
.history
|
||||
11
.prettierignore
Normal file
11
.prettierignore
Normal 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
6
.stylelintignore
Normal file
@@ -0,0 +1,6 @@
|
||||
/dist/*
|
||||
/public/*
|
||||
public/*
|
||||
/dist*
|
||||
/src/types/env.d.ts
|
||||
/docs/**/*
|
||||
18
.vscode/extensions.json
vendored
Normal file
18
.vscode/extensions.json
vendored
Normal 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
16
.vscode/launch.json
vendored
Normal 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
146
.vscode/settings.json
vendored
Normal 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
51
backend-idcard/README.md
Normal 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
27
backend-idcard/pom.xml
Normal 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>
|
||||
@@ -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 + 身份证识别(百度 OCR,AES 加解密)。
|
||||
*/
|
||||
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 位 aesKey(hex 字符串)
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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、aesKey(16 位 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 位 aesKey(hex 字符串)";
|
||||
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend-idcard/target/idcard-ocr-1.0.0.jar
Normal file
BIN
backend-idcard/target/idcard-ocr-1.0.0.jar
Normal file
Binary file not shown.
3
backend-idcard/target/maven-archiver/pom.properties
Normal file
3
backend-idcard/target/maven-archiver/pom.properties
Normal file
@@ -0,0 +1,3 @@
|
||||
artifactId=idcard-ocr
|
||||
groupId=com.ydoyun
|
||||
version=1.0.0
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
/Users/ouhaolan/project/百盛科技/报表/yudao-ui-admin-vue3/backend-idcard/src/main/java/com/ydoyun/ocr/IdcardRecognizer.java
|
||||
@@ -13,7 +13,7 @@ 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-ng'
|
||||
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
|
||||
import UnoCSS from 'unocss/vite'
|
||||
|
||||
export function createVitePlugins() {
|
||||
@@ -78,6 +78,7 @@ export function createVitePlugins() {
|
||||
createSvgIconsPlugin({
|
||||
iconDirs: [pathResolve('src/assets/svgs')],
|
||||
symbolId: 'icon-[dir]-[name]',
|
||||
svgoOptions: true
|
||||
}),
|
||||
viteCompression({
|
||||
verbose: true, // 是否在控制台输出压缩结果
|
||||
|
||||
@@ -25,8 +25,8 @@ const include = [
|
||||
'echarts/components',
|
||||
'echarts/renderers',
|
||||
'echarts-wordcloud',
|
||||
'@wangeditor-next/editor',
|
||||
'@wangeditor-next/editor-for-vue',
|
||||
'@wangeditor/editor',
|
||||
'@wangeditor/editor-for-vue',
|
||||
'@microsoft/fetch-event-source',
|
||||
'markdown-it',
|
||||
'markmap-view',
|
||||
@@ -114,9 +114,7 @@ const include = [
|
||||
'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',
|
||||
'element-plus/es/components/mention/style/css',
|
||||
'element-plus/es/components/progress/style/css'
|
||||
'element-plus/es/components/empty/style/css'
|
||||
]
|
||||
|
||||
const exclude = ['@iconify/json']
|
||||
|
||||
3531
package-lock.json
generated
3531
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -137,6 +137,7 @@
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-plugin-progress": "^0.0.7",
|
||||
"vite-plugin-purge-icons": "^0.10.0",
|
||||
"vite-plugin-svg-icons": "^2.0.1",
|
||||
"vite-plugin-svg-icons-ng": "^1.3.1",
|
||||
"vite-plugin-top-level-await": "^1.4.4",
|
||||
"vue-eslint-parser": "^9.3.2",
|
||||
|
||||
1951
pnpm-lock.yaml
generated
1951
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
55
src/api/ydoyun/customtag/index.ts
Normal file
55
src/api/ydoyun/customtag/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import request from '@/config/axios'
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
/** 自定义标签类型:product-产品, store-店铺, supplier-供货商 */
|
||||
export type CustomTagType = 'product' | 'store' | 'supplier'
|
||||
|
||||
/** 自定义标签信息 */
|
||||
export interface CustomTag {
|
||||
id?: number
|
||||
type?: CustomTagType // 类型,用于区分产品/店铺/供货商
|
||||
name?: string
|
||||
expression?: string
|
||||
color?: string
|
||||
sqlScript?: string
|
||||
useParams?: boolean | number
|
||||
params?: string
|
||||
}
|
||||
|
||||
// 自定义标签 API
|
||||
export const CustomTagApi = {
|
||||
// 查询自定义标签分页
|
||||
getCustomTagPage: async (params: any) => {
|
||||
return await request.get({ url: `/ydoyun/custom-tag/page`, params })
|
||||
},
|
||||
|
||||
// 查询自定义标签详情
|
||||
getCustomTag: async (id: number) => {
|
||||
return await request.get({ url: `/ydoyun/custom-tag/get?id=` + id })
|
||||
},
|
||||
|
||||
// 新增自定义标签
|
||||
createCustomTag: async (data: CustomTag) => {
|
||||
return await request.post({ url: `/ydoyun/custom-tag/create`, data })
|
||||
},
|
||||
|
||||
// 修改自定义标签
|
||||
updateCustomTag: async (data: CustomTag) => {
|
||||
return await request.put({ url: `/ydoyun/custom-tag/update`, data })
|
||||
},
|
||||
|
||||
// 删除自定义标签
|
||||
deleteCustomTag: async (id: number) => {
|
||||
return await request.delete({ url: `/ydoyun/custom-tag/delete?id=` + id })
|
||||
},
|
||||
|
||||
/** 批量删除自定义标签 */
|
||||
deleteCustomTagList: async (ids: number[]) => {
|
||||
return await request.delete({ url: `/ydoyun/custom-tag/delete-list?ids=${ids.join(',')}` })
|
||||
},
|
||||
|
||||
// 导出自定义标签 Excel
|
||||
exportCustomTag: async (params) => {
|
||||
return await request.download({ url: `/ydoyun/custom-tag/export-excel`, params })
|
||||
}
|
||||
}
|
||||
@@ -822,6 +822,46 @@ const remainingRouter: AppRouteRecordRaw[] = [
|
||||
component: () => import('@/views/ydoyun/report/lijun/reportpage6/detail.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/ydoyuntag',
|
||||
component: Layout,
|
||||
name: 'YdoyunTag',
|
||||
meta: {
|
||||
hidden: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'product-custom-tag',
|
||||
name: 'ProductCustomTag',
|
||||
meta: {
|
||||
title: '产品标签',
|
||||
noCache: true,
|
||||
canTo: true
|
||||
},
|
||||
component: () => import(/* webpackChunkName: "product-custom-tag" */ '@/views/ydoyun/productcustomtag/index.vue')
|
||||
},
|
||||
{
|
||||
path: 'store-custom-tag',
|
||||
name: 'StoreCustomTag',
|
||||
meta: {
|
||||
title: '店铺标签',
|
||||
noCache: true,
|
||||
canTo: true
|
||||
},
|
||||
component: () => import(/* webpackChunkName: "store-custom-tag" */ '@/views/ydoyun/storecustomtag/index.vue')
|
||||
},
|
||||
{
|
||||
path: 'supplier-custom-tag',
|
||||
name: 'SupplierCustomTag',
|
||||
meta: {
|
||||
title: '供货商标签',
|
||||
noCache: true,
|
||||
canTo: true
|
||||
},
|
||||
component: () => import(/* webpackChunkName: "supplier-custom-tag" */ '@/views/ydoyun/suppliercustomtag/index.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
203
src/views/ydoyun/customtag/CustomTagForm.vue
Normal file
203
src/views/ydoyun/customtag/CustomTagForm.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<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="SQL 脚本" prop="sqlScript">
|
||||
<el-input
|
||||
v-model="formData.sqlScript"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入 SQL 脚本,用于获取标签数据"
|
||||
/>
|
||||
<div class="form-tip">执行 SQL 获取标签选项,支持多列,第一列作为显示值</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 } from '@/api/ydoyun/customtag'
|
||||
|
||||
defineOptions({ name: 'CustomTagForm' })
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('')
|
||||
const formLoading = ref(false)
|
||||
const formType = ref('')
|
||||
|
||||
const formData = ref({
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
expression: undefined,
|
||||
color: undefined,
|
||||
sqlScript: undefined
|
||||
})
|
||||
|
||||
const formRules = reactive({
|
||||
name: [{ required: true, message: '标签名称不能为空', trigger: 'blur' }]
|
||||
})
|
||||
|
||||
const formRef = ref()
|
||||
|
||||
/** 预设颜色选项 */
|
||||
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 (id) {
|
||||
formLoading.value = true
|
||||
try {
|
||||
const data = await CustomTagApi.getCustomTag(id)
|
||||
formData.value = { ...data }
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
const submitForm = async () => {
|
||||
await formRef.value.validate()
|
||||
formLoading.value = true
|
||||
try {
|
||||
const data = formData.value as unknown 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,
|
||||
name: undefined,
|
||||
expression: undefined,
|
||||
color: undefined,
|
||||
sqlScript: 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;
|
||||
}
|
||||
</style>
|
||||
311
src/views/ydoyun/customtag/CustomTagFormWithParams.vue
Normal file
311
src/views/ydoyun/customtag/CustomTagFormWithParams.vue
Normal 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>
|
||||
232
src/views/ydoyun/customtag/index.vue
Normal file
232
src/views/ydoyun/customtag/index.vue
Normal file
@@ -0,0 +1,232 @@
|
||||
<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="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>
|
||||
|
||||
<CustomTagForm ref="formRef" @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 CustomTagForm from './CustomTagForm.vue'
|
||||
|
||||
defineOptions({ name: 'CustomTag' })
|
||||
|
||||
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,
|
||||
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()
|
||||
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>
|
||||
190
src/views/ydoyun/productcustomtag/index.vue
Normal file
190
src/views/ydoyun/productcustomtag/index.vue
Normal 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>
|
||||
@@ -2,12 +2,6 @@
|
||||
<div class="product-cards-page">
|
||||
<!-- 查询条件区域:与主页/详情页一致,首行时间+快捷+查询/重置/更多条件,其余折叠 -->
|
||||
<el-card class="query-card" shadow="never">
|
||||
<div class="query-header">
|
||||
<h1 class="page-title">查看更多产品卡片</h1>
|
||||
<span v-if="currentProductCode" class="current-product-tip">
|
||||
当前查看商品:<strong>{{ currentProductName || currentProductCode }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="query-form">
|
||||
<div class="query-item">
|
||||
<span class="query-label">时间区间</span>
|
||||
@@ -193,10 +187,11 @@
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 展示形式切换(放在最上面) -->
|
||||
<!-- 内容卡片:展示形式切换 + 标签筛选 + 卡片/列表内容 -->
|
||||
<el-card class="content-card" shadow="never">
|
||||
<!-- 展示形式切换:移入内容卡片内部,置于标签上方,仅保留切换按钮 -->
|
||||
<div class="view-toolbar">
|
||||
<div class="view-switch">
|
||||
<span class="toolbar-label">展示形式:</span>
|
||||
<el-radio-group v-model="displayMode" size="default">
|
||||
<el-radio-button label="card">
|
||||
<el-icon><Grid /></el-icon>
|
||||
@@ -222,15 +217,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容卡片:标签筛选 + 卡片/列表内容 -->
|
||||
<el-card class="content-card" shadow="never">
|
||||
<!-- 标签分类筛选(放在卡片内部) -->
|
||||
<div v-if="labelOpts.length > 0" class="filter-bar">
|
||||
<div
|
||||
v-for="opt in labelOpts"
|
||||
:key="opt.value"
|
||||
class="pill"
|
||||
:class="{ active: labelFilter === opt.value }"
|
||||
:class="['pill', productTagClass(opt.label), { active: labelFilter === opt.value }]"
|
||||
@click="labelFilter = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
@@ -261,19 +253,47 @@
|
||||
<!-- 媒体区:缩略图 + 名称/标签/价格 -->
|
||||
<div class="kb22-media-row">
|
||||
<div class="kb22-thumb-box">
|
||||
<img v-if="item.imageUrl" :src="item.imageUrl" :alt="item.name" class="kb22-thumb-img" />
|
||||
<img
|
||||
v-if="item.imageUrl || (item as any).pic"
|
||||
:src="item.imageUrl || (item as any).pic"
|
||||
:alt="item.name"
|
||||
class="kb22-thumb-img"
|
||||
/>
|
||||
<div v-else class="kb22-no-img"><el-icon :size="32"><Picture /></el-icon></div>
|
||||
</div>
|
||||
<div class="kb22-info-col">
|
||||
<div class="kb22-prod-title">{{ item.name }}</div>
|
||||
<div class="kb22-tags-container">
|
||||
<span class="kb22-tag-pill kb22-tag-blue">{{ item.season }}</span>
|
||||
<span class="kb22-tag-pill kb22-tag-purple">{{ item.discount }}</span>
|
||||
<span
|
||||
v-if="item.type"
|
||||
class="kb22-tag-pill"
|
||||
:class="productTagClass(item.type)"
|
||||
>
|
||||
{{ item.type }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.lifecycle"
|
||||
class="kb22-tag-pill"
|
||||
:class="productTagClass(item.lifecycle)"
|
||||
>
|
||||
{{ item.lifecycle }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="kb22-price-section">
|
||||
<span class="kb22-current-price">¥{{ item.sellingPrice ?? 0 }}</span>
|
||||
<span class="kb22-cost-price">¥{{ item.purchasePrice ?? 0 }}</span>
|
||||
<span class="kb22-margin-text">毛利 {{ formatGrossMarginPct(item.grossMargin, item.purchasePrice != null && item.sellingPrice != null ? (1 - item.purchasePrice / item.sellingPrice) * 100 : undefined) }}%</span>
|
||||
<span class="kb22-current-price">
|
||||
<template v-if="item.sellingPrice != null">¥{{ formatNumber(item.sellingPrice) }}</template>
|
||||
<template v-else>-</template>
|
||||
</span>
|
||||
<span class="kb22-cost-price">
|
||||
<template v-if="item.purchasePrice != null">¥{{ formatNumber(item.purchasePrice) }}</template>
|
||||
<template v-else>-</template>
|
||||
</span>
|
||||
<span class="kb22-margin-text">
|
||||
<template v-if="item.grossMargin != null">
|
||||
毛利 {{ formatGrossMarginPct(item.grossMargin, item.purchasePrice != null && item.sellingPrice != null ? (1 - item.purchasePrice / item.sellingPrice) * 100 : undefined) }}%
|
||||
</template>
|
||||
<template v-else>-</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -281,18 +301,34 @@
|
||||
<div class="kb22-stats-grid">
|
||||
<div class="kb22-stat-card kb22-sales">
|
||||
<div class="kb22-stat-label">总销量</div>
|
||||
<div class="kb22-stat-value">{{ item.salesCount ?? 0 }}<span class="kb22-stat-sub">件</span></div>
|
||||
<div class="kb22-sales-footer">
|
||||
<div class="kb22-mini-progress">
|
||||
<div class="kb22-mini-fill" :style="{ width: Math.min(100, item.selloutRate ?? 0) + '%' }"></div>
|
||||
<div class="kb22-stat-value">
|
||||
<template v-if="item.salesCount != null">
|
||||
{{ item.salesCount }}<span class="kb22-stat-sub">件</span>
|
||||
</template>
|
||||
<template v-else>-</template>
|
||||
</div>
|
||||
<div class="kb22-sales-footer">
|
||||
<div class="kb22-mini-progress" v-if="item.selloutRate != null">
|
||||
<div class="kb22-mini-fill" :style="{ width: Math.min(100, item.selloutRate) + '%' }"></div>
|
||||
</div>
|
||||
<div class="kb22-rate-text">
|
||||
<template v-if="item.selloutRate != null">售罄率 {{ item.selloutRate }}%</template>
|
||||
<template v-else>-</template>
|
||||
</div>
|
||||
<div class="kb22-rate-text">售罄率 {{ item.selloutRate ?? 0 }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kb22-stat-card kb22-stock">
|
||||
<div class="kb22-stat-label">当前库存</div>
|
||||
<div class="kb22-stat-value">{{ item.inventoryCount ?? 0 }}<span class="kb22-stat-sub">件</span></div>
|
||||
<div class="kb22-turnover-text" :class="uiTextClass(item.turnoverStatus)">周转: {{ item.turnoverText ?? '-' }}</div>
|
||||
<div class="kb22-stat-value">
|
||||
<template v-if="item.inventoryCount != null">
|
||||
{{ item.inventoryCount }}<span class="kb22-stat-sub">件</span>
|
||||
</template>
|
||||
<template v-else>-</template>
|
||||
</div>
|
||||
<div class="kb22-turnover-text" :class="uiTextClass(item.turnoverStatus)">
|
||||
<template v-if="item.turnoverText">{{ '周转: ' + item.turnoverText }}</template>
|
||||
<template v-else>-</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- SKU 明细:尺码 + 库存 + 销量 -->
|
||||
@@ -337,21 +373,53 @@
|
||||
row-class-name="product-list-row-clickable"
|
||||
@row-click="handleProductRowClick"
|
||||
>
|
||||
<el-table-column prop="productInfo" label="商品信息" width="280">
|
||||
<el-table-column prop="imageUrl" label="商品图片" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="product-info">
|
||||
<div class="product-image">
|
||||
<img v-if="row.imageUrl" :src="row.imageUrl" alt="商品图片" class="product-img" />
|
||||
<img
|
||||
v-if="row.imageUrl || row.pic"
|
||||
:src="row.imageUrl || row.pic"
|
||||
alt="商品图片"
|
||||
class="product-img"
|
||||
/>
|
||||
<el-icon v-else><Picture /></el-icon>
|
||||
</div>
|
||||
<div class="product-details">
|
||||
<div class="product-code">款号: {{ row.code || '-' }}</div>
|
||||
<div class="product-code">条码: {{ row.barcode || '-' }}</div>
|
||||
<div class="product-code">颜色: {{ row.color }}</div>
|
||||
<div class="product-code">进价: ¥{{ formatNumber(row.purchasePrice || 0) }}</div>
|
||||
<div class="product-code">售价: ¥{{ formatNumber(row.sellingPrice || 0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="商品名称" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.name || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="code" label="款号" width="140">
|
||||
<template #default="{ row }">
|
||||
<span class="product-code-link" @click.stop="copyCellText(row.code)">
|
||||
{{ row.code || '-' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="barcode" label="条码" width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="product-code-link" @click.stop="copyCellText(row.barcode)">
|
||||
{{ row.barcode || '-' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="color" label="颜色" width="100">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.color || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="purchasePrice" label="进价" align="right" width="110">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.purchasePrice != null">¥{{ formatNumber(row.purchasePrice) }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sellingPrice" label="售价" align="right" width="110">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.sellingPrice != null">¥{{ formatNumber(row.sellingPrice) }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="category" label="商品属性" width="200" show-overflow-tooltip>
|
||||
@@ -359,6 +427,26 @@
|
||||
<div>{{ row.category }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="tags" label="标签" width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div class="product-tags">
|
||||
<span
|
||||
v-if="row.type"
|
||||
class="kb22-tag-pill"
|
||||
:class="productTagClass(row.type)"
|
||||
>
|
||||
{{ row.type }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.lifecycle"
|
||||
class="kb22-tag-pill"
|
||||
:class="productTagClass(row.lifecycle)"
|
||||
>
|
||||
{{ row.lifecycle }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ls" label="销售数据" align="right" width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="font-bold">{{ row.lsRaw || '-' }}</div>
|
||||
@@ -438,6 +526,7 @@ import dayjs from 'dayjs'
|
||||
import { Picture, Grid, List, Search } from '@element-plus/icons-vue'
|
||||
import { ReportApi } from '@/api/ydoyun/report/reportpage'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
const REPORT_ID = 6
|
||||
|
||||
@@ -485,11 +574,20 @@ interface ProductCardItem {
|
||||
grossMargin?: number
|
||||
}
|
||||
|
||||
function productTagClass(tag: string): string {
|
||||
if (!tag) return ''
|
||||
if (tag.includes('慢') || tag.includes('低') || tag.includes('预警')) return 'kb22-tag-warn'
|
||||
if (tag.includes('滞后') || tag.includes('高')) return 'kb22-tag-danger'
|
||||
return 'kb22-tag-success'
|
||||
}
|
||||
|
||||
defineOptions({ name: 'ProductCardsPage' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const userStore = useUserStore()
|
||||
const username = computed(() => userStore.user?.username || '')
|
||||
|
||||
/** 展示形式:卡片 | 列表(与 ProductDashboard 商品明细表格一致) */
|
||||
const displayMode = ref<'card' | 'table'>('card')
|
||||
@@ -589,233 +687,7 @@ function hasOutOfStockSize(sizes: ProductSizeStatus[]): boolean {
|
||||
return Array.isArray(sizes) && sizes.some((s) => s.status === 'out' || s.stock === 0)
|
||||
}
|
||||
|
||||
// Mock:与列表页一致的全量数据(7 条)
|
||||
const productList = ref<ProductCardItem[]>([
|
||||
{
|
||||
name: 'GZ6596Z 法式收腰连衣裙',
|
||||
code: '54000008',
|
||||
color: '黑色',
|
||||
season: '25春 一波段',
|
||||
category: '女装 / 连衣裙',
|
||||
daysOnMarket: 35,
|
||||
salesAmount: 85200,
|
||||
salesCount: 320,
|
||||
inventoryCount: 120,
|
||||
turnoverText: '周转: 15天 (极快)',
|
||||
turnoverStatus: 'success',
|
||||
selloutRate: 78,
|
||||
selloutRateStatus: 'danger',
|
||||
discount: '9.5折',
|
||||
discountStatus: 'info',
|
||||
sizes: [
|
||||
{ label: 'S', status: 'out', title: 'S码 缺货', stock: 0 },
|
||||
{ label: 'M', status: 'out', title: 'M码 缺货', stock: 0 },
|
||||
{ label: 'L', status: 'low', title: 'L码 紧张', stock: 8 },
|
||||
{ label: 'XL', status: 'ok', title: 'XL码 充足', stock: 45 }
|
||||
],
|
||||
sizeStatusText: '缺核心码',
|
||||
sizeStatusStatus: 'danger',
|
||||
lifecycle: '爆发成长期',
|
||||
lifecycleType: 'success',
|
||||
barcode: '6901234567890',
|
||||
purchasePrice: 168,
|
||||
sellingPrice: 399,
|
||||
imageUrl: 'https://img.alicdn.com/imgextra/i1/2215397650053/O1CN01kQKs0S1COq3OWK7Wb_!!2215397650053.jpg',
|
||||
supplierName: '杭州女装供应链有限公司'
|
||||
},
|
||||
{
|
||||
name: '7537X 双面呢大衣',
|
||||
code: '54000321',
|
||||
color: '驼色',
|
||||
season: '24冬 三波段',
|
||||
category: '女装 / 外套',
|
||||
daysOnMarket: 120,
|
||||
salesAmount: 22000,
|
||||
salesCount: 18,
|
||||
inventoryCount: 850,
|
||||
turnoverText: '周转: 280天 (滞销)',
|
||||
turnoverStatus: 'info',
|
||||
selloutRate: 15,
|
||||
selloutRateStatus: 'warning',
|
||||
discount: '6.0折',
|
||||
discountStatus: 'danger',
|
||||
sizes: [
|
||||
{ label: 'S', status: 'ok', title: 'S码 充足', stock: 210 },
|
||||
{ label: 'M', status: 'ok', title: 'M码 充足', stock: 220 },
|
||||
{ label: 'L', status: 'ok', title: 'L码 充足', stock: 215 },
|
||||
{ label: 'XL', status: 'ok', title: 'XL码 充足', stock: 205 }
|
||||
],
|
||||
sizeStatusText: '库存齐色齐码',
|
||||
sizeStatusStatus: 'info',
|
||||
lifecycle: '严重滞销',
|
||||
lifecycleType: 'danger',
|
||||
barcode: '6901234567891',
|
||||
purchasePrice: 580,
|
||||
sellingPrice: 1299,
|
||||
imageUrl: 'https://img.alicdn.com/imgextra/i2/2215397650053/O1CN01kQKs0S1COq3OWK7Wb_!!2215397650053.jpg',
|
||||
supplierName: '上海大衣制造厂'
|
||||
},
|
||||
{
|
||||
name: 'C01J 圆领针织衫',
|
||||
code: '54000159',
|
||||
color: '米白',
|
||||
season: '25春 二波段',
|
||||
category: '女装 / 毛衫',
|
||||
daysOnMarket: 25,
|
||||
salesAmount: 45000,
|
||||
salesCount: 120,
|
||||
inventoryCount: 450,
|
||||
turnoverText: '周转: 45天 (健康)',
|
||||
turnoverStatus: 'success',
|
||||
selloutRate: 35,
|
||||
selloutRateStatus: 'info',
|
||||
discount: '9.0折',
|
||||
discountStatus: 'info',
|
||||
sizes: [
|
||||
{ label: 'S', status: 'ok', title: 'S码 充足', stock: 120 },
|
||||
{ label: 'M', status: 'warn', title: 'M码 紧张', stock: 25 },
|
||||
{ label: 'L', status: 'ok', title: 'L码 充足', stock: 150 },
|
||||
{ label: 'XL', status: 'ok', title: 'XL码 充足', stock: 155 }
|
||||
],
|
||||
sizeStatusText: '',
|
||||
sizeStatusStatus: 'info',
|
||||
lifecycle: '正常销售',
|
||||
lifecycleType: 'info',
|
||||
barcode: '6901234567892',
|
||||
purchasePrice: 128,
|
||||
sellingPrice: 299,
|
||||
imageUrl: 'https://img.alicdn.com/imgextra/i3/2215397650053/O1CN01kQKs0S1COq3OWK7Wb_!!2215397650053.jpg',
|
||||
supplierName: '广州针织品有限公司'
|
||||
},
|
||||
{
|
||||
name: 'LK2025 春季休闲裤',
|
||||
code: '54000245',
|
||||
color: '卡其色',
|
||||
season: '25春 一波段',
|
||||
category: '女装 / 休闲裤',
|
||||
daysOnMarket: 28,
|
||||
salesAmount: 125000,
|
||||
salesCount: 450,
|
||||
inventoryCount: 280,
|
||||
turnoverText: '周转: 38天 (健康)',
|
||||
turnoverStatus: 'success',
|
||||
selloutRate: 62,
|
||||
selloutRateStatus: 'success',
|
||||
discount: '8.5折',
|
||||
discountStatus: 'info',
|
||||
sizes: [
|
||||
{ label: 'S', status: 'ok', title: 'S码 充足', stock: 45 },
|
||||
{ label: 'M', status: 'warn', title: 'M码 紧张', stock: 8 },
|
||||
{ label: 'L', status: 'ok', title: 'L码 充足', stock: 52 },
|
||||
{ label: 'XL', status: 'ok', title: 'XL码 充足', stock: 38 }
|
||||
],
|
||||
sizeStatusText: 'M码需补货',
|
||||
sizeStatusStatus: 'warning',
|
||||
lifecycle: '稳定销售期',
|
||||
lifecycleType: 'info',
|
||||
barcode: '6901234567893',
|
||||
purchasePrice: 158,
|
||||
sellingPrice: 369,
|
||||
imageUrl: 'https://img.alicdn.com/imgextra/i4/2215397650053/O1CN01kQKs0S1COq3OWK7Wb_!!2215397650053.jpg',
|
||||
supplierName: '深圳休闲服饰有限公司'
|
||||
},
|
||||
{
|
||||
name: 'FW2024 冬季羽绒服',
|
||||
code: '54000188',
|
||||
color: '黑色',
|
||||
season: '24冬 二波段',
|
||||
category: '女装 / 外套',
|
||||
daysOnMarket: 95,
|
||||
salesAmount: 185000,
|
||||
salesCount: 95,
|
||||
inventoryCount: 520,
|
||||
turnoverText: '周转: 210天 (滞销)',
|
||||
turnoverStatus: 'danger',
|
||||
selloutRate: 18,
|
||||
selloutRateStatus: 'danger',
|
||||
discount: '5.5折',
|
||||
discountStatus: 'danger',
|
||||
sizes: [
|
||||
{ label: 'S', status: 'ok', title: 'S码 充足', stock: 120 },
|
||||
{ label: 'M', status: 'ok', title: 'M码 充足', stock: 135 },
|
||||
{ label: 'L', status: 'ok', title: 'L码 充足', stock: 145 },
|
||||
{ label: 'XL', status: 'ok', title: 'XL码 充足', stock: 120 }
|
||||
],
|
||||
sizeStatusText: '库存积压严重',
|
||||
sizeStatusStatus: 'danger',
|
||||
lifecycle: '衰退期',
|
||||
lifecycleType: 'danger',
|
||||
barcode: '6901234567894',
|
||||
purchasePrice: 680,
|
||||
sellingPrice: 1599,
|
||||
imageUrl: 'https://img.alicdn.com/imgextra/i1/2215397650053/O1CN01kQKs0S1COq3OWK7Wb_!!2215397650053.jpg',
|
||||
supplierName: '江苏羽绒制品厂'
|
||||
},
|
||||
{
|
||||
name: 'TS2025 春季新品T恤',
|
||||
code: '54000356',
|
||||
color: '白色',
|
||||
season: '25春 一波段',
|
||||
category: '女装 / T恤',
|
||||
daysOnMarket: 15,
|
||||
salesAmount: 98000,
|
||||
salesCount: 680,
|
||||
inventoryCount: 320,
|
||||
turnoverText: '周转: 12天 (极快)',
|
||||
turnoverStatus: 'success',
|
||||
selloutRate: 68,
|
||||
selloutRateStatus: 'success',
|
||||
discount: '9.8折',
|
||||
discountStatus: 'info',
|
||||
sizes: [
|
||||
{ label: 'S', status: 'out', title: 'S码 缺货', stock: 0 },
|
||||
{ label: 'M', status: 'out', title: 'M码 缺货', stock: 2 },
|
||||
{ label: 'L', status: 'low', title: 'L码 紧张', stock: 15 },
|
||||
{ label: 'XL', status: 'ok', title: 'XL码 充足', stock: 48 }
|
||||
],
|
||||
sizeStatusText: '核心码缺货,需紧急补货',
|
||||
sizeStatusStatus: 'danger',
|
||||
lifecycle: '爆发成长期',
|
||||
lifecycleType: 'success',
|
||||
barcode: '6901234567895',
|
||||
purchasePrice: 68,
|
||||
sellingPrice: 159,
|
||||
imageUrl: 'https://img.alicdn.com/imgextra/i2/2215397650053/O1CN01kQKs0S1COq3OWK7Wb_!!2215397650053.jpg',
|
||||
supplierName: '浙江T恤代工厂'
|
||||
},
|
||||
{
|
||||
name: 'CS2025 春季衬衫',
|
||||
code: '54000378',
|
||||
color: '蓝色',
|
||||
season: '25春 二波段',
|
||||
category: '女装 / 衬衫',
|
||||
daysOnMarket: 20,
|
||||
salesAmount: 156000,
|
||||
salesCount: 520,
|
||||
inventoryCount: 480,
|
||||
turnoverText: '周转: 25天 (快速)',
|
||||
turnoverStatus: 'success',
|
||||
selloutRate: 52,
|
||||
selloutRateStatus: 'success',
|
||||
discount: '9.2折',
|
||||
discountStatus: 'info',
|
||||
sizes: [
|
||||
{ label: 'S', status: 'ok', title: 'S码 充足', stock: 95 },
|
||||
{ label: 'M', status: 'warn', title: 'M码 紧张', stock: 12 },
|
||||
{ label: 'L', status: 'ok', title: 'L码 充足', stock: 88 },
|
||||
{ label: 'XL', status: 'ok', title: 'XL码 充足', stock: 76 }
|
||||
],
|
||||
sizeStatusText: 'M码需补货',
|
||||
sizeStatusStatus: 'warning',
|
||||
lifecycle: '成长期',
|
||||
lifecycleType: 'success',
|
||||
barcode: '6901234567896',
|
||||
purchasePrice: 138,
|
||||
sellingPrice: 329,
|
||||
imageUrl: 'https://img.alicdn.com/imgextra/i3/2215397650053/O1CN01kQKs0S1COq3OWK7Wb_!!2215397650053.jpg',
|
||||
supplierName: '杭州衬衫供应链'
|
||||
}
|
||||
])
|
||||
const productList = ref<ProductCardItem[]>([])
|
||||
|
||||
/** 表格行:在 ProductCardItem 基础上补充列表列所需字段(与 index 商品明细一致) */
|
||||
type TableRow = ProductCardItem & {
|
||||
@@ -853,16 +725,16 @@ const visibleProductList = computed(() => {
|
||||
return productList.value.filter((item) => productMatchesLabelFilter(item, filter))
|
||||
})
|
||||
|
||||
/** 将 visibleProductList 转为表格行(缺的 Raw 用派生值或 '-') */
|
||||
/** 将 visibleProductList 转为表格行(Raw 字段直接用于展示,缺失时用 '-') */
|
||||
const tableList = computed<TableRow[]>(() =>
|
||||
visibleProductList.value.map((item) => ({
|
||||
...item,
|
||||
lsRaw: item.salesCount != null || item.salesAmount != null ? `${item.salesCount ?? 0}件/${item.salesAmount ?? 0}元` : '-',
|
||||
j7slRaw: '-',
|
||||
kcslRaw: item.inventoryCount != null ? `${item.inventoryCount}件` : '-',
|
||||
zksqlRaw: item.discount != null && item.selloutRate != null ? `${item.discount},${item.selloutRate}%` : '-',
|
||||
shdxdRaw: '-',
|
||||
wsdpRaw: '-',
|
||||
lsRaw: item.salesAmount != null || item.salesCount != null ? `${item.salesCount ?? ''}${item.salesCount != null ? '件/' : ''}${item.salesAmount ?? ''}${item.salesAmount != null ? '元' : ''}` : undefined,
|
||||
j7slRaw: undefined,
|
||||
kcslRaw: item.inventoryCount != null ? `${item.inventoryCount}件` : undefined,
|
||||
zksqlRaw: item.discount != null || item.selloutRate != null ? `${item.discount ?? ''}${item.discount && item.selloutRate != null ? ',' : ''}${item.selloutRate != null ? item.selloutRate + '%' : ''}` : undefined,
|
||||
shdxdRaw: undefined,
|
||||
wsdpRaw: undefined,
|
||||
actionText: '分析',
|
||||
actionType: 'default' as const
|
||||
}))
|
||||
@@ -880,10 +752,25 @@ function handleProductRowClick(row: TableRow) {
|
||||
}
|
||||
|
||||
function handleProductCodeClick(row: TableRow) {
|
||||
router.push({
|
||||
const spdm = row.code || ''
|
||||
router
|
||||
.push({
|
||||
path: '/reports/lijun/reportpage6/detail',
|
||||
query: { spdm: row.code || '' }
|
||||
}).catch(() => {
|
||||
query: {
|
||||
// 关键商品编码
|
||||
spdm,
|
||||
// 透传当前大盘的所有查询条件,便于明细页还原筛选环境
|
||||
rq: queryParams.rq,
|
||||
rq2: queryParams.rq2,
|
||||
ckdm: queryParams.ckdm.join(','),
|
||||
pp: queryParams.pp.join(','),
|
||||
season: queryParams.season.join(','),
|
||||
zgj: queryParams.zgj.join(','),
|
||||
category: queryParams.category.join(','),
|
||||
line: queryParams.line.join(',')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessage.warning('路由未配置或详情页不存在')
|
||||
})
|
||||
}
|
||||
@@ -892,6 +779,20 @@ function handleTableAction(row: TableRow) {
|
||||
ElMessage.info(`执行操作: ${row.actionText}`)
|
||||
}
|
||||
|
||||
async function copyCellText(val?: string) {
|
||||
const text = (val ?? '').toString().trim()
|
||||
if (!text) {
|
||||
ElMessage.warning('无可复制内容')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('已复制到剪贴板')
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动选择复制')
|
||||
}
|
||||
}
|
||||
|
||||
function arrToArray(val: unknown): string[] {
|
||||
if (Array.isArray(val)) return val.filter((v) => typeof v === 'string')
|
||||
if (val === undefined || val === null) return []
|
||||
@@ -913,6 +814,46 @@ function applyQueryFromRoute() {
|
||||
currentProductCode.value = (q.productCode && typeof q.productCode === 'string') ? q.productCode : ''
|
||||
currentProductName.value = (q.productName && typeof q.productName === 'string') ? q.productName : ''
|
||||
dateRange.value = [queryParams.rq, queryParams.rq2]
|
||||
|
||||
// 根据路由传入的时间区间反推当前快捷按钮(与详情页/供应商/品类诊断一致)
|
||||
try {
|
||||
const rq = queryParams.rq
|
||||
const rq2 = queryParams.rq2
|
||||
const today = dayjs().format('YYYY-MM-DD')
|
||||
|
||||
// 仅在 rq2 为今天时才尝试匹配 week7/day15/day30 或年份
|
||||
if (rq && rq2 && rq2 === today) {
|
||||
const diff = dayjs(today).diff(dayjs(rq), 'day')
|
||||
if (diff === 6) {
|
||||
activeTimeRange.value = 'week7'
|
||||
return
|
||||
}
|
||||
if (diff === 14) {
|
||||
activeTimeRange.value = 'day15'
|
||||
return
|
||||
}
|
||||
if (diff === 29) {
|
||||
activeTimeRange.value = 'day30'
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试匹配全年快捷:从某年1月1日到该年末或今天
|
||||
if (rq && rq2) {
|
||||
const startYear = dayjs(rq).year()
|
||||
const yearStart = dayjs(`${startYear}-01-01`).format('YYYY-MM-DD')
|
||||
const yearEnd = startYear === dayjs().year() ? today : dayjs(`${startYear}-12-31`).format('YYYY-MM-DD')
|
||||
if (rq === yearStart && rq2 === yearEnd) {
|
||||
activeTimeRange.value = `year${startYear}`
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 其余情况,不高亮任何快捷按钮
|
||||
activeTimeRange.value = ''
|
||||
} catch {
|
||||
activeTimeRange.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function disabledDate(time: Date) {
|
||||
@@ -975,12 +916,158 @@ function handleReset() {
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
function handleQuery() {
|
||||
function arrToQuery(arr: string[]): string {
|
||||
return Array.isArray(arr) && arr.length > 0 ? arr.join(',') : ''
|
||||
}
|
||||
|
||||
async function handleQuery() {
|
||||
loading.value = true
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
try {
|
||||
const baseParams = {
|
||||
reportId: REPORT_ID,
|
||||
rq_s: queryParams.rq,
|
||||
rq_e: queryParams.rq2,
|
||||
ckdm: arrToQuery(queryParams.ckdm),
|
||||
pp: arrToQuery(queryParams.pp),
|
||||
dalei: arrToQuery(queryParams.category),
|
||||
jj: arrToQuery(queryParams.season),
|
||||
p: '123',
|
||||
username: username.value
|
||||
}
|
||||
|
||||
const res: any = await ReportApi.executeProcedureWithData({
|
||||
...baseParams,
|
||||
name: 'YDY_AI_GET_SPXQ'
|
||||
} as any)
|
||||
|
||||
// 兼容多种返回结构:数组 / { data: [] } / { code, data: [] }
|
||||
let data: any[] | null = null
|
||||
if (Array.isArray(res)) {
|
||||
data = res
|
||||
} else if (res && Array.isArray(res.data)) {
|
||||
data = res.data
|
||||
} else if (res && res.code != null && Array.isArray(res.data)) {
|
||||
data = res.data
|
||||
}
|
||||
|
||||
if (data && data.length > 0) {
|
||||
productList.value = data.map((row: any) => {
|
||||
// 映射存储过程字段 → 页面字段
|
||||
// jj: 季节代码,例如 "20"
|
||||
// jijie: 季节名称,例如 "春"
|
||||
const seasonName = row.jijie ?? ''
|
||||
const seasonCode = row.jj ?? ''
|
||||
const season = seasonName || seasonCode ? `${seasonName || ''}${seasonCode ? `(${seasonCode})` : ''}` : '-'
|
||||
|
||||
// kcsl: 库存数量(字符串),直接转数值
|
||||
const inventoryCount = Number(row.kcsl ?? 0) || 0
|
||||
|
||||
// lssl: 零售数量,如 "2726件/" → 2726
|
||||
const salesCount = (() => {
|
||||
const raw = String(row.lssl ?? '').split('件')[0].trim()
|
||||
const n = Number(raw)
|
||||
return isNaN(n) ? 0 : n
|
||||
})()
|
||||
|
||||
// lsje: 零售金额,如 "79051元" → 79051
|
||||
const salesAmount = (() => {
|
||||
const raw = String(row.lsje ?? '').replace('元', '').trim()
|
||||
const n = Number(raw)
|
||||
return isNaN(n) ? 0 : n
|
||||
})()
|
||||
|
||||
// zksql: 售罄率,如 "60.96%" → 60.96
|
||||
const selloutRate = (() => {
|
||||
const raw = String(row.zksql ?? '').replace('%', '').trim()
|
||||
const n = Number(raw)
|
||||
return isNaN(n) ? 0 : n
|
||||
})()
|
||||
|
||||
// pjz: 平均折,例如 "1.00" → "1.00折"
|
||||
const discount = row.pjz != null ? `${row.pjz}折` : row.discount ?? '-'
|
||||
|
||||
// 进售价:sj=售价,jj=进价
|
||||
const purchasePrice = Number(row.jj ?? row.purchasePrice ?? 0) || 0
|
||||
const sellingPrice = Number(row.sj ?? row.sellingPrice ?? 0) || 0
|
||||
const grossMargin =
|
||||
row.grossMargin != null
|
||||
? Number(row.grossMargin)
|
||||
: sellingPrice > 0
|
||||
? (sellingPrice - purchasePrice) / sellingPrice
|
||||
: 0
|
||||
|
||||
return {
|
||||
// 商品名称:spmc
|
||||
name: row.spmc ?? row.name ?? row.spdm ?? '-',
|
||||
// 款号:spdm
|
||||
code: String(row.spdm ?? row.code ?? ''),
|
||||
// 颜色:ysmc
|
||||
color: row.ysmc ?? row.color ?? '-',
|
||||
// 季节:jijie + jj
|
||||
season,
|
||||
// 类目:大类/中类/小类 dl / zl / xl
|
||||
category: row.dl || row.zl || row.xl || '-',
|
||||
// 上市天数:根据上市日期 ssrq 粗略计算
|
||||
daysOnMarket: row.ssrq ? dayjs().diff(dayjs(row.ssrq), 'day') : Number(row.daysOnMarket ?? 0),
|
||||
salesAmount,
|
||||
salesCount,
|
||||
inventoryCount,
|
||||
turnoverText: row.turnoverText ?? '',
|
||||
turnoverStatus: (row.turnoverStatus as UiStatus) ?? 'info',
|
||||
// 售罄率:zksql
|
||||
selloutRate,
|
||||
selloutRateStatus: (row.selloutRateStatus as UiStatus) ?? 'info',
|
||||
// 平均折:pjz
|
||||
discount,
|
||||
discountStatus: (row.discountStatus as UiStatus) ?? 'info',
|
||||
// 尺码明细:cmjs "M,488,red;L,718,red;XL,540,yellow"
|
||||
sizes: Array.isArray(row.sizes)
|
||||
? (row.sizes as ProductSizeStatus[])
|
||||
: String(row.cmjs ?? '')
|
||||
.split(';')
|
||||
.map((seg: string) => seg.trim())
|
||||
.filter(Boolean)
|
||||
.map((seg: string) => {
|
||||
const [label, stockStr, color] = seg.split(',').map((s) => s.trim())
|
||||
const stock = Number(stockStr ?? 0) || 0
|
||||
let status: ProductSizeStatus['status'] = 'ok'
|
||||
if (color === 'red') status = 'low'
|
||||
else if (color === 'yellow') status = 'warn'
|
||||
return {
|
||||
label: label || '',
|
||||
status,
|
||||
title: `${label || ''}码`,
|
||||
stock,
|
||||
sales: undefined
|
||||
} as ProductSizeStatus
|
||||
}),
|
||||
sizeStatusText: row.sizeStatusText ?? '',
|
||||
sizeStatusStatus: (row.sizeStatusStatus as UiStatus) ?? 'info',
|
||||
// 类型/生命周期标签:优先 type,其次 cz
|
||||
lifecycle: row.lifecycle ?? '',
|
||||
lifecycleType: (row.lifecycleType as any) ?? 'info',
|
||||
imageUrl: row.imageUrl ?? row.pic ?? undefined,
|
||||
type: row.type ?? row.cz ?? row.lx ?? undefined,
|
||||
supplierName: row.supplierName ?? row.ghs ?? undefined,
|
||||
productId: row.productId ?? undefined,
|
||||
barcode: row.barcode ?? row.sptm ?? undefined,
|
||||
purchasePrice,
|
||||
sellingPrice,
|
||||
grossMargin
|
||||
}
|
||||
})
|
||||
} else {
|
||||
productList.value = []
|
||||
}
|
||||
|
||||
ElMessage.success('查询成功')
|
||||
}, 300)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ElMessage.error('查询失败,请稍后重试')
|
||||
productList.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBrandOptions() {
|
||||
@@ -1045,6 +1132,8 @@ onMounted(async () => {
|
||||
if (queryParams.ckdm.length === 0 && storeOptions.value.length > 0) {
|
||||
queryParams.ckdm = storeOptions.value.map((o) => o.value)
|
||||
}
|
||||
// 进入页面后直接按当前查询条件拉取商品数据
|
||||
await handleQuery()
|
||||
})
|
||||
|
||||
watch(() => route.query, () => applyQueryFromRoute(), { deep: true })
|
||||
@@ -1052,7 +1141,6 @@ watch(() => route.query, () => applyQueryFromRoute(), { deep: true })
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.product-cards-page {
|
||||
padding: 16px;
|
||||
background: var(--el-bg-color-page);
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -1175,18 +1263,17 @@ watch(() => route.query, () => applyQueryFromRoute(), { deep: true })
|
||||
margin-bottom: 16px;
|
||||
|
||||
.pill {
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
background: var(--el-bg-color);
|
||||
color: var(--el-text-color-regular);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-fill-color);
|
||||
}
|
||||
|
||||
&.active {
|
||||
@@ -1194,6 +1281,24 @@ watch(() => route.query, () => applyQueryFromRoute(), { deep: true })
|
||||
color: #fff;
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
&.kb22-tag-danger {
|
||||
background: #fee2e2;
|
||||
color: #ef4444;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
&.kb22-tag-warn {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
|
||||
&.kb22-tag-success {
|
||||
background: #dcfce7;
|
||||
color: #16a34a;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1213,14 +1318,9 @@ watch(() => route.query, () => applyQueryFromRoute(), { deep: true })
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
|
||||
.product-image {
|
||||
width: 48px;
|
||||
height: 56px;
|
||||
height: 64px;
|
||||
background-color: var(--el-fill-color);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
@@ -1230,6 +1330,7 @@ watch(() => route.query, () => applyQueryFromRoute(), { deep: true })
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.product-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -1237,6 +1338,12 @@ watch(() => route.query, () => applyQueryFromRoute(), { deep: true })
|
||||
}
|
||||
}
|
||||
|
||||
.product-info {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.product-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -1298,12 +1405,11 @@ watch(() => route.query, () => applyQueryFromRoute(), { deep: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.product-cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@@ -1381,8 +1487,9 @@ $kb22-border: #e2e8f0;
|
||||
}
|
||||
.kb22-tags-container { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
||||
.kb22-tag-pill { font-size: 10px; padding: 2px 8px; border-radius: 4px; font-weight: 600; }
|
||||
.kb22-tag-blue { background: #eff6ff; color: $kb22-accent; border: 1px solid #dbeafe; }
|
||||
.kb22-tag-purple { background: #f5f3ff; color: #7c3aed; border: 1px solid #ede9fe; }
|
||||
.kb22-tag-danger { background: #fee2e2; color: #ef4444; border: 1px solid #fecaca; }
|
||||
.kb22-tag-warn { background: #fef3c7; color: #d97706; border: 1px solid #fde68a; }
|
||||
.kb22-tag-success { background: #dcfce7; color: #16a34a; border: 1px solid #bbf7d0; }
|
||||
.kb22-price-section { margin-top: auto; }
|
||||
.kb22-current-price { font-size: 22px; font-weight: 800; color: $kb22-primary; letter-spacing: -0.5px; }
|
||||
.kb22-cost-price { font-size: 12px; color: #94a3b8; text-decoration: line-through; margin-left: 6px; }
|
||||
@@ -1487,7 +1594,7 @@ $kb22-border: #e2e8f0;
|
||||
margin-top: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.kb22-legend-item { display: flex; align-items: center; gap: 4px; font-size: 9px; color: #94a3b8; }
|
||||
.kb22-legend-item { display: flex; align-items: center; gap: 4px; font-size: 12px; color: #94a3b8; }
|
||||
.kb22-dot { width: 6px; height: 6px; border-radius: 50%; }
|
||||
.kb22-dot-stock { background: #0f172a; }
|
||||
.kb22-dot-sales { background: #3b82f6; }
|
||||
|
||||
@@ -196,20 +196,38 @@
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 内容卡片:展示形式切换 + 标签筛选 + 卡片/列表(标签放在卡片内部更统一) -->
|
||||
<el-card class="content-card" shadow="never">
|
||||
<!-- 展示形式切换:移动到内容卡片内部,置于标签上方,仅保留切换按钮 -->
|
||||
<div class="view-toolbar">
|
||||
<div class="view-switch">
|
||||
<el-radio-group v-model="displayMode" size="default">
|
||||
<el-radio-button label="card">
|
||||
<el-icon><Grid /></el-icon>
|
||||
卡片
|
||||
</el-radio-button>
|
||||
<el-radio-button label="table">
|
||||
<el-icon><List /></el-icon>
|
||||
列表
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签分类筛选:与主页供应商表现一致 -->
|
||||
<div v-if="labelOpts.length > 0" class="filter-bar">
|
||||
<div
|
||||
v-for="opt in labelOpts"
|
||||
:key="opt.value"
|
||||
class="pill"
|
||||
:class="{ active: labelFilter === opt.value }"
|
||||
:class="['pill', getTagClass(opt.label), { active: labelFilter === opt.value }]"
|
||||
@click="labelFilter = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cards-wrap" v-loading="loading" element-loading-text="加载中...">
|
||||
<!-- 卡片形式 -->
|
||||
<div v-show="displayMode === 'card'" class="cards-wrap" v-loading="loading" element-loading-text="加载中...">
|
||||
<div v-if="visibleRows.length === 0 && !loading" class="empty">暂无数据</div>
|
||||
<div v-else class="cards-grid">
|
||||
<div
|
||||
@@ -282,6 +300,66 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表形式(不需要悬浮卡片) -->
|
||||
<div v-show="displayMode === 'table'" class="table-wrap">
|
||||
<el-table :data="visibleRows" v-loading="loading" border stripe style="width: 100%">
|
||||
<el-table-column label="供应商" min-width="220" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div class="table-title">
|
||||
<div class="table-title-main">{{ String((row as any)?.title ?? (row as any)?.name ?? '-') }}</div>
|
||||
<div v-if="getRowTags(row).length > 0" class="table-tags">
|
||||
<span
|
||||
v-for="(tag, ti) in getRowTags(row)"
|
||||
:key="ti"
|
||||
class="badge"
|
||||
:class="getTagClass(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="核心指标" width="160" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-core">
|
||||
<div class="core-name">{{ getTitleName(row) || '-' }}</div>
|
||||
<div class="core-value">{{ getTitleValue(row) || '-' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="检测指标明细" min-width="420">
|
||||
<template #default="{ row }">
|
||||
<div class="metric-cell">
|
||||
<div
|
||||
v-for="(item, mi) in getTableData(row)"
|
||||
:key="mi"
|
||||
class="metric-cell-row"
|
||||
>
|
||||
<div class="metric-cell-name">{{ item.metricName }}</div>
|
||||
<div class="metric-cell-actual" :class="item.valueColorClass">
|
||||
{{ item.actualValue }}
|
||||
<span v-if="item.arrow" class="trend-arrow" :class="item.arrowClass">{{ item.arrow }}</span>
|
||||
<template v-if="item.labelPairs && item.labelPairs.length > 0">
|
||||
<span
|
||||
v-for="(pair, pi) in item.labelPairs"
|
||||
:key="pi"
|
||||
class="badge-small"
|
||||
:class="getTrendClassFromColor(pair.color)"
|
||||
>
|
||||
{{ pair.label }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="metric-cell-ref">{{ item.paramValue }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -290,6 +368,7 @@ import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import dayjs from 'dayjs'
|
||||
import { Grid, List } from '@element-plus/icons-vue'
|
||||
import { ReportApi } from '@/api/ydoyun/report/reportpage'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
@@ -305,6 +384,9 @@ const REPORT_ID = 6
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const loading = ref(false)
|
||||
|
||||
/** 展示形式:卡片 | 列表 */
|
||||
const displayMode = ref<'card' | 'table'>('card')
|
||||
const columns = ref<ColumnCfg[]>([])
|
||||
const rows = ref<any[]>([])
|
||||
/** 标签分类筛选(与主页供应商表现一致) */
|
||||
@@ -341,6 +423,46 @@ function initQueryParamsFromRoute() {
|
||||
if (q.line) queryParams.line = String(q.line).split(',').filter(Boolean)
|
||||
if (q.code) supplierCode.value = String(q.code)
|
||||
dateRange.value = [queryParams.rq, queryParams.rq2]
|
||||
|
||||
// 根据路由传入的时间区间反推当前快捷按钮
|
||||
try {
|
||||
const rq = queryParams.rq
|
||||
const rq2 = queryParams.rq2
|
||||
const today = dayjs().format('YYYY-MM-DD')
|
||||
|
||||
// 仅在 rq2 为今天时才尝试匹配 week7/day15/day30 或年份
|
||||
if (rq && rq2 && rq2 === today) {
|
||||
const diff = dayjs(today).diff(dayjs(rq), 'day')
|
||||
if (diff === 6) {
|
||||
activeTimeRange.value = 'week7'
|
||||
return
|
||||
}
|
||||
if (diff === 14) {
|
||||
activeTimeRange.value = 'day15'
|
||||
return
|
||||
}
|
||||
if (diff === 29) {
|
||||
activeTimeRange.value = 'day30'
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试匹配全年快捷:从某年1月1日到该年末或今天
|
||||
if (rq && rq2) {
|
||||
const startYear = dayjs(rq).year()
|
||||
const yearStart = dayjs(`${startYear}-01-01`).format('YYYY-MM-DD')
|
||||
const yearEnd = startYear === dayjs().year() ? today : dayjs(`${startYear}-12-31`).format('YYYY-MM-DD')
|
||||
if (rq === yearStart && rq2 === yearEnd) {
|
||||
activeTimeRange.value = `year${startYear}`
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 其余情况,不高亮任何快捷按钮
|
||||
activeTimeRange.value = ''
|
||||
} catch {
|
||||
activeTimeRange.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 下拉选项(门店通过 executeTable tableName=kehu 获取)
|
||||
@@ -822,6 +944,36 @@ onMounted(async () => {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.view-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.view-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.toolbar-label {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.query-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -921,12 +1073,176 @@ onMounted(async () => {
|
||||
color: #fff;
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
&.badge-danger {
|
||||
background: #fee2e2;
|
||||
color: #ef4444;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
&.badge-warn {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
|
||||
&.badge-success {
|
||||
background: #dcfce7;
|
||||
color: #22c55e;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
}
|
||||
|
||||
.cards-wrap {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
|
||||
.table-title-main {
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
.badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: #fee2e2;
|
||||
color: #ef4444;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.badge-warn {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #dcfce7;
|
||||
color: #22c55e;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-core {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.core-name {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.core-value {
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.metric-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.metric-cell-row {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 1fr 120px;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.metric-cell-name {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.metric-cell-actual {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-primary);
|
||||
line-height: 1.4;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.trend-arrow {
|
||||
font-weight: 700;
|
||||
margin-left: 2px;
|
||||
|
||||
&.arrow-up {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
&.arrow-down {
|
||||
color: #22c55e;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-small {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
display: inline-block;
|
||||
|
||||
&.badge-danger,
|
||||
&.label-trend-up {
|
||||
background: #fee2e2;
|
||||
color: #ef4444;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
&.badge-warn,
|
||||
&.label-trend-flat {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
&.badge-success,
|
||||
&.label-trend-down {
|
||||
background: #dcfce7;
|
||||
color: #22c55e;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.metric-cell-ref {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-align: right;
|
||||
line-height: 1.4;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
|
||||
@@ -206,21 +206,38 @@
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 内容卡片:展示形式切换 + 标签筛选 + 卡片/列表 -->
|
||||
<el-card class="content-card" shadow="never">
|
||||
<!-- 展示形式切换(移动到卡片内部,置于标签上方,仅显示切换按钮) -->
|
||||
<div class="view-toolbar">
|
||||
<div class="view-switch">
|
||||
<el-radio-group v-model="displayMode" size="default">
|
||||
<el-radio-button label="card">
|
||||
<el-icon><Grid /></el-icon>
|
||||
卡片
|
||||
</el-radio-button>
|
||||
<el-radio-button label="table">
|
||||
<el-icon><List /></el-icon>
|
||||
列表
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签分类筛选 -->
|
||||
<div v-if="labelOpts.length > 0" class="filter-bar">
|
||||
<div
|
||||
v-for="opt in labelOpts"
|
||||
:key="opt.value"
|
||||
class="pill"
|
||||
:class="{ active: labelFilter === opt.value }"
|
||||
:class="['pill', getTagClass(opt.label), { active: labelFilter === opt.value }]"
|
||||
@click="labelFilter = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 品类卡片列表 -->
|
||||
<div class="category-cards-grid" v-loading="loading">
|
||||
<!-- 卡片形式:品类卡片列表 -->
|
||||
<div v-show="displayMode === 'card'" class="category-cards-grid" v-loading="loading">
|
||||
<div v-if="!loading && visibleRows.length === 0" class="empty-tip">暂无数据</div>
|
||||
<transition-group v-else name="card-list" tag="div" class="cards-container">
|
||||
<div
|
||||
@@ -290,6 +307,66 @@
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
|
||||
<!-- 列表形式(不需要悬浮卡片) -->
|
||||
<div v-show="displayMode === 'table'" class="table-wrap">
|
||||
<el-table :data="visibleRows" v-loading="loading" border stripe style="width: 100%">
|
||||
<el-table-column label="中类" min-width="220" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div class="table-title">
|
||||
<div class="table-title-main">{{ String(row?.title ?? row?.name ?? '-') }}</div>
|
||||
<div v-if="getRowTags(row).length > 0" class="table-tags">
|
||||
<span
|
||||
v-for="(tag, ti) in getRowTags(row)"
|
||||
:key="ti"
|
||||
class="badge"
|
||||
:class="getTagClass(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="核心指标" width="160" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-core">
|
||||
<div class="core-name">{{ getTitleName(row) || '-' }}</div>
|
||||
<div class="core-value">{{ getTitleValue(row) || '-' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="检测指标明细" min-width="420">
|
||||
<template #default="{ row }">
|
||||
<div class="metric-cell">
|
||||
<div
|
||||
v-for="(item, mi) in getTableData(row)"
|
||||
:key="mi"
|
||||
class="metric-cell-row"
|
||||
>
|
||||
<div class="metric-cell-name">{{ item.metricName }}</div>
|
||||
<div class="metric-cell-actual" :class="item.valueColorClass">
|
||||
{{ item.actualValue }}
|
||||
<span v-if="item.arrow" class="metric-trend-arrow" :class="item.arrowClass">{{ item.arrow }}</span>
|
||||
<template v-if="item.labelPairs && item.labelPairs.length > 0">
|
||||
<span
|
||||
v-for="(pair, pi) in item.labelPairs"
|
||||
:key="pi"
|
||||
class="metric-badge-small"
|
||||
:class="getTrendClassFromColor(pair.color)"
|
||||
>
|
||||
{{ pair.label }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="metric-cell-ref">{{ item.paramValue }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -297,7 +374,7 @@
|
||||
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { Search, Grid, List } from '@element-plus/icons-vue'
|
||||
import { ReportApi } from '@/api/ydoyun/report/reportpage'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
@@ -396,6 +473,8 @@ const getTimeRange = (range: string) => {
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
/** 展示形式:卡片 | 列表 */
|
||||
const displayMode = ref<'card' | 'table'>('card')
|
||||
const columns = ref<{ title: string; key: string; order?: number; labelKey?: string; colorKey?: string }[]>([])
|
||||
const categoryList = ref<any[]>([])
|
||||
/** 标签分类筛选 */
|
||||
@@ -414,11 +493,44 @@ function initQueryParamsFromRoute() {
|
||||
if (q.line) queryParams.line = String(q.line).split(',').filter(Boolean)
|
||||
if (q.zhonglei !== undefined) queryParams.zhonglei = String(q.zhonglei)
|
||||
dateRange.value = [queryParams.rq, queryParams.rq2]
|
||||
// 如果日期范围匹配默认的"近一周",设置 activeTimeRange
|
||||
const defaultWeek7 = getTimeRange('week7')
|
||||
if (queryParams.rq === defaultWeek7.rq && queryParams.rq2 === defaultWeek7.rq2) {
|
||||
|
||||
// 根据路由传入的时间区间反推当前快捷按钮
|
||||
try {
|
||||
const rq = queryParams.rq
|
||||
const rq2 = queryParams.rq2
|
||||
const today = dayjs().format('YYYY-MM-DD')
|
||||
|
||||
// 仅在 rq2 为今天时才尝试匹配 week7/day15/day30 或年份
|
||||
if (rq && rq2 && rq2 === today) {
|
||||
const diff = dayjs(today).diff(dayjs(rq), 'day')
|
||||
if (diff === 6) {
|
||||
activeTimeRange.value = 'week7'
|
||||
} else {
|
||||
return
|
||||
}
|
||||
if (diff === 14) {
|
||||
activeTimeRange.value = 'day15'
|
||||
return
|
||||
}
|
||||
if (diff === 29) {
|
||||
activeTimeRange.value = 'day30'
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试匹配全年快捷:从某年1月1日到该年末或今天
|
||||
if (rq && rq2) {
|
||||
const startYear = dayjs(rq).year()
|
||||
const yearStart = dayjs(`${startYear}-01-01`).format('YYYY-MM-DD')
|
||||
const yearEnd = startYear === dayjs().year() ? today : dayjs(`${startYear}-12-31`).format('YYYY-MM-DD')
|
||||
if (rq === yearStart && rq2 === yearEnd) {
|
||||
activeTimeRange.value = `year${startYear}`
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 其余情况,不高亮任何快捷按钮
|
||||
activeTimeRange.value = ''
|
||||
} catch {
|
||||
activeTimeRange.value = ''
|
||||
}
|
||||
}
|
||||
@@ -843,6 +955,179 @@ onMounted(async () => {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.view-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.view-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.toolbar-label {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
|
||||
.table-title-main {
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
.badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: #fee2e2;
|
||||
color: #ef4444;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.badge-warn {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #dcfce7;
|
||||
color: #22c55e;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-core {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.core-name {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.core-value {
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.metric-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.metric-cell-row {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 1fr 120px;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.metric-cell-name {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.metric-cell-actual {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-primary);
|
||||
line-height: 1.4;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.metric-trend-arrow {
|
||||
font-weight: 700;
|
||||
margin-left: 2px;
|
||||
|
||||
&.arrow-up {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
&.arrow-down {
|
||||
color: #22c55e;
|
||||
}
|
||||
}
|
||||
|
||||
.metric-badge-small {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
display: inline-block;
|
||||
|
||||
&.badge-danger {
|
||||
background: #fee2e2;
|
||||
color: #ef4444;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
&.badge-warn {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
&.badge-success {
|
||||
background: #dcfce7;
|
||||
color: #22c55e;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.metric-cell-ref {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-align: right;
|
||||
line-height: 1.4;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.query-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -918,6 +1203,149 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
|
||||
.table-title-main {
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
.badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: #fee2e2;
|
||||
color: #ef4444;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.badge-warn {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #dcfce7;
|
||||
color: #22c55e;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table-core {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.core-name {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.core-value {
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.metric-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.metric-cell-row {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 1fr 120px;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.metric-cell-name {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.metric-cell-actual {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-primary);
|
||||
line-height: 1.4;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.metric-trend-arrow {
|
||||
font-weight: 700;
|
||||
margin-left: 2px;
|
||||
|
||||
&.arrow-up {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
&.arrow-down {
|
||||
color: #22c55e;
|
||||
}
|
||||
}
|
||||
|
||||
.metric-badge-small {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
display: inline-block;
|
||||
|
||||
&.badge-danger {
|
||||
background: #fee2e2;
|
||||
color: #ef4444;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
&.badge-warn {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
&.badge-success {
|
||||
background: #dcfce7;
|
||||
color: #22c55e;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.metric-cell-ref {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-align: right;
|
||||
line-height: 1.4;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.pill {
|
||||
white-space: nowrap;
|
||||
padding: 4px 10px;
|
||||
@@ -938,6 +1366,24 @@ onMounted(async () => {
|
||||
color: #fff;
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
&.badge-danger {
|
||||
background: #fee2e2;
|
||||
color: #ef4444;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
&.badge-warn {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
|
||||
&.badge-success {
|
||||
background: #dcfce7;
|
||||
color: #22c55e;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 卡片网格布局 ====================
|
||||
|
||||
@@ -197,9 +197,11 @@
|
||||
</el-card>
|
||||
|
||||
<!-- 展示形式切换(放在最上面) -->
|
||||
<!-- 内容卡片:展示形式切换 + 标签 + 卡片/列表内容(标签放在卡片内,不突兀) -->
|
||||
<el-card class="content-card" shadow="never">
|
||||
<!-- 展示形式切换:移入内容卡片内部,置于标签上方,仅保留切换按钮 -->
|
||||
<div class="view-toolbar">
|
||||
<div class="view-switch">
|
||||
<span class="toolbar-label">展示形式:</span>
|
||||
<el-radio-group v-model="displayMode" size="default">
|
||||
<el-radio-button label="card">
|
||||
<el-icon><Grid /></el-icon>
|
||||
@@ -225,15 +227,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容卡片:标签 + 卡片/列表内容(标签放在卡片内,不突兀) -->
|
||||
<el-card class="content-card" shadow="never">
|
||||
<!-- 标签分类筛选:放在卡片内部 -->
|
||||
<div v-if="labelOpts.length > 0" class="filter-bar">
|
||||
<div
|
||||
v-for="opt in labelOpts"
|
||||
:key="opt.value"
|
||||
class="pill"
|
||||
:class="{ active: labelFilter === opt.value }"
|
||||
:class="['pill', { active: labelFilter === opt.value }]"
|
||||
@click="labelFilter = opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
@@ -495,6 +494,46 @@ function initQueryParamsFromRoute() {
|
||||
if (q.line) queryParams.line = String(q.line).split(',').filter(Boolean)
|
||||
if (q.spdm) queryParams.spdm = String(q.spdm)
|
||||
dateRange.value = [queryParams.rq, queryParams.rq2]
|
||||
|
||||
// 根据路由传入的时间区间反推当前快捷按钮
|
||||
try {
|
||||
const rq = queryParams.rq
|
||||
const rq2 = queryParams.rq2
|
||||
const today = dayjs().format('YYYY-MM-DD')
|
||||
|
||||
// 仅在 rq2 为今天时才尝试匹配 week7/day15/day30 或年份
|
||||
if (rq && rq2 && rq2 === today) {
|
||||
const diff = dayjs(today).diff(dayjs(rq), 'day')
|
||||
if (diff === 6) {
|
||||
activeTimeRange.value = 'week7'
|
||||
return
|
||||
}
|
||||
if (diff === 14) {
|
||||
activeTimeRange.value = 'day15'
|
||||
return
|
||||
}
|
||||
if (diff === 29) {
|
||||
activeTimeRange.value = 'day30'
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试匹配全年快捷:从某年1月1日到该年末或今天
|
||||
if (rq && rq2) {
|
||||
const startYear = dayjs(rq).year()
|
||||
const yearStart = dayjs(`${startYear}-01-01`).format('YYYY-MM-DD')
|
||||
const yearEnd = startYear === dayjs().year() ? today : dayjs(`${startYear}-12-31`).format('YYYY-MM-DD')
|
||||
if (rq === yearStart && rq2 === yearEnd) {
|
||||
activeTimeRange.value = `year${startYear}`
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 其余情况,不高亮任何快捷按钮
|
||||
activeTimeRange.value = ''
|
||||
} catch {
|
||||
activeTimeRange.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 下拉选项(门店通过 executeTable tableName=kehu 获取)
|
||||
@@ -1275,6 +1314,24 @@ onMounted(async () => {
|
||||
color: #fff;
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
&.badge-danger {
|
||||
background: #fee2e2;
|
||||
color: #ef4444;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
&.badge-warn {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
|
||||
&.badge-success {
|
||||
background: #dcfce7;
|
||||
color: #22c55e;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
}
|
||||
|
||||
.view-toolbar {
|
||||
|
||||
@@ -546,10 +546,11 @@
|
||||
<template #default="{ row }">
|
||||
<el-popover
|
||||
placement="right"
|
||||
:width="340"
|
||||
:width="360"
|
||||
trigger="hover"
|
||||
popper-class="product-detail-popover"
|
||||
:popper-options="{ modifiers: [{ name: 'offset', options: { offset: [0, 12] } }] }"
|
||||
@show="handleProductHoverShow(row)"
|
||||
>
|
||||
<template #reference>
|
||||
<div class="product-info">
|
||||
@@ -574,59 +575,116 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 悬浮详情卡片(KB22 样式) -->
|
||||
<!-- 悬浮详情卡片(KB22 样式,与 product-cards 保持一致,仅展示当前单个商品) -->
|
||||
<div class="product-detail-card kb22-card">
|
||||
<div class="kb22-header-bar">
|
||||
<div>款号: <span class="kb22-code">{{ row.code }}</span></div>
|
||||
<div>款号: <span class="kb22-code">{{ hoverDetail?.code || row.code }}</span></div>
|
||||
<div class="kb22-color-badge">
|
||||
<span class="kb22-color-dot" :style="{ background: getColorCode(row.color) }"></span>
|
||||
{{ row.color }}
|
||||
<span class="kb22-color-dot" :style="{ background: getColorCode(hoverDetail?.color || row.color) }"></span>
|
||||
{{ hoverDetail?.color || row.color }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="kb22-card-body">
|
||||
<div class="kb22-media-row">
|
||||
<div class="kb22-thumb-box">
|
||||
<img v-if="row.imageUrl" :src="row.imageUrl" alt="" class="kb22-thumb-img" />
|
||||
<img v-if="hoverDetail?.imageUrl || row.imageUrl" :src="hoverDetail?.imageUrl || row.imageUrl" alt="" class="kb22-thumb-img" />
|
||||
<div v-else class="kb22-no-img"><el-icon :size="32"><Picture /></el-icon></div>
|
||||
</div>
|
||||
<div class="kb22-info-col">
|
||||
<div class="kb22-prod-title">{{ row.name }}</div>
|
||||
<div class="kb22-prod-title">{{ hoverDetail?.name || row.name }}</div>
|
||||
<div class="kb22-tags-container">
|
||||
<span class="kb22-tag-pill kb22-tag-blue">{{ row.season }}</span>
|
||||
<span class="kb22-tag-pill kb22-tag-purple">{{ row.discount }}</span>
|
||||
<span
|
||||
v-if="hoverDetail?.type"
|
||||
class="kb22-tag-pill"
|
||||
:class="productTagClass(hoverDetail.type)"
|
||||
>
|
||||
{{ hoverDetail.type }}
|
||||
</span>
|
||||
<span
|
||||
v-if="hoverDetail?.lifecycle"
|
||||
class="kb22-tag-pill"
|
||||
:class="productTagClass(hoverDetail.lifecycle)"
|
||||
>
|
||||
{{ hoverDetail.lifecycle }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="kb22-price-section">
|
||||
<span class="kb22-current-price">¥{{ row.sellingPrice ?? 0 }}</span>
|
||||
<span class="kb22-cost-price">¥{{ row.purchasePrice ?? 0 }}</span>
|
||||
<span class="kb22-margin-text">毛利 {{ formatGrossMarginPct(row.grossMargin, row.purchasePrice != null && row.sellingPrice != null ? (1 - row.purchasePrice / row.sellingPrice) * 100 : undefined) }}%</span>
|
||||
<span class="kb22-current-price">
|
||||
<template v-if="(hoverDetail?.sellingPrice ?? row.sellingPrice) != null">
|
||||
¥{{ formatNumber(hoverDetail?.sellingPrice ?? row.sellingPrice) }}
|
||||
</template>
|
||||
<template v-else>-</template>
|
||||
</span>
|
||||
<span class="kb22-cost-price">
|
||||
<template v-if="(hoverDetail?.purchasePrice ?? row.purchasePrice) != null">
|
||||
¥{{ formatNumber(hoverDetail?.purchasePrice ?? row.purchasePrice) }}
|
||||
</template>
|
||||
<template v-else>-</template>
|
||||
</span>
|
||||
<span class="kb22-margin-text">
|
||||
<template v-if="(hoverDetail?.grossMargin ?? row.grossMargin) != null">
|
||||
毛利
|
||||
{{
|
||||
formatGrossMarginPct(
|
||||
hoverDetail?.grossMargin ?? row.grossMargin,
|
||||
(hoverDetail?.purchasePrice ?? row.purchasePrice) != null &&
|
||||
(hoverDetail?.sellingPrice ?? row.sellingPrice) != null
|
||||
? (1 - (hoverDetail?.purchasePrice ?? row.purchasePrice) / (hoverDetail?.sellingPrice ?? row.sellingPrice)) * 100
|
||||
: undefined
|
||||
)
|
||||
}}%
|
||||
</template>
|
||||
<template v-else>-</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kb22-stats-grid">
|
||||
<div class="kb22-stat-card kb22-sales">
|
||||
<div class="kb22-stat-label">总销量</div>
|
||||
<div class="kb22-stat-value">{{ row.salesCount ?? 0 }}<span class="kb22-stat-sub">件</span></div>
|
||||
<div class="kb22-sales-footer">
|
||||
<div class="kb22-mini-progress">
|
||||
<div class="kb22-mini-fill" :style="{ width: Math.min(100, row.selloutRate ?? 0) + '%' }"></div>
|
||||
<div class="kb22-stat-value">
|
||||
<template v-if="(hoverDetail?.salesCount ?? row.salesCount) != null">
|
||||
{{ hoverDetail?.salesCount ?? row.salesCount }}<span class="kb22-stat-sub">件</span>
|
||||
</template>
|
||||
<template v-else>-</template>
|
||||
</div>
|
||||
<div class="kb22-sales-footer">
|
||||
<div class="kb22-mini-progress" v-if="(hoverDetail?.selloutRate ?? row.selloutRate) != null">
|
||||
<div class="kb22-mini-fill" :style="{ width: Math.min(100, hoverDetail?.selloutRate ?? row.selloutRate) + '%' }"></div>
|
||||
</div>
|
||||
<div class="kb22-rate-text">
|
||||
<template v-if="(hoverDetail?.selloutRate ?? row.selloutRate) != null">
|
||||
售罄率 {{ hoverDetail?.selloutRate ?? row.selloutRate }}%
|
||||
</template>
|
||||
<template v-else>-</template>
|
||||
</div>
|
||||
<div class="kb22-rate-text">售罄率 {{ row.selloutRate ?? 0 }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kb22-stat-card kb22-stock">
|
||||
<div class="kb22-stat-label">当前库存</div>
|
||||
<div class="kb22-stat-value kcsl-display">{{ row.kcslRaw || (row.inventoryCount != null ? row.inventoryCount + '件' : '0件') }}</div>
|
||||
<div v-if="row.turnoverText" class="kb22-turnover-text" :class="uiTextClass(row.turnoverStatus)">周转: {{ row.turnoverText }}</div>
|
||||
<div class="kb22-stat-value kcsl-display">
|
||||
<template v-if="(hoverDetail?.inventoryCount ?? row.inventoryCount) != null">
|
||||
{{ hoverDetail?.inventoryCount ?? row.inventoryCount }}件
|
||||
</template>
|
||||
<template v-else>-</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="hoverDetail?.turnoverText || row.turnoverText"
|
||||
class="kb22-turnover-text"
|
||||
:class="uiTextClass((hoverDetail?.turnoverStatus as any) || row.turnoverStatus)"
|
||||
>
|
||||
周转: {{ hoverDetail?.turnoverText || row.turnoverText }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="row.sizes && row.sizes.length" class="kb22-stock-footer">
|
||||
</div>
|
||||
<div v-if="(hoverDetail?.sizes && hoverDetail.sizes.length) || (row.sizes && row.sizes.length)" class="kb22-stock-footer">
|
||||
<div class="kb22-section-header">
|
||||
<span>SKU明细 ({{ row.sizes.length }}码)</span>
|
||||
<span v-if="hasOutOfStockSize(row.sizes)" class="kb22-alert-tip">⚠️ 有断货</span>
|
||||
<span>SKU明细 ({{ (hoverDetail?.sizes || row.sizes).length }}码)</span>
|
||||
<span v-if="hasOutOfStockSize(hoverDetail?.sizes || row.sizes)" class="kb22-alert-tip">⚠️ 有断货</span>
|
||||
</div>
|
||||
<div class="kb22-size-grid">
|
||||
<div
|
||||
v-for="(size, i) in row.sizes"
|
||||
v-for="(size, i) in (hoverDetail?.sizes || row.sizes)"
|
||||
:key="i"
|
||||
class="kb22-size-cell"
|
||||
:class="{ 'kb22-alert': size.status === 'out' || size.stock === 0 }"
|
||||
@@ -1176,6 +1234,7 @@ const loadingPie = ref(false)
|
||||
/** 商品明细列表加载状态 */
|
||||
const loadingProductList = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
const hoverDetail = ref<ProductDetailData | null>(null)
|
||||
|
||||
// 计算时间区间(近一周、近15天、近30天、年份)
|
||||
const getTimeRange = (range: string) => {
|
||||
@@ -1211,6 +1270,44 @@ const getTimeRange = (range: string) => {
|
||||
return { rq: start.format('YYYY-MM-DD'), rq2: end.format('YYYY-MM-DD') }
|
||||
}
|
||||
|
||||
// 商品明细 hover 时,按当前查询条件 + spdm 取最新单品详情(使用与 product-cards 相同的存储过程)
|
||||
const handleProductHoverShow = async (row: ProductDetailData) => {
|
||||
try {
|
||||
const params = {
|
||||
reportId: REPORT_ID,
|
||||
rq_s: queryParams.rq,
|
||||
rq_e: queryParams.rq2,
|
||||
ckdm: arrToQuery(queryParams.ckdm),
|
||||
pp: arrToQuery(queryParams.pp),
|
||||
dalei: arrToQuery(queryParams.category),
|
||||
jj: arrToQuery(queryParams.season),
|
||||
p: '123',
|
||||
username: username.value,
|
||||
spdm: String(row.spdm || '')
|
||||
}
|
||||
|
||||
const res: any = await ReportApi.executeProcedureWithData({
|
||||
...params,
|
||||
name: 'YDY_AI_GET_SPXQ'
|
||||
} as any)
|
||||
|
||||
let data: any[] | null = null
|
||||
if (Array.isArray(res)) data = res
|
||||
else if (res && Array.isArray(res.data)) data = res.data
|
||||
else if (res && res.code != null && Array.isArray(res.data)) data = res.data
|
||||
|
||||
if (data && data.length > 0) {
|
||||
// 复用现有映射逻辑,将第一条记录映射为 ProductDetailData
|
||||
hoverDetail.value = mapApiRowToProductDetail(data[0])
|
||||
} else {
|
||||
hoverDetail.value = null
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载商品 hover 详情失败:', e)
|
||||
hoverDetail.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 处理快捷时间按钮点击
|
||||
const handleTimeRangeClick = (range: string) => {
|
||||
activeTimeRange.value = range
|
||||
@@ -1920,13 +2017,23 @@ const getProductCode = (row: ProductDetailData): string => {
|
||||
return row.code || '-'
|
||||
}
|
||||
|
||||
// 点击款号跳转到详情页面
|
||||
// 点击款号跳转到详情页面(携带当前大盘的全部查询条件)
|
||||
const handleProductCodeClick = (row: ProductDetailData) => {
|
||||
const spdm = row.spdm || row.code || ''
|
||||
router.push({
|
||||
path: '/reports/lijun/reportpage6/detail',
|
||||
query: {
|
||||
spdm: spdm
|
||||
// 关键商品编码
|
||||
spdm,
|
||||
// 透传当前大盘查询条件,明细页可直接还原
|
||||
rq: queryParams.rq,
|
||||
rq2: queryParams.rq2,
|
||||
ckdm: queryParams.ckdm.join(','),
|
||||
pp: queryParams.pp.join(','),
|
||||
season: queryParams.season.join(','),
|
||||
zgj: queryParams.zgj.join(','),
|
||||
category: queryParams.category.join(','),
|
||||
line: queryParams.line.join(',')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
111
src/views/ydoyun/reportdatabase/DatabaseList.vue
Normal file
111
src/views/ydoyun/reportdatabase/DatabaseList.vue
Normal 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>
|
||||
190
src/views/ydoyun/storecustomtag/index.vue
Normal file
190
src/views/ydoyun/storecustomtag/index.vue
Normal 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>
|
||||
190
src/views/ydoyun/suppliercustomtag/index.vue
Normal file
190
src/views/ydoyun/suppliercustomtag/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user