Compare commits

..

2 Commits

Author SHA1 Message Date
13fe5d9124 fix: 自定义标签 2026-03-03 15:40:21 +08:00
fe4e742551 fix: 自定义标签 2026-03-03 15:36:57 +08:00
53 changed files with 10022 additions and 556 deletions

12
.editorconfig Normal file
View File

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

37
.env Normal file
View File

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

38
.env.dev Normal file
View File

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

35
.env.local Normal file
View File

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

34
.env.prod Normal file
View File

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

34
.env.stage Normal file
View File

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

34
.env.test Normal file
View File

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

8
.eslintignore Normal file
View File

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

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

@@ -0,0 +1,259 @@
{
"globals": {
"EffectScope": true,
"ElMessage": true,
"ElMessageBox": true,
"ElTag": true,
"asyncComputed": true,
"autoResetRef": true,
"computed": true,
"computedAsync": true,
"computedEager": true,
"computedInject": true,
"computedWithControl": true,
"controlledComputed": true,
"controlledRef": true,
"createApp": true,
"createEventHook": true,
"createGlobalState": true,
"createInjectionState": true,
"createReactiveFn": true,
"createSharedComposable": true,
"createUnrefFn": true,
"customRef": true,
"debouncedRef": true,
"debouncedWatch": true,
"defineAsyncComponent": true,
"defineComponent": true,
"eagerComputed": true,
"effectScope": true,
"extendRef": true,
"getCurrentInstance": true,
"getCurrentScope": true,
"h": true,
"ignorableWatch": true,
"inject": true,
"isDefined": true,
"isProxy": true,
"isReactive": true,
"isReadonly": true,
"isRef": true,
"makeDestructurable": true,
"markRaw": true,
"nextTick": true,
"onActivated": true,
"onBeforeMount": true,
"onBeforeUnmount": true,
"onBeforeUpdate": true,
"onClickOutside": true,
"onDeactivated": true,
"onErrorCaptured": true,
"onKeyStroke": true,
"onLongPress": true,
"onMounted": true,
"onRenderTracked": true,
"onRenderTriggered": true,
"onScopeDispose": true,
"onServerPrefetch": true,
"onStartTyping": true,
"onUnmounted": true,
"onUpdated": true,
"pausableWatch": true,
"provide": true,
"reactify": true,
"reactifyObject": true,
"reactive": true,
"reactiveComputed": true,
"reactiveOmit": true,
"reactivePick": true,
"readonly": true,
"ref": true,
"refAutoReset": true,
"refDebounced": true,
"refDefault": true,
"refThrottled": true,
"refWithControl": true,
"resolveComponent": true,
"resolveRef": true,
"resolveUnref": true,
"shallowReactive": true,
"shallowReadonly": true,
"shallowRef": true,
"syncRef": true,
"syncRefs": true,
"templateRef": true,
"throttledRef": true,
"throttledWatch": true,
"toRaw": true,
"toReactive": true,
"toRef": true,
"toRefs": true,
"triggerRef": true,
"tryOnBeforeMount": true,
"tryOnBeforeUnmount": true,
"tryOnMounted": true,
"tryOnScopeDispose": true,
"tryOnUnmounted": true,
"unref": true,
"unrefElement": true,
"until": true,
"useActiveElement": true,
"useArrayEvery": true,
"useArrayFilter": true,
"useArrayFind": true,
"useArrayFindIndex": true,
"useArrayJoin": true,
"useArrayMap": true,
"useArrayReduce": true,
"useArraySome": true,
"useAsyncQueue": true,
"useAsyncState": true,
"useAttrs": true,
"useBase64": true,
"useBattery": true,
"useBluetooth": true,
"useBreakpoints": true,
"useBroadcastChannel": true,
"useBrowserLocation": true,
"useCached": true,
"useClipboard": true,
"useColorMode": true,
"useConfirmDialog": true,
"useCounter": true,
"useCssModule": true,
"useCssVar": true,
"useCssVars": true,
"useCurrentElement": true,
"useCycleList": true,
"useDark": true,
"useDateFormat": true,
"useDebounce": true,
"useDebounceFn": true,
"useDebouncedRefHistory": true,
"useDeviceMotion": true,
"useDeviceOrientation": true,
"useDevicePixelRatio": true,
"useDevicesList": true,
"useDisplayMedia": true,
"useDocumentVisibility": true,
"useDraggable": true,
"useDropZone": true,
"useElementBounding": true,
"useElementByPoint": true,
"useElementHover": true,
"useElementSize": true,
"useElementVisibility": true,
"useEventBus": true,
"useEventListener": true,
"useEventSource": true,
"useEyeDropper": true,
"useFavicon": true,
"useFetch": true,
"useFileDialog": true,
"useFileSystemAccess": true,
"useFocus": true,
"useFocusWithin": true,
"useFps": true,
"useFullscreen": true,
"useGamepad": true,
"useGeolocation": true,
"useIdle": true,
"useImage": true,
"useInfiniteScroll": true,
"useIntersectionObserver": true,
"useInterval": true,
"useIntervalFn": true,
"useKeyModifier": true,
"useLastChanged": true,
"useLocalStorage": true,
"useMagicKeys": true,
"useManualRefHistory": true,
"useMediaControls": true,
"useMediaQuery": true,
"useMemoize": true,
"useMemory": true,
"useMounted": true,
"useMouse": true,
"useMouseInElement": true,
"useMousePressed": true,
"useMutationObserver": true,
"useNavigatorLanguage": true,
"useNetwork": true,
"useNow": true,
"useObjectUrl": true,
"useOffsetPagination": true,
"useOnline": true,
"usePageLeave": true,
"useParallax": true,
"usePermission": true,
"usePointer": true,
"usePointerSwipe": true,
"usePreferredColorScheme": true,
"usePreferredDark": true,
"usePreferredLanguages": true,
"useRafFn": true,
"useRefHistory": true,
"useResizeObserver": true,
"useRoute": true,
"useRouter": true,
"useScreenOrientation": true,
"useScreenSafeArea": true,
"useScriptTag": true,
"useScroll": true,
"useScrollLock": true,
"useSessionStorage": true,
"useShare": true,
"useSlots": true,
"useSpeechRecognition": true,
"useSpeechSynthesis": true,
"useStepper": true,
"useStorage": true,
"useStorageAsync": true,
"useStyleTag": true,
"useSupported": true,
"useSwipe": true,
"useTemplateRefsList": true,
"useTextDirection": true,
"useTextSelection": true,
"useTextareaAutosize": true,
"useThrottle": true,
"useThrottleFn": true,
"useThrottledRefHistory": true,
"useTimeAgo": true,
"useTimeout": true,
"useTimeoutFn": true,
"useTimeoutPoll": true,
"useTimestamp": true,
"useTitle": true,
"useToggle": true,
"useTransition": true,
"useUrlSearchParams": true,
"useUserMedia": true,
"useVModel": true,
"useVModels": true,
"useVibrate": true,
"useVirtualList": true,
"useWakeLock": true,
"useWebNotification": true,
"useWebSocket": true,
"useWebWorker": true,
"useWebWorkerFn": true,
"useWindowFocus": true,
"useWindowScroll": true,
"useWindowSize": true,
"watch": true,
"watchArray": true,
"watchAtMost": true,
"watchDebounced": true,
"watchEffect": true,
"watchIgnorable": true,
"watchOnce": true,
"watchPausable": true,
"watchPostEffect": true,
"watchSyncEffect": true,
"watchThrottled": true,
"watchTriggerable": true,
"watchWithFilter": true,
"whenever": true
}
}

75
.eslintrc.js Normal file
View File

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

9
.gitignore vendored Normal file
View File

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

11
.prettierignore Normal file
View File

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

6
.stylelintignore Normal file
View File

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

@@ -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, // 是否在控制台输出压缩结果

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

File diff suppressed because it is too large Load Diff

View 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 })
}
}

View File

@@ -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')
}
]
}
]

View 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>

View File

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

View File

@@ -0,0 +1,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>

View File

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

View File

@@ -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;
@@ -1299,11 +1406,10 @@ 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; }

View File

@@ -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;

View File

@@ -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;
}
}
// ==================== 卡片网格布局 ====================

View File

@@ -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 {

View File

@@ -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(',')
}
})
}

View File

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

View File

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

View File

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