Compare commits
39 Commits
3806ad6c26
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a7cd16848c | |||
| 33dc672440 | |||
| 7c2f828222 | |||
| d3dde79474 | |||
| 51f04d7528 | |||
| 47a9eaebcd | |||
| 40a4c7fa08 | |||
| dfa908f4a3 | |||
| 4cdc6e0308 | |||
| 7d66ed72c8 | |||
| a12c7caa3a | |||
| d05ded72c7 | |||
| 6818456070 | |||
| c90259e3f4 | |||
| ee4b6c4de7 | |||
| 00374f285b | |||
| 792f8d6abe | |||
| 68da50984c | |||
| 37c18be7c2 | |||
| c9ef3f6f9c | |||
| 4db033c557 | |||
| 34d3e4c715 | |||
| fe1147cebf | |||
| a02e27f270 | |||
| 80ea79edc3 | |||
| 9ce64757fc | |||
| 3c14bbbebf | |||
| c53eedbe09 | |||
| e6cbedbc01 | |||
| f131a23a52 | |||
| 54acbd0f31 | |||
| b4ab3c38aa | |||
| b6f1d71ee6 | |||
| 46b6170c41 | |||
| 2bde0c38b9 | |||
| 3ddf6a2b70 | |||
| df4cdb24b3 | |||
| 734e71976e | |||
| cd634e6a3e |
2
.env
2
.env
@@ -1 +1,3 @@
|
|||||||
VITE_API_URL="https://api.tianyuanapi.com"
|
VITE_API_URL="https://api.tianyuanapi.com"
|
||||||
|
VITE_CAPTCHA_SCENE_ID="wynt39to"
|
||||||
|
VITE_CAPTCHA_ENCRYPTED_MODE=false
|
||||||
@@ -66,6 +66,7 @@
|
|||||||
"downloadFile": true,
|
"downloadFile": true,
|
||||||
"eagerComputed": true,
|
"eagerComputed": true,
|
||||||
"effectScope": true,
|
"effectScope": true,
|
||||||
|
"encodeRequest": true,
|
||||||
"endsWith": true,
|
"endsWith": true,
|
||||||
"escape": true,
|
"escape": true,
|
||||||
"extendRef": true,
|
"extendRef": true,
|
||||||
@@ -77,6 +78,8 @@
|
|||||||
"formatMoney": true,
|
"formatMoney": true,
|
||||||
"formatPhone": true,
|
"formatPhone": true,
|
||||||
"fromNow": true,
|
"fromNow": true,
|
||||||
|
"generateNonce": true,
|
||||||
|
"generateSMSRequest": true,
|
||||||
"generateUUID": true,
|
"generateUUID": true,
|
||||||
"get": true,
|
"get": true,
|
||||||
"getActivePinia": true,
|
"getActivePinia": true,
|
||||||
@@ -215,6 +218,7 @@
|
|||||||
"until": true,
|
"until": true,
|
||||||
"upperCase": true,
|
"upperCase": true,
|
||||||
"useActiveElement": true,
|
"useActiveElement": true,
|
||||||
|
"useAliyunCaptcha": true,
|
||||||
"useAnimate": true,
|
"useAnimate": true,
|
||||||
"useAppStore": true,
|
"useAppStore": true,
|
||||||
"useArrayDifference": true,
|
"useArrayDifference": true,
|
||||||
@@ -362,6 +366,7 @@
|
|||||||
"useThrottleFn": true,
|
"useThrottleFn": true,
|
||||||
"useThrottledRefHistory": true,
|
"useThrottledRefHistory": true,
|
||||||
"useTimeAgo": true,
|
"useTimeAgo": true,
|
||||||
|
"useTimeAgoIntl": true,
|
||||||
"useTimeout": true,
|
"useTimeout": true,
|
||||||
"useTimeoutFn": true,
|
"useTimeoutFn": true,
|
||||||
"useTimeoutPoll": true,
|
"useTimeoutPoll": true,
|
||||||
|
|||||||
12
auto-imports.d.ts
vendored
12
auto-imports.d.ts
vendored
@@ -7,7 +7,7 @@
|
|||||||
export {}
|
export {}
|
||||||
declare global {
|
declare global {
|
||||||
const EffectScope: typeof import('vue')['EffectScope']
|
const EffectScope: typeof import('vue')['EffectScope']
|
||||||
const ElLoading: typeof import('element-plus')['ElLoading']
|
const ElLoading: typeof import('element-plus/es')['ElLoading']
|
||||||
const ElMessage: typeof import('element-plus/es')['ElMessage']
|
const ElMessage: typeof import('element-plus/es')['ElMessage']
|
||||||
const ElMessageBox: typeof import('element-plus/es')['ElMessageBox']
|
const ElMessageBox: typeof import('element-plus/es')['ElMessageBox']
|
||||||
const ElNotification: typeof import('element-plus')['ElNotification']
|
const ElNotification: typeof import('element-plus')['ElNotification']
|
||||||
@@ -63,6 +63,7 @@ declare global {
|
|||||||
const downloadFile: typeof import('./src/utils/index.js')['downloadFile']
|
const downloadFile: typeof import('./src/utils/index.js')['downloadFile']
|
||||||
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
|
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
|
||||||
const effectScope: typeof import('vue')['effectScope']
|
const effectScope: typeof import('vue')['effectScope']
|
||||||
|
const encodeRequest: typeof import('./src/utils/smsSignature.js')['encodeRequest']
|
||||||
const endsWith: typeof import('lodash-es')['endsWith']
|
const endsWith: typeof import('lodash-es')['endsWith']
|
||||||
const errorMonitor: typeof import('./src/utils/errorMonitor.js')['default']
|
const errorMonitor: typeof import('./src/utils/errorMonitor.js')['default']
|
||||||
const escape: typeof import('lodash-es')['escape']
|
const escape: typeof import('lodash-es')['escape']
|
||||||
@@ -80,6 +81,8 @@ declare global {
|
|||||||
const formatPhone: typeof import('./src/utils/index.js')['formatPhone']
|
const formatPhone: typeof import('./src/utils/index.js')['formatPhone']
|
||||||
const fromNow: typeof import('./src/utils/index.js')['fromNow']
|
const fromNow: typeof import('./src/utils/index.js')['fromNow']
|
||||||
const generateFilename: typeof import('./src/utils/export.js')['generateFilename']
|
const generateFilename: typeof import('./src/utils/export.js')['generateFilename']
|
||||||
|
const generateNonce: typeof import('./src/utils/smsSignature.js')['generateNonce']
|
||||||
|
const generateSMSRequest: typeof import('./src/utils/smsSignature.js')['generateSMSRequest']
|
||||||
const generateUUID: typeof import('./src/utils/index.js')['generateUUID']
|
const generateUUID: typeof import('./src/utils/index.js')['generateUUID']
|
||||||
const get: typeof import('lodash-es')['get']
|
const get: typeof import('lodash-es')['get']
|
||||||
const getActivePinia: typeof import('pinia')['getActivePinia']
|
const getActivePinia: typeof import('pinia')['getActivePinia']
|
||||||
@@ -222,6 +225,7 @@ declare global {
|
|||||||
const until: typeof import('@vueuse/core')['until']
|
const until: typeof import('@vueuse/core')['until']
|
||||||
const upperCase: typeof import('lodash-es')['upperCase']
|
const upperCase: typeof import('lodash-es')['upperCase']
|
||||||
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
|
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
|
||||||
|
const useAliyunCaptcha: typeof import('./src/composables/useAliyunCaptcha.js')['default']
|
||||||
const useAnimate: typeof import('@vueuse/core')['useAnimate']
|
const useAnimate: typeof import('@vueuse/core')['useAnimate']
|
||||||
const useAppStore: typeof import('./src/stores/app.js')['useAppStore']
|
const useAppStore: typeof import('./src/stores/app.js')['useAppStore']
|
||||||
const useArrayDifference: typeof import('@vueuse/core')['useArrayDifference']
|
const useArrayDifference: typeof import('@vueuse/core')['useArrayDifference']
|
||||||
@@ -399,6 +403,7 @@ declare global {
|
|||||||
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
|
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
|
||||||
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
|
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
|
||||||
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
|
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
|
||||||
|
const useTimeAgoIntl: typeof import('@vueuse/core')['useTimeAgoIntl']
|
||||||
const useTimeout: typeof import('@vueuse/core')['useTimeout']
|
const useTimeout: typeof import('@vueuse/core')['useTimeout']
|
||||||
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
|
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
|
||||||
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
|
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
|
||||||
@@ -512,6 +517,7 @@ declare module 'vue' {
|
|||||||
readonly downloadFile: UnwrapRef<typeof import('./src/utils/index.js')['downloadFile']>
|
readonly downloadFile: UnwrapRef<typeof import('./src/utils/index.js')['downloadFile']>
|
||||||
readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
|
readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
|
||||||
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
|
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
|
||||||
|
readonly encodeRequest: UnwrapRef<typeof import('./src/utils/smsSignature.js')['encodeRequest']>
|
||||||
readonly endsWith: UnwrapRef<typeof import('lodash-es')['endsWith']>
|
readonly endsWith: UnwrapRef<typeof import('lodash-es')['endsWith']>
|
||||||
readonly escape: UnwrapRef<typeof import('lodash-es')['escape']>
|
readonly escape: UnwrapRef<typeof import('lodash-es')['escape']>
|
||||||
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
|
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
|
||||||
@@ -523,6 +529,8 @@ declare module 'vue' {
|
|||||||
readonly formatMoney: UnwrapRef<typeof import('./src/utils/index.js')['formatMoney']>
|
readonly formatMoney: UnwrapRef<typeof import('./src/utils/index.js')['formatMoney']>
|
||||||
readonly formatPhone: UnwrapRef<typeof import('./src/utils/index.js')['formatPhone']>
|
readonly formatPhone: UnwrapRef<typeof import('./src/utils/index.js')['formatPhone']>
|
||||||
readonly fromNow: UnwrapRef<typeof import('./src/utils/index.js')['fromNow']>
|
readonly fromNow: UnwrapRef<typeof import('./src/utils/index.js')['fromNow']>
|
||||||
|
readonly generateNonce: UnwrapRef<typeof import('./src/utils/smsSignature.js')['generateNonce']>
|
||||||
|
readonly generateSMSRequest: UnwrapRef<typeof import('./src/utils/smsSignature.js')['generateSMSRequest']>
|
||||||
readonly generateUUID: UnwrapRef<typeof import('./src/utils/index.js')['generateUUID']>
|
readonly generateUUID: UnwrapRef<typeof import('./src/utils/index.js')['generateUUID']>
|
||||||
readonly get: UnwrapRef<typeof import('lodash-es')['get']>
|
readonly get: UnwrapRef<typeof import('lodash-es')['get']>
|
||||||
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
|
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
|
||||||
@@ -661,6 +669,7 @@ declare module 'vue' {
|
|||||||
readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>
|
readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>
|
||||||
readonly upperCase: UnwrapRef<typeof import('lodash-es')['upperCase']>
|
readonly upperCase: UnwrapRef<typeof import('lodash-es')['upperCase']>
|
||||||
readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>
|
readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>
|
||||||
|
readonly useAliyunCaptcha: UnwrapRef<typeof import('./src/composables/useAliyunCaptcha.js')['default']>
|
||||||
readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>
|
readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>
|
||||||
readonly useAppStore: UnwrapRef<typeof import('./src/stores/app.js')['useAppStore']>
|
readonly useAppStore: UnwrapRef<typeof import('./src/stores/app.js')['useAppStore']>
|
||||||
readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']>
|
readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']>
|
||||||
@@ -808,6 +817,7 @@ declare module 'vue' {
|
|||||||
readonly useThrottleFn: UnwrapRef<typeof import('@vueuse/core')['useThrottleFn']>
|
readonly useThrottleFn: UnwrapRef<typeof import('@vueuse/core')['useThrottleFn']>
|
||||||
readonly useThrottledRefHistory: UnwrapRef<typeof import('@vueuse/core')['useThrottledRefHistory']>
|
readonly useThrottledRefHistory: UnwrapRef<typeof import('@vueuse/core')['useThrottledRefHistory']>
|
||||||
readonly useTimeAgo: UnwrapRef<typeof import('@vueuse/core')['useTimeAgo']>
|
readonly useTimeAgo: UnwrapRef<typeof import('@vueuse/core')['useTimeAgo']>
|
||||||
|
readonly useTimeAgoIntl: UnwrapRef<typeof import('@vueuse/core')['useTimeAgoIntl']>
|
||||||
readonly useTimeout: UnwrapRef<typeof import('@vueuse/core')['useTimeout']>
|
readonly useTimeout: UnwrapRef<typeof import('@vueuse/core')['useTimeout']>
|
||||||
readonly useTimeoutFn: UnwrapRef<typeof import('@vueuse/core')['useTimeoutFn']>
|
readonly useTimeoutFn: UnwrapRef<typeof import('@vueuse/core')['useTimeoutFn']>
|
||||||
readonly useTimeoutPoll: UnwrapRef<typeof import('@vueuse/core')['useTimeoutPoll']>
|
readonly useTimeoutPoll: UnwrapRef<typeof import('@vueuse/core')['useTimeoutPoll']>
|
||||||
|
|||||||
99
components.d.ts
vendored
99
components.d.ts
vendored
@@ -0,0 +1,99 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
// @ts-nocheck
|
||||||
|
// Generated by unplugin-vue-components
|
||||||
|
// Read more: https://github.com/vuejs/core/pull/3399
|
||||||
|
// biome-ignore lint: disable
|
||||||
|
export {}
|
||||||
|
|
||||||
|
/* prettier-ignore */
|
||||||
|
declare module 'vue' {
|
||||||
|
export interface GlobalComponents {
|
||||||
|
AppBreadcrumb: typeof import('./src/components/layout/AppBreadcrumb.vue')['default']
|
||||||
|
AppHeader: typeof import('./src/components/layout/AppHeader.vue')['default']
|
||||||
|
AppLoading: typeof import('./src/components/common/AppLoading.vue')['default']
|
||||||
|
AppSidebar: typeof import('./src/components/layout/AppSidebar.vue')['default']
|
||||||
|
BusinessConsultationDialog: typeof import('./src/components/common/BusinessConsultationDialog.vue')['default']
|
||||||
|
CertificationBanner: typeof import('./src/components/common/CertificationBanner.vue')['default']
|
||||||
|
CertificationNotice: typeof import('./src/components/common/CertificationNotice.vue')['default']
|
||||||
|
ChartCard: typeof import('./src/components/statistics/ChartCard.vue')['default']
|
||||||
|
CodeDisplay: typeof import('./src/components/common/CodeDisplay.vue')['default']
|
||||||
|
CustomSteps: typeof import('./src/components/common/CustomSteps.vue')['default']
|
||||||
|
DanmakuBar: typeof import('./src/components/common/DanmakuBar.vue')['default']
|
||||||
|
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||||
|
ElAside: typeof import('element-plus/es')['ElAside']
|
||||||
|
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||||
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
|
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
|
||||||
|
ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
|
ElCascader: typeof import('element-plus/es')['ElCascader']
|
||||||
|
ElCol: typeof import('element-plus/es')['ElCol']
|
||||||
|
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||||
|
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||||
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
|
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||||
|
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||||
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
|
ElDivider: typeof import('element-plus/es')['ElDivider']
|
||||||
|
ElDrawer: typeof import('element-plus/es')['ElDrawer']
|
||||||
|
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||||
|
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||||
|
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||||
|
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||||
|
ElForm: typeof import('element-plus/es')['ElForm']
|
||||||
|
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||||
|
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||||
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
|
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||||
|
ElLoading: typeof import('element-plus/es')['ElLoading']
|
||||||
|
ElMain: typeof import('element-plus/es')['ElMain']
|
||||||
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
|
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||||
|
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||||
|
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||||
|
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||||
|
ElRow: typeof import('element-plus/es')['ElRow']
|
||||||
|
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
||||||
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
|
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||||
|
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
|
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||||
|
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||||
|
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||||
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
|
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
|
||||||
|
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
|
ElTree: typeof import('element-plus/es')['ElTree']
|
||||||
|
ElUpload: typeof import('element-plus/es')['ElUpload']
|
||||||
|
ExportDialog: typeof import('./src/components/common/ExportDialog.vue')['default']
|
||||||
|
FileUpload: typeof import('./src/components/common/FileUpload.vue')['default']
|
||||||
|
FilterItem: typeof import('./src/components/common/FilterItem.vue')['default']
|
||||||
|
FilterSection: typeof import('./src/components/common/FilterSection.vue')['default']
|
||||||
|
FloatingCustomerService: typeof import('./src/components/common/FloatingCustomerService.vue')['default']
|
||||||
|
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
|
||||||
|
ListPageLayout: typeof import('./src/components/common/ListPageLayout.vue')['default']
|
||||||
|
MarkdownEditor: typeof import('./src/components/common/MarkdownEditor.vue')['default']
|
||||||
|
NotificationPanel: typeof import('./src/components/layout/NotificationPanel.vue')['default']
|
||||||
|
PermissionGuard: typeof import('./src/components/auth/PermissionGuard.vue')['default']
|
||||||
|
ProductApiConfigDialog: typeof import('./src/components/admin/ProductApiConfigDialog.vue')['default']
|
||||||
|
ProductCard: typeof import('./src/components/product/ProductCard.vue')['default']
|
||||||
|
ProductDocumentationDialog: typeof import('./src/components/admin/ProductDocumentationDialog.vue')['default']
|
||||||
|
ProductFormDialog: typeof import('./src/components/admin/ProductFormDialog.vue')['default']
|
||||||
|
ResponsiveActionColumn: typeof import('./src/components/common/ResponsiveActionColumn.vue')['default']
|
||||||
|
RichTextEditor: typeof import('./src/components/common/RichTextEditor.vue')['default']
|
||||||
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
StatCard: typeof import('./src/components/statistics/StatCard.vue')['default']
|
||||||
|
StatisticsDashboard: typeof import('./src/components/statistics/StatisticsDashboard.vue')['default']
|
||||||
|
TheWelcome: typeof import('./src/components/TheWelcome.vue')['default']
|
||||||
|
VersionInfo: typeof import('./src/components/common/VersionInfo.vue')['default']
|
||||||
|
WelcomeItem: typeof import('./src/components/WelcomeItem.vue')['default']
|
||||||
|
}
|
||||||
|
export interface GlobalDirectives {
|
||||||
|
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
17
index.html
17
index.html
@@ -1,13 +1,22 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="">
|
<html lang="">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>天远数据</title>
|
<title>天远数据</title>
|
||||||
|
<!-- 阿里云滑块验证码 -->
|
||||||
|
<script>
|
||||||
|
window.AliyunCaptchaConfig = { region: "cn", prefix: "12zxnj" };
|
||||||
|
</script>
|
||||||
|
<script
|
||||||
|
type="text/javascript"
|
||||||
|
src="https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js"
|
||||||
|
></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
<div id="captcha-element"></div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
266
public/examples/javascript/sms_signature_demo.js
Normal file
266
public/examples/javascript/sms_signature_demo.js
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
/**
|
||||||
|
* 短信发送接口签名示例(浏览器版本)
|
||||||
|
*
|
||||||
|
* 本示例演示如何在浏览器中为短信发送接口生成HMAC-SHA256签名
|
||||||
|
*
|
||||||
|
* 安全提示:
|
||||||
|
* 1. 密钥应该通过代码混淆、字符串拆分等方式隐藏
|
||||||
|
* 2. 不要在前端代码中直接暴露完整密钥
|
||||||
|
* 3. 建议使用构建工具进行代码混淆和压缩
|
||||||
|
* 4. 可以考虑将签名逻辑放在后端代理接口中
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取签名密钥(通过多种方式混淆,增加破解难度)
|
||||||
|
* 注意:这只是示例,实际使用时应该进一步混淆
|
||||||
|
*/
|
||||||
|
function getSecretKey() {
|
||||||
|
// 方式1: 字符串拆分和拼接
|
||||||
|
const part1 = 'TyApi2024';
|
||||||
|
const part2 = 'SMSSecret';
|
||||||
|
const part3 = 'Key!@#$%^';
|
||||||
|
const part4 = '&*()_+QWERTY';
|
||||||
|
const part5 = 'UIOP';
|
||||||
|
|
||||||
|
// 方式2: 使用数组和join(增加混淆)
|
||||||
|
const arr = [part1, part2, part3, part4, part5];
|
||||||
|
return arr.join('');
|
||||||
|
|
||||||
|
// 方式3: 字符数组拼接(更复杂的方式)
|
||||||
|
// const chars = ['T', 'y', 'A', 'p', 'i', '2', '0', '2', '4', ...];
|
||||||
|
// return chars.join('');
|
||||||
|
|
||||||
|
// 方式4: 使用atob解码(如果密钥经过base64编码)
|
||||||
|
// const encoded = 'base64_encoded_string';
|
||||||
|
// return atob(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成随机字符串(用于nonce)
|
||||||
|
*/
|
||||||
|
function generateNonce(length = 16) {
|
||||||
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
let result = '';
|
||||||
|
const array = new Uint8Array(length);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += chars[array[i] % chars.length];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用Web Crypto API生成HMAC-SHA256签名
|
||||||
|
*
|
||||||
|
* @param {Object} params - 请求参数对象
|
||||||
|
* @param {string} secretKey - 签名密钥
|
||||||
|
* @param {number} timestamp - 时间戳(秒)
|
||||||
|
* @param {string} nonce - 随机字符串
|
||||||
|
* @returns {Promise<string>} 签名字符串(hex编码)
|
||||||
|
*/
|
||||||
|
async function generateSignature(params, secretKey, timestamp, nonce) {
|
||||||
|
// 1. 构建待签名字符串:按key排序,拼接成 key1=value1&key2=value2 格式
|
||||||
|
const keys = Object.keys(params)
|
||||||
|
.filter(k => k !== 'signature') // 排除签名字段
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
const parts = keys.map(k => `${k}=${params[k]}`);
|
||||||
|
|
||||||
|
// 2. 添加时间戳和随机数
|
||||||
|
parts.push(`timestamp=${timestamp}`);
|
||||||
|
parts.push(`nonce=${nonce}`);
|
||||||
|
|
||||||
|
// 3. 拼接成待签名字符串
|
||||||
|
const signString = parts.join('&');
|
||||||
|
|
||||||
|
// 4. 使用Web Crypto API计算HMAC-SHA256签名
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const keyData = encoder.encode(secretKey);
|
||||||
|
const messageData = encoder.encode(signString);
|
||||||
|
|
||||||
|
// 导入密钥
|
||||||
|
const cryptoKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
keyData,
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
|
// 计算签名
|
||||||
|
const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData);
|
||||||
|
|
||||||
|
// 转换为hex字符串
|
||||||
|
const hashArray = Array.from(new Uint8Array(signature));
|
||||||
|
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
|
||||||
|
return hashHex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义编码字符集(与后端保持一致)
|
||||||
|
*/
|
||||||
|
const CUSTOM_ENCODE_CHARSET = "0123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz!@#$%^&*()_+-=[]{}|;:,.<>?";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义Base64编码(使用自定义字符集)
|
||||||
|
*/
|
||||||
|
function customBase64Encode(data) {
|
||||||
|
if (data.length === 0) return '';
|
||||||
|
|
||||||
|
// 将字符串转换为UTF-8字节数组
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const bytes = encoder.encode(data);
|
||||||
|
const charset = CUSTOM_ENCODE_CHARSET;
|
||||||
|
let result = '';
|
||||||
|
|
||||||
|
// 将3个字节(24位)编码为4个字符
|
||||||
|
for (let i = 0; i < bytes.length; i += 3) {
|
||||||
|
const b1 = bytes[i];
|
||||||
|
const b2 = i + 1 < bytes.length ? bytes[i + 1] : 0;
|
||||||
|
const b3 = i + 2 < bytes.length ? bytes[i + 2] : 0;
|
||||||
|
|
||||||
|
// 组合成24位
|
||||||
|
const combined = (b1 << 16) | (b2 << 8) | b3;
|
||||||
|
|
||||||
|
// 分成4个6位段
|
||||||
|
result += charset[(combined >> 18) & 0x3F];
|
||||||
|
result += charset[(combined >> 12) & 0x3F];
|
||||||
|
|
||||||
|
if (i + 1 < bytes.length) {
|
||||||
|
result += charset[(combined >> 6) & 0x3F];
|
||||||
|
} else {
|
||||||
|
result += '='; // 填充字符
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i + 2 < bytes.length) {
|
||||||
|
result += charset[combined & 0x3F];
|
||||||
|
} else {
|
||||||
|
result += '='; // 填充字符
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用字符偏移混淆
|
||||||
|
*/
|
||||||
|
function applyCharShift(data, shift) {
|
||||||
|
const charset = CUSTOM_ENCODE_CHARSET;
|
||||||
|
const charsetLen = charset.length;
|
||||||
|
let result = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const c = data[i];
|
||||||
|
if (c === '=') {
|
||||||
|
result += c; // 填充字符不变
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = charset.indexOf(c);
|
||||||
|
if (idx === -1) {
|
||||||
|
result += c; // 不在字符集中,保持不变
|
||||||
|
} else {
|
||||||
|
// 应用偏移
|
||||||
|
const newIdx = (idx + shift) % charsetLen;
|
||||||
|
result += charset[newIdx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义编码请求数据
|
||||||
|
*/
|
||||||
|
function encodeRequest(data) {
|
||||||
|
// 1. 使用自定义Base64编码
|
||||||
|
const encoded = customBase64Encode(data);
|
||||||
|
|
||||||
|
// 2. 应用字符偏移混淆(偏移7个位置)
|
||||||
|
const confused = applyCharShift(encoded, 7);
|
||||||
|
|
||||||
|
return confused;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送短信验证码(带签名)- 方式2:编码后传输(推荐,更安全)
|
||||||
|
* 将所有参数(包括签名)使用自定义编码方案编码后传输,隐藏参数结构
|
||||||
|
*
|
||||||
|
* @param {string} phone - 手机号
|
||||||
|
* @param {string} scene - 场景(register/login/change_password/reset_password等)
|
||||||
|
* @param {string} apiBaseUrl - API基础URL
|
||||||
|
* @returns {Promise<Object>} 响应结果
|
||||||
|
*/
|
||||||
|
async function sendSMSWithEncodedSignature(phone, scene, apiBaseUrl = 'http://localhost:8080') {
|
||||||
|
// 1. 准备参数
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000); // 当前时间戳(秒)
|
||||||
|
const nonce = generateNonce(16); // 生成随机字符串
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
phone: phone,
|
||||||
|
scene: scene,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 生成签名
|
||||||
|
const secretKey = getSecretKey();
|
||||||
|
const signature = await generateSignature(params, secretKey, timestamp, nonce);
|
||||||
|
|
||||||
|
// 3. 构建包含所有参数的JSON对象
|
||||||
|
const allParams = {
|
||||||
|
phone: phone,
|
||||||
|
scene: scene,
|
||||||
|
timestamp: timestamp,
|
||||||
|
nonce: nonce,
|
||||||
|
signature: signature,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. 将JSON对象转换为字符串,然后使用自定义编码方案编码
|
||||||
|
const jsonString = JSON.stringify(allParams);
|
||||||
|
const encodedData = encodeRequest(jsonString);
|
||||||
|
|
||||||
|
// 5. 构建请求体(只包含编码后的data字段)
|
||||||
|
const requestBody = {
|
||||||
|
data: encodedData,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 6. 发送请求
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiBaseUrl}/api/v1/users/send-code`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发送短信失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果在浏览器环境中使用,可以导出到全局
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.SMSSignature = {
|
||||||
|
sendSMSWithEncodedSignature,
|
||||||
|
generateSignature,
|
||||||
|
generateNonce,
|
||||||
|
encodeRequest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果在Node.js环境中使用(需要安装crypto-js等库)
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = {
|
||||||
|
sendSMSWithEncodedSignature,
|
||||||
|
generateSignature,
|
||||||
|
generateNonce,
|
||||||
|
getSecretKey,
|
||||||
|
encodeRequest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
246
public/examples/nodejs/sms_signature_demo.js
Normal file
246
public/examples/nodejs/sms_signature_demo.js
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* 短信发送接口签名示例
|
||||||
|
*
|
||||||
|
* 本示例演示如何为短信发送接口生成HMAC-SHA256签名
|
||||||
|
*
|
||||||
|
* 安全提示:
|
||||||
|
* 1. 密钥应该通过代码混淆、字符串拆分等方式隐藏
|
||||||
|
* 2. 不要在前端代码中直接暴露完整密钥
|
||||||
|
* 3. 建议使用构建工具进行代码混淆
|
||||||
|
*/
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取签名密钥(通过多种方式混淆,增加破解难度)
|
||||||
|
* 注意:这只是示例,实际使用时应该进一步混淆
|
||||||
|
*/
|
||||||
|
function getSecretKey() {
|
||||||
|
// 方式1: 字符串拆分和拼接
|
||||||
|
const part1 = 'TyApi2024';
|
||||||
|
const part2 = 'SMSSecret';
|
||||||
|
const part3 = 'Key!@#$%^';
|
||||||
|
const part4 = '&*()_+QWERTY';
|
||||||
|
const part5 = 'UIOP';
|
||||||
|
|
||||||
|
// 方式2: Base64解码(可选,增加一层混淆)
|
||||||
|
// const encoded = Buffer.from('some_base64_string', 'base64').toString();
|
||||||
|
|
||||||
|
// 方式3: 字符数组拼接
|
||||||
|
const chars = ['T', 'y', 'A', 'p', 'i', '2', '0', '2', '4', 'S', 'M', 'S', 'S', 'e', 'c', 'r', 'e', 't', 'K', 'e', 'y', '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '_', '+', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P'];
|
||||||
|
const fromChars = chars.join('');
|
||||||
|
|
||||||
|
// 组合多种方式(实际密钥:TyApi2024SMSSecretKey!@#$%^&*()_+QWERTYUIOP)
|
||||||
|
return part1 + part2 + part3 + part4 + part5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成随机字符串(用于nonce)
|
||||||
|
*/
|
||||||
|
function generateNonce(length = 16) {
|
||||||
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成HMAC-SHA256签名
|
||||||
|
*
|
||||||
|
* @param {Object} params - 请求参数对象
|
||||||
|
* @param {string} secretKey - 签名密钥
|
||||||
|
* @param {number} timestamp - 时间戳(秒)
|
||||||
|
* @param {string} nonce - 随机字符串
|
||||||
|
* @returns {string} 签名字符串(hex编码)
|
||||||
|
*/
|
||||||
|
function generateSignature(params, secretKey, timestamp, nonce) {
|
||||||
|
// 1. 构建待签名字符串:按key排序,拼接成 key1=value1&key2=value2 格式
|
||||||
|
const keys = Object.keys(params)
|
||||||
|
.filter(k => k !== 'signature') // 排除签名字段
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
const parts = keys.map(k => `${k}=${params[k]}`);
|
||||||
|
|
||||||
|
// 2. 添加时间戳和随机数
|
||||||
|
parts.push(`timestamp=${timestamp}`);
|
||||||
|
parts.push(`nonce=${nonce}`);
|
||||||
|
|
||||||
|
// 3. 拼接成待签名字符串
|
||||||
|
const signString = parts.join('&');
|
||||||
|
|
||||||
|
// 4. 使用HMAC-SHA256计算签名
|
||||||
|
const signature = crypto
|
||||||
|
.createHmac('sha256', secretKey)
|
||||||
|
.update(signString)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
return signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义编码字符集(与后端保持一致)
|
||||||
|
*/
|
||||||
|
const CUSTOM_ENCODE_CHARSET = "0123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz!@#$%^&*()_+-=[]{}|;:,.<>?";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义Base64编码(使用自定义字符集)
|
||||||
|
*/
|
||||||
|
function customBase64Encode(data) {
|
||||||
|
if (data.length === 0) return '';
|
||||||
|
|
||||||
|
const bytes = Buffer.from(data, 'utf8');
|
||||||
|
const charset = CUSTOM_ENCODE_CHARSET;
|
||||||
|
let result = '';
|
||||||
|
|
||||||
|
// 将3个字节(24位)编码为4个字符
|
||||||
|
for (let i = 0; i < bytes.length; i += 3) {
|
||||||
|
const b1 = bytes[i];
|
||||||
|
const b2 = i + 1 < bytes.length ? bytes[i + 1] : 0;
|
||||||
|
const b3 = i + 2 < bytes.length ? bytes[i + 2] : 0;
|
||||||
|
|
||||||
|
// 组合成24位
|
||||||
|
const combined = (b1 << 16) | (b2 << 8) | b3;
|
||||||
|
|
||||||
|
// 分成4个6位段
|
||||||
|
result += charset[(combined >> 18) & 0x3F];
|
||||||
|
result += charset[(combined >> 12) & 0x3F];
|
||||||
|
|
||||||
|
if (i + 1 < bytes.length) {
|
||||||
|
result += charset[(combined >> 6) & 0x3F];
|
||||||
|
} else {
|
||||||
|
result += '='; // 填充字符
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i + 2 < bytes.length) {
|
||||||
|
result += charset[combined & 0x3F];
|
||||||
|
} else {
|
||||||
|
result += '='; // 填充字符
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用字符偏移混淆
|
||||||
|
*/
|
||||||
|
function applyCharShift(data, shift) {
|
||||||
|
const charset = CUSTOM_ENCODE_CHARSET;
|
||||||
|
const charsetLen = charset.length;
|
||||||
|
let result = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const c = data[i];
|
||||||
|
if (c === '=') {
|
||||||
|
result += c; // 填充字符不变
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = charset.indexOf(c);
|
||||||
|
if (idx === -1) {
|
||||||
|
result += c; // 不在字符集中,保持不变
|
||||||
|
} else {
|
||||||
|
// 应用偏移
|
||||||
|
const newIdx = (idx + shift) % charsetLen;
|
||||||
|
result += charset[newIdx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义编码请求数据
|
||||||
|
*/
|
||||||
|
function encodeRequest(data) {
|
||||||
|
// 1. 使用自定义Base64编码
|
||||||
|
const encoded = customBase64Encode(data);
|
||||||
|
|
||||||
|
// 2. 应用字符偏移混淆(偏移7个位置)
|
||||||
|
const confused = applyCharShift(encoded, 7);
|
||||||
|
|
||||||
|
return confused;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送短信验证码(带签名)- 方式2:编码后传输(推荐,更安全)
|
||||||
|
* 将所有参数(包括签名)使用自定义编码方案编码后传输,隐藏参数结构
|
||||||
|
*
|
||||||
|
* @param {string} phone - 手机号
|
||||||
|
* @param {string} scene - 场景(register/login/change_password/reset_password等)
|
||||||
|
* @param {string} apiBaseUrl - API基础URL
|
||||||
|
* @returns {Promise<Object>} 响应结果
|
||||||
|
*/
|
||||||
|
async function sendSMSWithEncodedSignature(phone, scene, apiBaseUrl = 'http://localhost:8080') {
|
||||||
|
// 1. 准备参数
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000); // 当前时间戳(秒)
|
||||||
|
const nonce = generateNonce(16); // 生成随机字符串
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
phone: phone,
|
||||||
|
scene: scene,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 生成签名
|
||||||
|
const secretKey = getSecretKey();
|
||||||
|
const signature = generateSignature(params, secretKey, timestamp, nonce);
|
||||||
|
|
||||||
|
// 3. 构建包含所有参数的JSON对象
|
||||||
|
const allParams = {
|
||||||
|
phone: phone,
|
||||||
|
scene: scene,
|
||||||
|
timestamp: timestamp,
|
||||||
|
nonce: nonce,
|
||||||
|
signature: signature,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. 将JSON对象转换为字符串,然后使用自定义编码方案编码
|
||||||
|
const jsonString = JSON.stringify(allParams);
|
||||||
|
const encodedData = encodeRequest(jsonString);
|
||||||
|
|
||||||
|
// 5. 构建请求体(只包含编码后的data字段)
|
||||||
|
const requestBody = {
|
||||||
|
data: encodedData,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 6. 发送请求
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiBaseUrl}/api/v1/users/send-code`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发送短信失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用示例
|
||||||
|
if (require.main === module) {
|
||||||
|
console.log('=== 发送短信验证码(使用自定义编码) ===');
|
||||||
|
// 示例:发送注册验证码(使用自定义编码方案,只传递data字段)
|
||||||
|
sendSMSWithEncodedSignature('13800138000', 'register', 'http://localhost:8080')
|
||||||
|
.then(result => {
|
||||||
|
console.log('发送成功:', result);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('发送失败:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sendSMSWithEncodedSignature,
|
||||||
|
generateSignature,
|
||||||
|
generateNonce,
|
||||||
|
getSecretKey,
|
||||||
|
encodeRequest,
|
||||||
|
};
|
||||||
|
|
||||||
@@ -40,6 +40,14 @@ export const userApi = {
|
|||||||
getUserStats: () => request.get('/users/admin/stats')
|
getUserStats: () => request.get('/users/admin/stats')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证码(阿里云滑块)相关接口
|
||||||
|
export const captchaApi = {
|
||||||
|
// 获取加密场景 ID,用于前端加密模式初始化滑块
|
||||||
|
getEncryptedSceneId: (params) => request.post('/captcha/encryptedSceneId', params || {}),
|
||||||
|
// 获取验证码配置(是否启用、场景 ID)
|
||||||
|
getConfig: () => request.get('/captcha/config')
|
||||||
|
}
|
||||||
|
|
||||||
// 产品相关接口
|
// 产品相关接口
|
||||||
export const productApi = {
|
export const productApi = {
|
||||||
// 产品列表(用户端接口)
|
// 产品列表(用户端接口)
|
||||||
@@ -63,7 +71,19 @@ export const productApi = {
|
|||||||
// 下载接口文档(支持PDF和Markdown)
|
// 下载接口文档(支持PDF和Markdown)
|
||||||
downloadProductDocumentation: (productId) => request.get(`/products/${productId}/documentation/download`, {
|
downloadProductDocumentation: (productId) => request.get(`/products/${productId}/documentation/download`, {
|
||||||
responseType: 'blob'
|
responseType: 'blob'
|
||||||
})
|
}),
|
||||||
|
|
||||||
|
// 组件报告下载相关API
|
||||||
|
// 检查产品是否可以下载示例报告
|
||||||
|
checkComponentReportAvailability: (productId) => request.get(`/products/${productId}/component-report/check`),
|
||||||
|
// 获取产品示例报告下载信息和价格计算
|
||||||
|
getComponentReportInfo: (productId) => request.get(`/products/${productId}/component-report/info`),
|
||||||
|
// 创建示例报告购买订单
|
||||||
|
createComponentReportPaymentOrder: (productId, data) => request.post(`/products/${productId}/component-report/create-order`, data),
|
||||||
|
// 检查示例报告购买订单支付状态
|
||||||
|
checkComponentReportPaymentStatus: (orderId) => request.get(`/component-report/check-payment/${orderId}`),
|
||||||
|
// 生成并下载示例报告ZIP文件
|
||||||
|
generateAndDownloadComponentReport: (data) => request.post('/component-report/generate-and-download', data, { responseType: 'blob' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分类相关接口 - 数据大厅
|
// 分类相关接口 - 数据大厅
|
||||||
@@ -130,7 +150,15 @@ export const financeApi = {
|
|||||||
|
|
||||||
// 充值记录相关接口
|
// 充值记录相关接口
|
||||||
getUserRechargeRecords: (params) => request.get('/finance/wallet/recharge-records', { params }),
|
getUserRechargeRecords: (params) => request.get('/finance/wallet/recharge-records', { params }),
|
||||||
getAdminRechargeRecords: (params) => request.get('/admin/finance/recharge-records', { params })
|
getAdminRechargeRecords: (params) => request.get('/admin/finance/recharge-records', { params }),
|
||||||
|
|
||||||
|
// 购买记录相关接口
|
||||||
|
getUserPurchaseRecords: (params) => request.get('/finance/purchase-records', { params }),
|
||||||
|
getAdminPurchaseRecords: (params) => request.get('/admin/finance/purchase-records', { params }),
|
||||||
|
exportAdminPurchaseRecords: (params) => request.get('/admin/finance/purchase-records/export', {
|
||||||
|
params,
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 认证相关接口
|
// 认证相关接口
|
||||||
@@ -164,7 +192,29 @@ export const certificationApi = {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data'
|
'Content-Type': 'multipart/form-data'
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
|
|
||||||
|
// 上传认证图片到七牛云(企业信息中的营业执照、办公场地、场景附件、授权代表身份证等)
|
||||||
|
uploadFile: (file) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
return request.post('/certifications/upload', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 管理员代用户完成认证(暂不关联合同)
|
||||||
|
adminCompleteWithoutContract: (data) => request.post('/certifications/admin/complete-without-contract', data),
|
||||||
|
|
||||||
|
// 管理端企业审核:列表(按状态机 certification_status 筛选)、详情、通过、拒绝、按用户变更状态
|
||||||
|
adminListSubmitRecords: (params) => request.get('/certifications/admin/submit-records', { params }),
|
||||||
|
adminGetSubmitRecord: (id) => request.get(`/certifications/admin/submit-records/${id}`),
|
||||||
|
adminApproveSubmitRecord: (id, data) => request.post(`/certifications/admin/submit-records/${id}/approve`, data || {}),
|
||||||
|
adminRejectSubmitRecord: (id, data) => request.post(`/certifications/admin/submit-records/${id}/reject`, data),
|
||||||
|
// 管理端按用户变更认证状态(以状态机为准:info_submitted=通过 / info_rejected=拒绝)
|
||||||
|
adminTransitionCertificationStatus: (data) => request.post('/certifications/admin/transition-status', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// API相关接口
|
// API相关接口
|
||||||
@@ -269,6 +319,14 @@ export const productAdminApi = {
|
|||||||
updateCategory: (id, data) => request.put(`/admin/product-categories/${id}`, data),
|
updateCategory: (id, data) => request.put(`/admin/product-categories/${id}`, data),
|
||||||
deleteCategory: (id) => request.delete(`/admin/product-categories/${id}`),
|
deleteCategory: (id) => request.delete(`/admin/product-categories/${id}`),
|
||||||
|
|
||||||
|
// 小类管理
|
||||||
|
getSubCategories: (params) => request.get('/admin/sub-categories', { params }),
|
||||||
|
getSubCategoryDetail: (id) => request.get(`/admin/sub-categories/${id}`),
|
||||||
|
createSubCategory: (data) => request.post('/admin/sub-categories', data),
|
||||||
|
updateSubCategory: (id, data) => request.put(`/admin/sub-categories/${id}`, data),
|
||||||
|
deleteSubCategory: (id) => request.delete(`/admin/sub-categories/${id}`),
|
||||||
|
getSubCategoriesByCategory: (categoryId) => request.get(`/admin/product-categories/${categoryId}/sub-categories`),
|
||||||
|
|
||||||
// 订阅管理
|
// 订阅管理
|
||||||
getSubscriptions: (params) => request.get('/admin/subscriptions', { params }),
|
getSubscriptions: (params) => request.get('/admin/subscriptions', { params }),
|
||||||
getSubscriptionStats: () => request.get('/admin/subscriptions/stats'),
|
getSubscriptionStats: () => request.get('/admin/subscriptions/stats'),
|
||||||
|
|||||||
@@ -659,3 +659,31 @@ export function adminGetTodayCertifiedEnterprises(params = {}) {
|
|||||||
params
|
params
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================ 管理员安全可视化接口 ================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取可疑IP列表
|
||||||
|
* @param {Object} params - 查询参数
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export function adminGetSuspiciousIPList(params = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/admin/security/suspicious-ip/list',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取可疑IP地球请求流
|
||||||
|
* @param {Object} params - 查询参数
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export function adminGetSuspiciousIPGeoStream(params = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/admin/security/suspicious-ip/geo-stream',
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
99
src/api/ui-component.js
Normal file
99
src/api/ui-component.js
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export const uiComponentApi = {
|
||||||
|
// 获取UI组件列表
|
||||||
|
getUIComponentList(params) {
|
||||||
|
return request.get('/admin/ui-components', { params })
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取UI组件详情
|
||||||
|
getUIComponentDetail(id) {
|
||||||
|
return request.get(`/admin/ui-components/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建UI组件
|
||||||
|
createUIComponent(data) {
|
||||||
|
// 确保发送的数据结构与后端期望的完全匹配
|
||||||
|
const requestData = {
|
||||||
|
component_code: data.component_code || '',
|
||||||
|
component_name: data.component_name || '',
|
||||||
|
description: data.description || '',
|
||||||
|
version: data.version || '',
|
||||||
|
is_active: data.is_active !== undefined ? data.is_active : true,
|
||||||
|
sort_order: data.sort_order !== undefined ? data.sort_order : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加调试日志
|
||||||
|
console.log('创建UI组件请求数据:', requestData)
|
||||||
|
|
||||||
|
return request.post('/admin/ui-components', requestData)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新UI组件
|
||||||
|
updateUIComponent(id, data) {
|
||||||
|
// 确保发送的数据结构与后端期望的完全匹配
|
||||||
|
const requestData = {
|
||||||
|
id: id,
|
||||||
|
component_code: data.component_code || '',
|
||||||
|
component_name: data.component_name || '',
|
||||||
|
description: data.description || '',
|
||||||
|
version: data.version || '',
|
||||||
|
is_active: data.is_active !== undefined ? data.is_active : true,
|
||||||
|
sort_order: data.sort_order !== undefined ? data.sort_order : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加调试日志
|
||||||
|
console.log('更新UI组件请求数据:', requestData)
|
||||||
|
|
||||||
|
return request.put(`/admin/ui-components/${id}`, requestData)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除UI组件
|
||||||
|
deleteUIComponent(id) {
|
||||||
|
return request.delete(`/admin/ui-components/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 上传UI组件文件
|
||||||
|
uploadUIComponentFile(id, formData) {
|
||||||
|
return request.post(`/admin/ui-components/${id}/upload`, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 上传并解压UI组件文件
|
||||||
|
uploadAndExtractUIComponentFile(id, formData) {
|
||||||
|
return request.post(`/admin/ui-components/${id}/upload-extract`, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 下载UI组件文件
|
||||||
|
downloadUIComponentFile(id) {
|
||||||
|
return request.get(`/admin/ui-components/${id}/download`, {
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取UI组件文件夹内容
|
||||||
|
getUIComponentFolderContent(id) {
|
||||||
|
return request.get(`/admin/ui-components/${id}/folder-content`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除UI组件文件夹
|
||||||
|
deleteUIComponentFolder(id) {
|
||||||
|
return request.delete(`/admin/ui-components/${id}/folder`)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建UI组件并上传文件(合并操作)
|
||||||
|
createUIComponentWithFile(formData) {
|
||||||
|
return request.post('/admin/ui-components/create-with-file', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,19 +31,16 @@
|
|||||||
placeholder="请输入产品名称"
|
placeholder="请输入产品名称"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="产品分类" prop="category_id">
|
<el-form-item label="产品分类" prop="category_cascader">
|
||||||
<el-select
|
<el-cascader
|
||||||
v-model="form.category_id"
|
v-model="form.category_cascader"
|
||||||
|
:options="categoryOptions"
|
||||||
|
:props="cascaderProps"
|
||||||
placeholder="选择产品分类"
|
placeholder="选择产品分类"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
>
|
clearable
|
||||||
<el-option
|
@change="handleCategoryChange"
|
||||||
v-for="category in categories"
|
/>
|
||||||
:key="category.id"
|
|
||||||
:label="category.name"
|
|
||||||
:value="category.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="产品类型" prop="is_package">
|
<el-form-item label="产品类型" prop="is_package">
|
||||||
@@ -114,6 +111,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- UI组件配置 - 仅组合包显示 -->
|
||||||
|
<div v-if="form.is_package" class="space-y-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">UI组件配置</h3>
|
||||||
|
<el-alert
|
||||||
|
title="UI组件说明"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<p>配置此组合包的UI组件销售选项,用户可以单独购买此组合包的UI组件示例。</p>
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
|
<el-form-item label="是否出售UI组件" prop="sell_ui_component">
|
||||||
|
<el-switch
|
||||||
|
v-model="form.sell_ui_component"
|
||||||
|
:active-value="true"
|
||||||
|
:inactive-value="false"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item
|
||||||
|
v-if="form.sell_ui_component"
|
||||||
|
label="UI组件销售价格"
|
||||||
|
prop="ui_component_price"
|
||||||
|
>
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.ui_component_price"
|
||||||
|
:precision="2"
|
||||||
|
:min="0"
|
||||||
|
:step="0.01"
|
||||||
|
placeholder="请输入UI组件销售价格"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 组合包配置 -->
|
<!-- 组合包配置 -->
|
||||||
@@ -341,6 +377,39 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 子分类数据
|
||||||
|
const subCategories = ref([])
|
||||||
|
|
||||||
|
// 级联选择器配置(checkStrictly: true 允许只选一级分类,不强制选子类)
|
||||||
|
const cascaderProps = {
|
||||||
|
value: 'id',
|
||||||
|
label: 'name',
|
||||||
|
children: 'children',
|
||||||
|
emitPath: false,
|
||||||
|
checkStrictly: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分类选项(包含子分类)
|
||||||
|
const categoryOptions = computed(() => {
|
||||||
|
return props.categories.map(category => {
|
||||||
|
// 查找该分类下的子分类
|
||||||
|
const children = subCategories.value
|
||||||
|
.filter(sub => sub.category_id === category.id)
|
||||||
|
.map(sub => ({
|
||||||
|
id: sub.id,
|
||||||
|
name: sub.name,
|
||||||
|
code: sub.code
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: category.id,
|
||||||
|
name: category.name,
|
||||||
|
code: category.code,
|
||||||
|
children: children.length > 0 ? children : undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'success'])
|
const emit = defineEmits(['update:modelValue', 'success'])
|
||||||
|
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
@@ -369,12 +438,17 @@ const form = reactive({
|
|||||||
description: '',
|
description: '',
|
||||||
content: '',
|
content: '',
|
||||||
category_id: '',
|
category_id: '',
|
||||||
|
sub_category_id: '',
|
||||||
|
category_cascader: [], // 用于级联选择器
|
||||||
price: 0,
|
price: 0,
|
||||||
cost_price: 0,
|
cost_price: 0,
|
||||||
remark: '',
|
remark: '',
|
||||||
is_enabled: true,
|
is_enabled: true,
|
||||||
is_visible: true,
|
is_visible: true,
|
||||||
is_package: false,
|
is_package: false,
|
||||||
|
// UI组件相关字段
|
||||||
|
sell_ui_component: false,
|
||||||
|
ui_component_price: 0,
|
||||||
seo_title: '',
|
seo_title: '',
|
||||||
seo_description: '',
|
seo_description: '',
|
||||||
seo_keywords: ''
|
seo_keywords: ''
|
||||||
@@ -389,7 +463,7 @@ const rules = {
|
|||||||
{ required: true, message: '请输入产品名称', trigger: 'blur' },
|
{ required: true, message: '请输入产品名称', trigger: 'blur' },
|
||||||
{ min: 2, max: 50, message: '产品名称长度在 2 到 50 个字符', trigger: 'blur' }
|
{ min: 2, max: 50, message: '产品名称长度在 2 到 50 个字符', trigger: 'blur' }
|
||||||
],
|
],
|
||||||
category_id: [
|
category_cascader: [
|
||||||
{ required: true, message: '请选择产品分类', trigger: 'change' }
|
{ required: true, message: '请选择产品分类', trigger: 'change' }
|
||||||
],
|
],
|
||||||
price: [
|
price: [
|
||||||
@@ -399,6 +473,22 @@ const rules = {
|
|||||||
cost_price: [
|
cost_price: [
|
||||||
{ type: 'number', min: 0, message: '成本价不能小于0', trigger: 'blur' }
|
{ type: 'number', min: 0, message: '成本价不能小于0', trigger: 'blur' }
|
||||||
],
|
],
|
||||||
|
ui_component_price: [
|
||||||
|
{
|
||||||
|
type: 'number',
|
||||||
|
min: 0,
|
||||||
|
message: 'UI组件价格不能小于0',
|
||||||
|
trigger: 'blur',
|
||||||
|
validator: (rule, value, callback) => {
|
||||||
|
// 如果是组合包且出售UI组件,则价格必须大于0
|
||||||
|
if (form.is_package && form.sell_ui_component && (!value || value < 0)) {
|
||||||
|
callback(new Error('出售UI组件时,价格不能小于0'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
remark: [
|
remark: [
|
||||||
{ max: 1000, message: '备注长度不能超过1000个字符', trigger: 'blur' }
|
{ max: 1000, message: '备注长度不能超过1000个字符', trigger: 'blur' }
|
||||||
],
|
],
|
||||||
@@ -434,6 +524,9 @@ const initForm = async () => {
|
|||||||
// 重置所有状态
|
// 重置所有状态
|
||||||
resetFormState()
|
resetFormState()
|
||||||
|
|
||||||
|
// 加载子分类
|
||||||
|
await loadSubCategories()
|
||||||
|
|
||||||
if (props.product) {
|
if (props.product) {
|
||||||
// 编辑模式
|
// 编辑模式
|
||||||
await handleEditMode()
|
await handleEditMode()
|
||||||
@@ -468,6 +561,24 @@ const handleEditMode = async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 确保UI组件相关字段正确加载
|
||||||
|
// 如果后端返回的字段名不同,这里可以添加映射
|
||||||
|
if (props.product.sell_ui_component !== undefined) {
|
||||||
|
form.sell_ui_component = props.product.sell_ui_component
|
||||||
|
}
|
||||||
|
if (props.product.ui_component_price !== undefined) {
|
||||||
|
form.ui_component_price = props.product.ui_component_price
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置级联选择器的值
|
||||||
|
if (props.product.sub_category_id) {
|
||||||
|
// 有二级分类,级联选择器设置为二级分类的ID
|
||||||
|
form.category_cascader = props.product.sub_category_id
|
||||||
|
} else if (props.product.category_id) {
|
||||||
|
// 只有一级分类,级联选择器设置为一级分类的ID
|
||||||
|
form.category_cascader = props.product.category_id
|
||||||
|
}
|
||||||
|
|
||||||
// 如果是组合包,处理子产品数据
|
// 如果是组合包,处理子产品数据
|
||||||
if (props.product.is_package) {
|
if (props.product.is_package) {
|
||||||
await handlePackageData()
|
await handlePackageData()
|
||||||
@@ -502,8 +613,10 @@ const handleCreateMode = () => {
|
|||||||
// 保持默认值 true,但允许用户在界面上切换
|
// 保持默认值 true,但允许用户在界面上切换
|
||||||
// 注意:这里不强制设置,让用户可以在界面上自由切换
|
// 注意:这里不强制设置,让用户可以在界面上自由切换
|
||||||
// 默认值已经在 form 初始化时设置为 true(第375-376行)
|
// 默认值已经在 form 初始化时设置为 true(第375-376行)
|
||||||
} else if (key === 'price' || key === 'cost_price') {
|
} else if (key === 'price' || key === 'cost_price' || key === 'ui_component_price') {
|
||||||
form[key] = 0
|
form[key] = 0
|
||||||
|
} else if (key === 'sell_ui_component') {
|
||||||
|
form[key] = false
|
||||||
} else {
|
} else {
|
||||||
form[key] = ''
|
form[key] = ''
|
||||||
}
|
}
|
||||||
@@ -607,6 +720,38 @@ const loadAvailableProducts = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载子分类
|
||||||
|
const loadSubCategories = async () => {
|
||||||
|
try {
|
||||||
|
const response = await productAdminApi.getSubCategories({ page: 1, page_size: 100 })
|
||||||
|
subCategories.value = response.data?.items || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载子分类失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理分类选择变化
|
||||||
|
const handleCategoryChange = (value) => {
|
||||||
|
// value可能是单个值(选择叶子节点)或数组(选择非叶子节点)
|
||||||
|
// 查找选择的分类是否是二级分类
|
||||||
|
const isSubCategory = subCategories.value.some(sub => sub.id === value)
|
||||||
|
|
||||||
|
if (isSubCategory) {
|
||||||
|
// 选择的是二级分类,需要同时设置category_id和sub_category_id
|
||||||
|
const subCategory = subCategories.value.find(sub => sub.id === value)
|
||||||
|
form.category_id = subCategory.category_id
|
||||||
|
form.sub_category_id = value
|
||||||
|
} else if (value) {
|
||||||
|
// 选择的是一级分类,只设置category_id
|
||||||
|
form.category_id = value
|
||||||
|
form.sub_category_id = undefined
|
||||||
|
} else {
|
||||||
|
// 清空选择
|
||||||
|
form.category_id = ''
|
||||||
|
form.sub_category_id = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 处理组合包搜索
|
// 处理组合包搜索
|
||||||
const handlePackageSearch = () => {
|
const handlePackageSearch = () => {
|
||||||
if (packageSearchTimer) {
|
if (packageSearchTimer) {
|
||||||
@@ -757,10 +902,18 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
const submitData = { ...form }
|
const submitData = { ...form }
|
||||||
|
|
||||||
|
// 移除级联选择器字段
|
||||||
|
delete submitData.category_cascader
|
||||||
|
|
||||||
// 确保布尔值正确传递
|
// 确保布尔值正确传递
|
||||||
submitData.is_enabled = Boolean(form.is_enabled)
|
submitData.is_enabled = Boolean(form.is_enabled)
|
||||||
submitData.is_visible = Boolean(form.is_visible)
|
submitData.is_visible = Boolean(form.is_visible)
|
||||||
|
|
||||||
|
// 如果sub_category_id为undefined,移除该字段(不传递)
|
||||||
|
if (submitData.sub_category_id === undefined) {
|
||||||
|
delete submitData.sub_category_id
|
||||||
|
}
|
||||||
|
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
// 编辑模式
|
// 编辑模式
|
||||||
await productAdminApi.updateProduct(props.product.id, submitData)
|
await productAdminApi.updateProduct(props.product.id, submitData)
|
||||||
|
|||||||
176
src/composables/useAliyunCaptcha.js
Normal file
176
src/composables/useAliyunCaptcha.js
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { captchaApi } from '@/api'
|
||||||
|
|
||||||
|
// 阿里云验证码场景 ID(需与后端 config.sms.scene_id 一致;加密模式可由后端 config 下发)
|
||||||
|
const ALIYUN_CAPTCHA_SCENE_ID = import.meta.env.VITE_CAPTCHA_SCENE_ID || "wynt39to"
|
||||||
|
// 是否启用加密模式:通过环境变量 VITE_CAPTCHA_ENCRYPTED_MODE 控制,为 'true' 不加密
|
||||||
|
const ENABLE_ENCRYPTED = import.meta.env.VITE_CAPTCHA_ENCRYPTED_MODE === true
|
||||||
|
|
||||||
|
let captchaInitialised = false
|
||||||
|
let captchaReadyPromise = null
|
||||||
|
let captchaReadyResolve = null
|
||||||
|
|
||||||
|
async function ensureCaptchaInit() {
|
||||||
|
console.log("ENABLE_ENCRYPTED", ENABLE_ENCRYPTED)
|
||||||
|
if (captchaInitialised || typeof window === "undefined") return
|
||||||
|
if (typeof window.initAliyunCaptcha !== "function") return
|
||||||
|
|
||||||
|
captchaInitialised = true
|
||||||
|
window.captcha = null
|
||||||
|
window.__lastBizResponse = null
|
||||||
|
window.__onCaptchaBizSuccess = null
|
||||||
|
captchaReadyPromise = new Promise((resolve) => {
|
||||||
|
captchaReadyResolve = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
// 非加密模式:仅传 SceneId,不调用后端接口
|
||||||
|
if (!ENABLE_ENCRYPTED) {
|
||||||
|
console.log("NON-ENCRYPTED")
|
||||||
|
window.initAliyunCaptcha({
|
||||||
|
SceneId: ALIYUN_CAPTCHA_SCENE_ID,
|
||||||
|
mode: "popup",
|
||||||
|
element: "#captcha-element",
|
||||||
|
getInstance(instance) {
|
||||||
|
window.captcha = instance
|
||||||
|
if (typeof captchaReadyResolve === "function") {
|
||||||
|
captchaReadyResolve()
|
||||||
|
captchaReadyResolve = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
captchaVerifyCallback(param) {
|
||||||
|
console.log("captchaVerifyCallback", param)
|
||||||
|
return typeof window.__captchaVerifyCallback === "function"
|
||||||
|
? window.__captchaVerifyCallback(param)
|
||||||
|
: Promise.resolve({
|
||||||
|
captchaResult: false,
|
||||||
|
bizResult: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onBizResultCallback(bizResult) {
|
||||||
|
if (typeof window.__onBizResultCallback === "function") {
|
||||||
|
window.__onBizResultCallback(bizResult)
|
||||||
|
}
|
||||||
|
window.__lastBizResponse = null
|
||||||
|
window.__onCaptchaBizSuccess = null
|
||||||
|
},
|
||||||
|
slideStyle: { width: 360, height: 40 },
|
||||||
|
language: "cn",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加密模式:先从后端获取 EncryptedSceneId,再初始化
|
||||||
|
try {
|
||||||
|
const resp = await captchaApi.getEncryptedSceneId()
|
||||||
|
const encryptedSceneId = resp?.data?.data?.encryptedSceneId ?? resp?.data?.encryptedSceneId
|
||||||
|
if (!encryptedSceneId) {
|
||||||
|
ElMessage.error("获取验证码参数失败,请稍后重试")
|
||||||
|
captchaInitialised = false
|
||||||
|
captchaReadyPromise = null
|
||||||
|
captchaReadyResolve = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.initAliyunCaptcha({
|
||||||
|
SceneId: ALIYUN_CAPTCHA_SCENE_ID,
|
||||||
|
EncryptedSceneId: encryptedSceneId,
|
||||||
|
mode: "popup",
|
||||||
|
element: "#captcha-element",
|
||||||
|
getInstance(instance) {
|
||||||
|
window.captcha = instance
|
||||||
|
if (typeof captchaReadyResolve === "function") {
|
||||||
|
captchaReadyResolve()
|
||||||
|
captchaReadyResolve = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
captchaVerifyCallback(param) {
|
||||||
|
return typeof window.__captchaVerifyCallback === "function"
|
||||||
|
? window.__captchaVerifyCallback(param)
|
||||||
|
: Promise.resolve({ captchaResult: false, bizResult: false })
|
||||||
|
},
|
||||||
|
onBizResultCallback(bizResult) {
|
||||||
|
if (typeof window.__onBizResultCallback === "function") {
|
||||||
|
window.__onBizResultCallback(bizResult)
|
||||||
|
}
|
||||||
|
window.__lastBizResponse = null
|
||||||
|
window.__onCaptchaBizSuccess = null
|
||||||
|
},
|
||||||
|
slideStyle: { width: 360, height: 40 },
|
||||||
|
language: "cn",
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error("获取验证码参数失败,请稍后重试")
|
||||||
|
captchaInitialised = false
|
||||||
|
captchaReadyPromise = null
|
||||||
|
captchaReadyResolve = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 阿里云滑块验证码通用封装。
|
||||||
|
* 依赖 index.html 中已加载的 AliyunCaptcha.js;初始化在首次调起时执行。
|
||||||
|
*/
|
||||||
|
export function useAliyunCaptcha() {
|
||||||
|
/**
|
||||||
|
* 先弹出滑块,通过后执行 bizVerify(captchaVerifyParam),再根据结果调用 onSuccess。
|
||||||
|
* @param { (captchaVerifyParam: string) => Promise<{ success: boolean, data: any, error?: any }> } bizVerify - 业务请求函数,接收滑块参数
|
||||||
|
* @param { (res: any) => void } onSuccess - 业务成功回调
|
||||||
|
*/
|
||||||
|
async function runWithCaptcha(bizVerify, onSuccess) {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
ElMessage.error("验证码仅支持浏览器环境")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingInstance = ElMessage({
|
||||||
|
message: "安全验证加载中...",
|
||||||
|
type: "info",
|
||||||
|
duration: 0,
|
||||||
|
iconClass: "el-icon-loading"
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.__captchaVerifyCallback = async (captchaVerifyParam) => {
|
||||||
|
window.__lastBizResponse = null
|
||||||
|
try {
|
||||||
|
const result = await bizVerify(captchaVerifyParam)
|
||||||
|
window.__lastBizResponse = result
|
||||||
|
const captchaOk = result?.data?.captchaVerifyResult !== false
|
||||||
|
const bizOk = result.success === true
|
||||||
|
return { captchaResult: captchaOk, bizResult: bizOk }
|
||||||
|
} catch (error) {
|
||||||
|
return { captchaResult: false, bizResult: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__onBizResultCallback = (bizResult) => {
|
||||||
|
if (
|
||||||
|
bizResult === true &&
|
||||||
|
window.__lastBizResponse &&
|
||||||
|
typeof window.__onCaptchaBizSuccess === "function"
|
||||||
|
) {
|
||||||
|
window.__onCaptchaBizSuccess(window.__lastBizResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureCaptchaInit()
|
||||||
|
|
||||||
|
// 首次初始化时 SDK 会异步调用 getInstance,需等待实例就绪后再 show
|
||||||
|
if (captchaReadyPromise) {
|
||||||
|
await captchaReadyPromise
|
||||||
|
captchaReadyPromise = null
|
||||||
|
}
|
||||||
|
if (!window.captcha) {
|
||||||
|
ElMessage.error("验证码未加载,请刷新页面重试")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.__onCaptchaBizSuccess = onSuccess
|
||||||
|
window.captcha.show()
|
||||||
|
} finally {
|
||||||
|
loadingInstance.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { runWithCaptcha }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useAliyunCaptcha
|
||||||
@@ -41,6 +41,7 @@ export const userMenuItems = [
|
|||||||
children: [
|
children: [
|
||||||
{ name: '余额充值', path: '/finance/wallet', icon: CreditCard, requiresCertification: true },
|
{ name: '余额充值', path: '/finance/wallet', icon: CreditCard, requiresCertification: true },
|
||||||
{ name: '充值记录', path: '/finance/recharge-records', icon: CreditCard, requiresCertification: true },
|
{ name: '充值记录', path: '/finance/recharge-records', icon: CreditCard, requiresCertification: true },
|
||||||
|
{ name: '购买记录', path: '/finance/purchase-records', icon: ShoppingCart, requiresCertification: true },
|
||||||
{ name: '消费记录', path: '/finance/transactions', icon: Clipboard, requiresCertification: true },
|
{ name: '消费记录', path: '/finance/transactions', icon: Clipboard, requiresCertification: true },
|
||||||
{ name: '发票申请', path: '/finance/invoice', icon: Wallet, requiresCertification: true }
|
{ name: '发票申请', path: '/finance/invoice', icon: Wallet, requiresCertification: true }
|
||||||
]
|
]
|
||||||
@@ -115,6 +116,7 @@ export const getUserAccessibleMenuItems = (userType = 'user') => {
|
|||||||
icon: Setting,
|
icon: Setting,
|
||||||
children: [
|
children: [
|
||||||
{ name: '系统统计', path: '/admin/statistics', icon: ChartBar },
|
{ name: '系统统计', path: '/admin/statistics', icon: ChartBar },
|
||||||
|
{ name: '企业审核', path: '/admin/certification-reviews', icon: ShieldCheck },
|
||||||
{ name: '产品管理', path: '/admin/products', icon: Cube },
|
{ name: '产品管理', path: '/admin/products', icon: Cube },
|
||||||
{ name: '用户管理', path: '/admin/users', icon: Users },
|
{ name: '用户管理', path: '/admin/users', icon: Users },
|
||||||
{ name: '分类管理', path: '/admin/categories', icon: Tag },
|
{ name: '分类管理', path: '/admin/categories', icon: Tag },
|
||||||
@@ -124,7 +126,9 @@ export const getUserAccessibleMenuItems = (userType = 'user') => {
|
|||||||
{ name: '调用记录', path: '/admin/usage', icon: Clipboard },
|
{ name: '调用记录', path: '/admin/usage', icon: Clipboard },
|
||||||
{ name: '消费记录', path: '/admin/transactions', icon: Clipboard },
|
{ name: '消费记录', path: '/admin/transactions', icon: Clipboard },
|
||||||
{ name: '充值记录', path: '/admin/recharge-records', icon: CreditCard },
|
{ name: '充值记录', path: '/admin/recharge-records', icon: CreditCard },
|
||||||
{ name: '发票管理', path: '/admin/invoices', icon: Wallet }
|
{ name: '购买记录', path: '/admin/purchase-records', icon: ShoppingCart },
|
||||||
|
{ name: '发票管理', path: '/admin/invoices', icon: Wallet },
|
||||||
|
{ name: '组件管理', path: '/admin/ui-components', icon: Cube }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -136,6 +140,7 @@ export const getUserAccessibleMenuItems = (userType = 'user') => {
|
|||||||
export const requiresCertificationPaths = [
|
export const requiresCertificationPaths = [
|
||||||
'/finance/wallet',
|
'/finance/wallet',
|
||||||
'/finance/recharge-records',
|
'/finance/recharge-records',
|
||||||
|
'/finance/purchase-records',
|
||||||
'/finance/transactions',
|
'/finance/transactions',
|
||||||
'/apis/usage',
|
'/apis/usage',
|
||||||
'/apis/whitelist'
|
'/apis/whitelist'
|
||||||
@@ -165,6 +170,10 @@ export const getCurrentPageCertificationConfig = (path) => {
|
|||||||
title: '充值记录',
|
title: '充值记录',
|
||||||
description: '为了查看完整的充值记录,请先完成企业入驻认证。认证成功后我们将赠送您一定的调用额度!'
|
description: '为了查看完整的充值记录,请先完成企业入驻认证。认证成功后我们将赠送您一定的调用额度!'
|
||||||
},
|
},
|
||||||
|
'/finance/purchase-records': {
|
||||||
|
title: '购买记录',
|
||||||
|
description: '为了查看完整的购买记录,请先完成企业入驻认证。认证成功后我们将赠送您一定的调用额度!'
|
||||||
|
},
|
||||||
'/finance/transactions': {
|
'/finance/transactions': {
|
||||||
title: '消费记录',
|
title: '消费记录',
|
||||||
description: '为了查看完整的消费记录,请先完成企业入驻认证。认证成功后我们将赠送您一定的调用额度!'
|
description: '为了查看完整的消费记录,请先完成企业入驻认证。认证成功后我们将赠送您一定的调用额度!'
|
||||||
|
|||||||
@@ -862,7 +862,7 @@ const handleExport = async () => {
|
|||||||
const response = await apiCallApi.exportAdminApiCalls(params)
|
const response = await apiCallApi.exportAdminApiCalls(params)
|
||||||
|
|
||||||
// 创建下载链接
|
// 创建下载链接
|
||||||
const blob = new Blob([response], {
|
const blob = new Blob([response.data], {
|
||||||
type: exportOptions.format === 'excel'
|
type: exportOptions.format === 'excel'
|
||||||
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
: 'text/csv;charset=utf-8'
|
: 'text/csv;charset=utf-8'
|
||||||
|
|||||||
@@ -40,9 +40,16 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column label="操作" width="180" fixed="right">
|
<el-table-column label="操作" width="240" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
|
<el-button
|
||||||
|
type="info"
|
||||||
|
size="small"
|
||||||
|
@click="handleManageSubCategories(row)"
|
||||||
|
>
|
||||||
|
小类管理
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -128,6 +135,137 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 小类管理弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="subCategoryDialogVisible"
|
||||||
|
:title="`${selectedCategory?.name || '分类'} - 小类管理`"
|
||||||
|
width="900px"
|
||||||
|
@close="handleCloseSubCategoryDialog"
|
||||||
|
>
|
||||||
|
<div class="mb-4">
|
||||||
|
<el-button type="primary" @click="handleCreateSubCategory">
|
||||||
|
新增小类
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
v-loading="subCategoryLoading"
|
||||||
|
:data="subCategories"
|
||||||
|
stripe
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<el-table-column prop="code" label="小类编号" width="120" />
|
||||||
|
<el-table-column prop="name" label="小类名称" min-width="150" />
|
||||||
|
<el-table-column prop="description" label="小类描述" min-width="200" />
|
||||||
|
<el-table-column prop="sort" label="排序" width="80" />
|
||||||
|
<el-table-column prop="is_enabled" label="状态" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_enabled ? 'success' : 'danger'" size="small">
|
||||||
|
{{ row.is_enabled ? '启用' : '禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="is_visible" label="展示" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_visible ? 'success' : 'warning'" size="small">
|
||||||
|
{{ row.is_visible ? '显示' : '隐藏' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="140" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="handleEditSubCategory(row)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
@click="handleDeleteSubCategory(row)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<el-button @click="subCategoryDialogVisible = false">关闭</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 小类表单弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="subCategoryFormDialogVisible"
|
||||||
|
:title="isSubCategoryEdit ? '编辑小类' : '新增小类'"
|
||||||
|
width="600px"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="subCategoryFormRef"
|
||||||
|
:model="subCategoryForm"
|
||||||
|
:rules="subCategoryRules"
|
||||||
|
label-width="100px"
|
||||||
|
>
|
||||||
|
<el-form-item label="小类编号" prop="code">
|
||||||
|
<el-input
|
||||||
|
v-model="subCategoryForm.code"
|
||||||
|
placeholder="请输入小类编号"
|
||||||
|
:disabled="isSubCategoryEdit"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="小类名称" prop="name">
|
||||||
|
<el-input
|
||||||
|
v-model="subCategoryForm.name"
|
||||||
|
placeholder="请输入小类名称"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="小类描述" prop="description">
|
||||||
|
<el-input
|
||||||
|
v-model="subCategoryForm.description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="请输入小类描述"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="排序" prop="sort">
|
||||||
|
<el-input-number
|
||||||
|
v-model="subCategoryForm.sort"
|
||||||
|
:min="0"
|
||||||
|
:max="999"
|
||||||
|
placeholder="排序值"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="是否启用" prop="is_enabled">
|
||||||
|
<el-switch v-model="subCategoryForm.is_enabled" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="是否展示" prop="is_visible">
|
||||||
|
<el-switch v-model="subCategoryForm.is_visible" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<el-button @click="handleSubCategoryCancel">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleSubCategorySubmit" :loading="subCategorySubmitting">
|
||||||
|
{{ isSubCategoryEdit ? '保存修改' : '创建小类' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
</template>
|
</template>
|
||||||
@@ -145,6 +283,16 @@ const submitting = ref(false)
|
|||||||
const editingCategory = ref(null)
|
const editingCategory = ref(null)
|
||||||
const formRef = ref(null)
|
const formRef = ref(null)
|
||||||
|
|
||||||
|
// 小类管理相关数据
|
||||||
|
const subCategoryDialogVisible = ref(false)
|
||||||
|
const subCategoryFormDialogVisible = ref(false)
|
||||||
|
const subCategoryLoading = ref(false)
|
||||||
|
const subCategories = ref([])
|
||||||
|
const selectedCategory = ref(null)
|
||||||
|
const editingSubCategory = ref(null)
|
||||||
|
const subCategoryFormRef = ref(null)
|
||||||
|
const subCategorySubmitting = ref(false)
|
||||||
|
|
||||||
// 表单初始值
|
// 表单初始值
|
||||||
const initialFormData = {
|
const initialFormData = {
|
||||||
code: '',
|
code: '',
|
||||||
@@ -173,8 +321,38 @@ const rules = {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 小类表单初始值
|
||||||
|
const initialSubCategoryFormData = {
|
||||||
|
category_id: '',
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
sort: 0,
|
||||||
|
is_enabled: true,
|
||||||
|
is_visible: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小类表单数据
|
||||||
|
const subCategoryForm = reactive({ ...initialSubCategoryFormData })
|
||||||
|
|
||||||
|
// 小类表单验证规则
|
||||||
|
const subCategoryRules = {
|
||||||
|
code: [
|
||||||
|
{ required: true, message: '请输入小类编号', trigger: 'blur' },
|
||||||
|
{ min: 2, max: 20, message: '小类编号长度在 2 到 20 个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入小类名称', trigger: 'blur' },
|
||||||
|
{ min: 2, max: 50, message: '小类名称长度在 2 到 50 个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
description: [
|
||||||
|
{ required: true, message: '请输入小类描述', trigger: 'blur' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const isEdit = computed(() => !!editingCategory.value)
|
const isEdit = computed(() => !!editingCategory.value)
|
||||||
|
const isSubCategoryEdit = computed(() => !!editingSubCategory.value)
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -299,6 +477,138 @@ const handleCancel = () => {
|
|||||||
editingCategory.value = null
|
editingCategory.value = null
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 小类管理相关方法
|
||||||
|
|
||||||
|
// 管理小类
|
||||||
|
const handleManageSubCategories = async (category) => {
|
||||||
|
selectedCategory.value = category
|
||||||
|
subCategoryDialogVisible.value = true
|
||||||
|
await loadSubCategories(category.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载小类列表
|
||||||
|
const loadSubCategories = async (categoryId) => {
|
||||||
|
subCategoryLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await productAdminApi.getSubCategories({ category_id: categoryId, page: 1, page_size: 100 })
|
||||||
|
subCategories.value = response.data?.items || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载小类失败:', error)
|
||||||
|
ElMessage.error('加载小类失败')
|
||||||
|
} finally {
|
||||||
|
subCategoryLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增小类
|
||||||
|
const handleCreateSubCategory = () => {
|
||||||
|
if (!selectedCategory.value) {
|
||||||
|
ElMessage.warning('请先选择分类')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
editingSubCategory.value = null
|
||||||
|
resetSubCategoryForm()
|
||||||
|
subCategoryForm.category_id = selectedCategory.value.id
|
||||||
|
subCategoryFormDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑小类
|
||||||
|
const handleEditSubCategory = (subCategory) => {
|
||||||
|
editingSubCategory.value = { ...subCategory }
|
||||||
|
Object.keys(subCategoryForm).forEach(key => {
|
||||||
|
if (subCategory[key] !== undefined) {
|
||||||
|
subCategoryForm[key] = subCategory[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
subCategoryFormDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除小类
|
||||||
|
const handleDeleteSubCategory = async (subCategory) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要删除小类"${subCategory.name}"吗?此操作不可撤销。`,
|
||||||
|
'确认删除',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await productAdminApi.deleteSubCategory(subCategory.id)
|
||||||
|
ElMessage.success('小类删除成功')
|
||||||
|
await loadSubCategories(selectedCategory.value.id)
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
console.error('删除小类失败:', error)
|
||||||
|
ElMessage.error('删除小类失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交小类表单
|
||||||
|
const handleSubCategorySubmit = async () => {
|
||||||
|
if (!subCategoryFormRef.value) return
|
||||||
|
try {
|
||||||
|
await subCategoryFormRef.value.validate()
|
||||||
|
subCategorySubmitting.value = true
|
||||||
|
|
||||||
|
const submitData = { ...subCategoryForm }
|
||||||
|
|
||||||
|
if (isSubCategoryEdit.value) {
|
||||||
|
await productAdminApi.updateSubCategory(editingSubCategory.value.id, submitData)
|
||||||
|
ElMessage.success('小类更新成功')
|
||||||
|
} else {
|
||||||
|
await productAdminApi.createSubCategory(submitData)
|
||||||
|
ElMessage.success('小类创建成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
subCategoryFormDialogVisible.value = false
|
||||||
|
await loadSubCategories(selectedCategory.value.id)
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== false) { // 不是表单验证错误
|
||||||
|
console.error('提交小类失败:', error)
|
||||||
|
ElMessage.error(isSubCategoryEdit.value ? '更新小类失败' : '创建小类失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
subCategorySubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置小类表单
|
||||||
|
const resetSubCategoryForm = () => {
|
||||||
|
subCategoryForm.category_id = ''
|
||||||
|
subCategoryForm.code = ''
|
||||||
|
subCategoryForm.name = ''
|
||||||
|
subCategoryForm.description = ''
|
||||||
|
subCategoryForm.sort = 0
|
||||||
|
subCategoryForm.is_enabled = true
|
||||||
|
subCategoryForm.is_visible = true
|
||||||
|
|
||||||
|
// 清除表单验证状态
|
||||||
|
nextTick(() => {
|
||||||
|
if (subCategoryFormRef.value) {
|
||||||
|
subCategoryFormRef.value.clearValidate()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消小类操作
|
||||||
|
const handleSubCategoryCancel = () => {
|
||||||
|
subCategoryFormDialogVisible.value = false
|
||||||
|
setTimeout(() => {
|
||||||
|
resetSubCategoryForm()
|
||||||
|
editingSubCategory.value = null
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭小类管理弹窗
|
||||||
|
const handleCloseSubCategoryDialog = () => {
|
||||||
|
selectedCategory.value = null
|
||||||
|
subCategories.value = []
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
702
src/pages/admin/certification-reviews/index.vue
Normal file
702
src/pages/admin/certification-reviews/index.vue
Normal file
@@ -0,0 +1,702 @@
|
|||||||
|
<template>
|
||||||
|
<div class="certification-reviews-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">企业审核</h1>
|
||||||
|
<p class="page-subtitle">审核用户提交的企业信息,通过后可进入企业认证流程</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-select
|
||||||
|
v-model="filterStatus"
|
||||||
|
placeholder="认证状态"
|
||||||
|
clearable
|
||||||
|
style="width: 140px"
|
||||||
|
@change="loadList"
|
||||||
|
>
|
||||||
|
<el-option label="全部" value="" />
|
||||||
|
<el-option label="待审核" value="info_pending_review" />
|
||||||
|
<el-option label="已通过" value="info_submitted" />
|
||||||
|
<el-option label="已拒绝" value="info_rejected" />
|
||||||
|
</el-select>
|
||||||
|
<el-input
|
||||||
|
v-model="filterCompanyName"
|
||||||
|
placeholder="企业名称"
|
||||||
|
clearable
|
||||||
|
style="width: 180px"
|
||||||
|
@keyup.enter="loadList"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-model="filterLegalPersonPhone"
|
||||||
|
placeholder="法人手机号"
|
||||||
|
clearable
|
||||||
|
style="width: 140px"
|
||||||
|
@keyup.enter="loadList"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-model="filterLegalPersonName"
|
||||||
|
placeholder="法人姓名"
|
||||||
|
clearable
|
||||||
|
style="width: 120px"
|
||||||
|
@keyup.enter="loadList"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" @click="loadList">查询</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
v-if="!isMobile"
|
||||||
|
v-loading="loading"
|
||||||
|
:data="list"
|
||||||
|
stripe
|
||||||
|
style="width: 100%"
|
||||||
|
class="reviews-table"
|
||||||
|
>
|
||||||
|
<el-table-column prop="submit_at" label="提交时间" width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDate(row.submit_at) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="user_id" label="用户ID" width="280" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="company_name" label="企业名称" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="unified_social_code" label="统一社会信用代码" width="200" />
|
||||||
|
<el-table-column prop="legal_person_name" label="法人姓名" width="100" />
|
||||||
|
<el-table-column prop="certification_status" label="认证状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusTagType(row)" size="small">
|
||||||
|
{{ certificationStatusDisplay(row?.certification_status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="240" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" @click="openDetail(row.id)">查看详情</el-button>
|
||||||
|
<template v-if="canShowApproveReject(row)">
|
||||||
|
<el-button link type="success" @click="handleApprove(row)">通过</el-button>
|
||||||
|
<el-button link type="danger" @click="handleReject(row)">拒绝</el-button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div v-else v-loading="loading" class="mobile-list">
|
||||||
|
<div v-if="list.length" class="mobile-card-list">
|
||||||
|
<article v-for="row in list" :key="row.id" class="mobile-card">
|
||||||
|
<div class="mobile-card-header">
|
||||||
|
<div class="mobile-company">{{ row.company_name || '-' }}</div>
|
||||||
|
<el-tag :type="statusTagType(row)" size="small">
|
||||||
|
{{ certificationStatusDisplay(row?.certification_status) }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="mobile-card-info"><span>提交时间:</span>{{ formatDate(row.submit_at) }}</div>
|
||||||
|
<div class="mobile-card-info"><span>法人:</span>{{ row.legal_person_name || '-' }}</div>
|
||||||
|
<div class="mobile-card-info"><span>手机号:</span>{{ row.legal_person_phone || '-' }}</div>
|
||||||
|
<div class="mobile-card-info"><span>统一代码:</span>{{ row.unified_social_code || '-' }}</div>
|
||||||
|
<div class="mobile-card-actions">
|
||||||
|
<el-button type="primary" plain size="small" @click="openDetail(row.id)">查看详情</el-button>
|
||||||
|
<template v-if="canShowApproveReject(row)">
|
||||||
|
<el-button type="success" plain size="small" @click="handleApprove(row)">通过</el-button>
|
||||||
|
<el-button type="danger" plain size="small" @click="handleReject(row)">拒绝</el-button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else description="暂无数据" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination-wrap">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@size-change="loadList"
|
||||||
|
@current-change="loadList"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 详情抽屉 -->
|
||||||
|
<el-drawer
|
||||||
|
v-model="drawerVisible"
|
||||||
|
title="企业信息详情"
|
||||||
|
:size="isMobile ? '100%' : '560px'"
|
||||||
|
direction="rtl"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span class="drawer-title">企业信息详情</span>
|
||||||
|
<div class="drawer-actions">
|
||||||
|
<template v-if="detail && canShowApproveReject(detail)">
|
||||||
|
<el-button type="success" size="small" @click="approveFromDrawer">通过</el-button>
|
||||||
|
<el-button type="danger" size="small" @click="rejectFromDrawer">拒绝</el-button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="detail" class="detail-content">
|
||||||
|
<section class="detail-section">
|
||||||
|
<h4 class="detail-section-title">基本信息</h4>
|
||||||
|
<dl class="detail-dl">
|
||||||
|
<dt>企业名称</dt>
|
||||||
|
<dd>{{ detail.company_name }}</dd>
|
||||||
|
<dt>统一社会信用代码</dt>
|
||||||
|
<dd class="detail-mono">{{ detail.unified_social_code }}</dd>
|
||||||
|
<dt>法人姓名</dt>
|
||||||
|
<dd>{{ detail.legal_person_name }}</dd>
|
||||||
|
<dt>法人身份证号</dt>
|
||||||
|
<dd class="detail-mono">{{ detail.legal_person_id }}</dd>
|
||||||
|
<dt>法人手机号</dt>
|
||||||
|
<dd>{{ detail.legal_person_phone }}</dd>
|
||||||
|
<dt>企业地址</dt>
|
||||||
|
<dd class="detail-long">{{ detail.enterprise_address || '-' }}</dd>
|
||||||
|
<dt>提交时间</dt>
|
||||||
|
<dd>{{ formatDate(detail.submit_at) }}</dd>
|
||||||
|
<dt>认证状态</dt>
|
||||||
|
<dd>
|
||||||
|
<el-tag :type="statusTagType(detail)" size="small">
|
||||||
|
{{ certificationStatusDisplay(detail?.certification_status) }}
|
||||||
|
</el-tag>
|
||||||
|
</dd>
|
||||||
|
<template v-if="detail.failure_reason">
|
||||||
|
<dt>失败原因</dt>
|
||||||
|
<dd class="detail-long detail-error">{{ detail.failure_reason }}</dd>
|
||||||
|
</template>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-section">
|
||||||
|
<h4 class="detail-section-title">授权代表</h4>
|
||||||
|
<dl class="detail-dl">
|
||||||
|
<dt>姓名</dt>
|
||||||
|
<dd>{{ (detail.authorized_rep_name ?? detail.authorizedRepName) || '-' }}</dd>
|
||||||
|
<dt>身份证号</dt>
|
||||||
|
<dd class="detail-mono">{{ (detail.authorized_rep_id ?? detail.authorizedRepId) || '-' }}</dd>
|
||||||
|
<dt>手机号</dt>
|
||||||
|
<dd>{{ (detail.authorized_rep_phone ?? detail.authorizedRepPhone) || '-' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-section">
|
||||||
|
<h4 class="detail-section-title">应用场景说明</h4>
|
||||||
|
<div class="detail-long-block">{{ detail.api_usage || '无' }}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="image-section">
|
||||||
|
<h4>营业执照</h4>
|
||||||
|
<div v-if="detail.business_license_image_url" class="image-list">
|
||||||
|
<a :href="detail.business_license_image_url" target="_blank" rel="noopener" class="image-link">
|
||||||
|
<img v-if="isImageUrl(detail.business_license_image_url)" :src="detail.business_license_image_url" alt="营业执照" class="thumb" />
|
||||||
|
<span v-else>查看链接</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-gray-500 text-sm">无</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="image-section">
|
||||||
|
<h4>办公场地照片</h4>
|
||||||
|
<div v-if="officePlaceUrls.length" class="image-list">
|
||||||
|
<a v-for="(url, i) in officePlaceUrls" :key="i" :href="url" target="_blank" rel="noopener" class="image-link">
|
||||||
|
<img v-if="isImageUrl(url)" :src="url" :alt="`场地${i + 1}`" class="thumb" />
|
||||||
|
<span v-else>链接{{ i + 1 }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-gray-500 text-sm">无</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="image-section">
|
||||||
|
<h4>应用场景附件</h4>
|
||||||
|
<div v-if="scenarioUrls.length" class="image-list">
|
||||||
|
<a v-for="(url, i) in scenarioUrls" :key="i" :href="url" target="_blank" rel="noopener" class="image-link">
|
||||||
|
<img v-if="isImageUrl(url)" :src="url" :alt="`场景${i + 1}`" class="thumb" />
|
||||||
|
<span v-else>链接{{ i + 1 }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-gray-500 text-sm">无</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="image-section">
|
||||||
|
<h4>授权代表身份证</h4>
|
||||||
|
<div v-if="authorizedRepIdUrls.length" class="image-list">
|
||||||
|
<a v-for="(url, i) in authorizedRepIdUrls" :key="i" :href="url" target="_blank" rel="noopener" class="image-link">
|
||||||
|
<img v-if="isImageUrl(url)" :src="url" :alt="i === 0 ? '人像面' : '国徽面'" class="thumb" />
|
||||||
|
<span v-else>{{ i === 0 ? '人像面' : '国徽面' }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-gray-500 text-sm">无</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
|
<!-- 通过弹窗 -->
|
||||||
|
<el-dialog v-model="approveDialogVisible" title="审核通过" :width="isMobile ? '92%' : '400px'">
|
||||||
|
<el-form label-width="80">
|
||||||
|
<el-form-item label="审核备注">
|
||||||
|
<el-input v-model="approveRemark" type="textarea" :rows="3" placeholder="选填" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="approveDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="success" :loading="actionLoading" @click="confirmApprove">确认通过</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 拒绝弹窗 -->
|
||||||
|
<el-dialog v-model="rejectDialogVisible" title="审核拒绝" :width="isMobile ? '92%' : '400px'">
|
||||||
|
<el-form label-width="80">
|
||||||
|
<el-form-item label="拒绝原因" required>
|
||||||
|
<el-input v-model="rejectRemark" type="textarea" :rows="3" placeholder="必填" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="rejectDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="danger" :loading="actionLoading" :disabled="!rejectRemark.trim()" @click="confirmReject">确认拒绝</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { certificationApi } from '@/api/index.js'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const list = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const filterStatus = ref('')
|
||||||
|
const filterCompanyName = ref('')
|
||||||
|
const filterLegalPersonPhone = ref('')
|
||||||
|
const filterLegalPersonName = ref('')
|
||||||
|
|
||||||
|
const drawerVisible = ref(false)
|
||||||
|
const detail = ref(null)
|
||||||
|
const approveDialogVisible = ref(false)
|
||||||
|
const rejectDialogVisible = ref(false)
|
||||||
|
const approveRemark = ref('')
|
||||||
|
const rejectRemark = ref('')
|
||||||
|
const actionLoading = ref(false)
|
||||||
|
const pendingRecordId = ref('')
|
||||||
|
const pendingUserId = ref('')
|
||||||
|
const isMobile = ref(false)
|
||||||
|
|
||||||
|
function updateMobileState() {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
isMobile.value = window.innerWidth <= 768
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(val) {
|
||||||
|
if (!val) return '-'
|
||||||
|
try {
|
||||||
|
const d = new Date(val)
|
||||||
|
return Number.isNaN(d.getTime()) ? val : d.toLocaleString('zh-CN')
|
||||||
|
} catch {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 以状态机为准:认证状态展示与是否可操作(全流程口径)
|
||||||
|
const CERTIFICATION_STATUS_LABELS = {
|
||||||
|
pending: '待认证',
|
||||||
|
info_pending_review: '待审核',
|
||||||
|
info_submitted: '已通过',
|
||||||
|
info_rejected: '已拒绝',
|
||||||
|
enterprise_verified: '已企业认证',
|
||||||
|
contract_applied: '已申请合同',
|
||||||
|
contract_signed: '已签署合同',
|
||||||
|
contract_rejected: '合同拒签',
|
||||||
|
contract_expired: '合同超时',
|
||||||
|
completed: '已完成'
|
||||||
|
}
|
||||||
|
|
||||||
|
function certificationStatusDisplay(certificationStatus) {
|
||||||
|
if (!certificationStatus) return '-'
|
||||||
|
return CERTIFICATION_STATUS_LABELS[certificationStatus] || certificationStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTagType(row) {
|
||||||
|
const status = row?.certification_status
|
||||||
|
const m = {
|
||||||
|
pending: 'info',
|
||||||
|
info_pending_review: 'warning',
|
||||||
|
info_submitted: 'success',
|
||||||
|
info_rejected: 'danger',
|
||||||
|
enterprise_verified: 'success',
|
||||||
|
contract_applied: 'info',
|
||||||
|
contract_signed: 'success',
|
||||||
|
contract_rejected: 'danger',
|
||||||
|
contract_expired: 'warning',
|
||||||
|
completed: 'success'
|
||||||
|
}
|
||||||
|
return m[status] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否展示通过/拒绝:须当前认证为待审核,且本条提交记录为「校验通过」(verified)。
|
||||||
|
* 历史失败记录 (failed) 与认证状态无关字段共用同一 certification_status,否则会误显按钮。
|
||||||
|
*/
|
||||||
|
function canShowApproveReject(row) {
|
||||||
|
if (!row) return false
|
||||||
|
if (row.certification_status !== 'info_pending_review') return false
|
||||||
|
return row.status === 'verified'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否可作为图片展示(含七牛云等无扩展名的 CDN URL)
|
||||||
|
function isImageUrl(url) {
|
||||||
|
if (!url || typeof url !== 'string') return false
|
||||||
|
if (url.startsWith('blob:') || url.startsWith('data:')) return true
|
||||||
|
if (url.startsWith('https://file.tianyuanapi.com')) return true
|
||||||
|
return /\.(jpe?g|png|webp|gif)(\?|$)/i.test(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const officePlaceUrls = computed(() => {
|
||||||
|
if (!detail.value?.office_place_image_urls) return []
|
||||||
|
try {
|
||||||
|
const v = detail.value.office_place_image_urls
|
||||||
|
return typeof v === 'string' ? JSON.parse(v) : v
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const scenarioUrls = computed(() => {
|
||||||
|
if (!detail.value?.scenario_attachment_urls) return []
|
||||||
|
try {
|
||||||
|
const v = detail.value.scenario_attachment_urls
|
||||||
|
return typeof v === 'string' ? JSON.parse(v) : v
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const authorizedRepIdUrls = computed(() => {
|
||||||
|
if (!detail.value?.authorized_rep_id_image_urls) return []
|
||||||
|
try {
|
||||||
|
const v = detail.value.authorized_rep_id_image_urls
|
||||||
|
return typeof v === 'string' ? JSON.parse(v) : v
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await certificationApi.adminListSubmitRecords({
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
certification_status: filterStatus.value || undefined,
|
||||||
|
company_name: filterCompanyName.value || undefined,
|
||||||
|
legal_person_phone: filterLegalPersonPhone.value || undefined,
|
||||||
|
legal_person_name: filterLegalPersonName.value || undefined
|
||||||
|
})
|
||||||
|
const data = res?.data
|
||||||
|
list.value = data?.items ?? []
|
||||||
|
total.value = data?.total ?? 0
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e?.message || '加载列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(id) {
|
||||||
|
try {
|
||||||
|
const res = await certificationApi.adminGetSubmitRecord(id)
|
||||||
|
detail.value = res?.data ?? res
|
||||||
|
drawerVisible.value = true
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e?.message || '加载详情失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleApprove(row) {
|
||||||
|
pendingRecordId.value = row.id
|
||||||
|
pendingUserId.value = row.user_id
|
||||||
|
approveRemark.value = ''
|
||||||
|
approveDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function approveFromDrawer() {
|
||||||
|
if (!detail.value?.id) return
|
||||||
|
pendingRecordId.value = detail.value.id
|
||||||
|
pendingUserId.value = detail.value.user_id
|
||||||
|
approveRemark.value = ''
|
||||||
|
approveDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmApprove() {
|
||||||
|
if (!pendingUserId.value) return
|
||||||
|
actionLoading.value = true
|
||||||
|
try {
|
||||||
|
await certificationApi.adminTransitionCertificationStatus({
|
||||||
|
user_id: pendingUserId.value,
|
||||||
|
target_status: 'info_submitted',
|
||||||
|
remark: approveRemark.value || ''
|
||||||
|
})
|
||||||
|
ElMessage.success('已通过')
|
||||||
|
approveDialogVisible.value = false
|
||||||
|
drawerVisible.value = false
|
||||||
|
detail.value = null
|
||||||
|
pendingRecordId.value = ''
|
||||||
|
pendingUserId.value = ''
|
||||||
|
loadList()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e?.message || '操作失败')
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReject(row) {
|
||||||
|
pendingRecordId.value = row.id
|
||||||
|
pendingUserId.value = row.user_id
|
||||||
|
rejectRemark.value = ''
|
||||||
|
rejectDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectFromDrawer() {
|
||||||
|
if (!detail.value?.id) return
|
||||||
|
pendingRecordId.value = detail.value.id
|
||||||
|
pendingUserId.value = detail.value.user_id
|
||||||
|
rejectRemark.value = ''
|
||||||
|
rejectDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmReject() {
|
||||||
|
if (!pendingUserId.value || !rejectRemark.value?.trim()) return
|
||||||
|
actionLoading.value = true
|
||||||
|
try {
|
||||||
|
await certificationApi.adminTransitionCertificationStatus({
|
||||||
|
user_id: pendingUserId.value,
|
||||||
|
target_status: 'info_rejected',
|
||||||
|
remark: rejectRemark.value.trim()
|
||||||
|
})
|
||||||
|
ElMessage.success('已拒绝')
|
||||||
|
rejectDialogVisible.value = false
|
||||||
|
drawerVisible.value = false
|
||||||
|
detail.value = null
|
||||||
|
pendingRecordId.value = ''
|
||||||
|
pendingUserId.value = ''
|
||||||
|
loadList()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e?.message || '操作失败')
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateMobileState()
|
||||||
|
window.addEventListener('resize', updateMobileState)
|
||||||
|
loadList()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', updateMobileState)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.certification-reviews-page {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #64748b;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.pagination-wrap {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.mobile-list {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
.mobile-card-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.mobile-card {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.mobile-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.mobile-company {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.mobile-card-info {
|
||||||
|
color: #475569;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.mobile-card-info span {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.mobile-card-actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.drawer-title {
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
.drawer-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.detail-content {
|
||||||
|
padding-right: 12px;
|
||||||
|
max-height: calc(100vh - 60px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.detail-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.detail-section-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
.detail-dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 110px 1fr;
|
||||||
|
gap: 8px 16px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.detail-dl dt {
|
||||||
|
margin: 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.detail-dl dd {
|
||||||
|
margin: 0;
|
||||||
|
color: #1e293b;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.detail-mono {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.detail-long {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.detail-long-block {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #334155;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
max-height: 160px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.detail-error {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
.image-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.image-section h4 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
.image-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.image-link {
|
||||||
|
display: block;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 100px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.image-link .thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.certification-reviews-page {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.toolbar > * {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
.pagination-wrap {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.detail-content {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
.drawer-actions {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.detail-dl {
|
||||||
|
grid-template-columns: 88px 1fr;
|
||||||
|
gap: 6px 10px;
|
||||||
|
}
|
||||||
|
.image-link {
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
line-height: 88px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
857
src/pages/admin/purchase-records/index.vue
Normal file
857
src/pages/admin/purchase-records/index.vue
Normal file
@@ -0,0 +1,857 @@
|
|||||||
|
<template>
|
||||||
|
<ListPageLayout title="购买记录管理" subtitle="管理系统内所有用户的购买记录">
|
||||||
|
<!-- 单用户模式显示 -->
|
||||||
|
<template #stats v-if="singleUserMode">
|
||||||
|
<div class="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<User class="w-4 h-4" />
|
||||||
|
<span>当前用户:{{ currentUser?.company_name || currentUser?.phone }}</span>
|
||||||
|
<span class="text-gray-400">(仅显示当前用户)</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 单用户模式操作按钮 -->
|
||||||
|
<template #actions v-if="singleUserMode">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<User class="w-4 h-4" />
|
||||||
|
<span>{{ currentUser?.enterprise_info?.company_name || currentUser?.phone }}</span>
|
||||||
|
</div>
|
||||||
|
<el-button size="small" @click="exitSingleUserMode">
|
||||||
|
<Close class="w-4 h-4 mr-1" />
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="primary" @click="goBackToUsers">
|
||||||
|
<Back class="w-4 h-4 mr-1" />
|
||||||
|
返回用户管理
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 筛选区域 -->
|
||||||
|
<template #filters>
|
||||||
|
<FilterSection>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
<FilterItem label="企业名称" v-if="!singleUserMode">
|
||||||
|
<el-input
|
||||||
|
v-model="filters.company_name"
|
||||||
|
placeholder="输入企业名称"
|
||||||
|
clearable
|
||||||
|
@input="handleFilterChange"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</FilterItem>
|
||||||
|
|
||||||
|
<FilterItem label="支付类型">
|
||||||
|
<el-select
|
||||||
|
v-model="filters.payment_type"
|
||||||
|
placeholder="选择支付类型"
|
||||||
|
clearable
|
||||||
|
@change="handleFilterChange"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<el-option label="支付宝" value="alipay" />
|
||||||
|
<el-option label="微信" value="wechat" />
|
||||||
|
<el-option label="免费" value="free" />
|
||||||
|
</el-select>
|
||||||
|
</FilterItem>
|
||||||
|
|
||||||
|
<FilterItem label="支付渠道">
|
||||||
|
<el-select
|
||||||
|
v-model="filters.pay_channel"
|
||||||
|
placeholder="选择支付渠道"
|
||||||
|
clearable
|
||||||
|
@change="handleFilterChange"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<el-option label="支付宝" value="alipay" />
|
||||||
|
<el-option label="微信" value="wechat" />
|
||||||
|
</el-select>
|
||||||
|
</FilterItem>
|
||||||
|
|
||||||
|
<FilterItem label="订单状态">
|
||||||
|
<el-select
|
||||||
|
v-model="filters.status"
|
||||||
|
placeholder="选择订单状态"
|
||||||
|
clearable
|
||||||
|
@change="handleFilterChange"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<el-option label="已创建" value="created" />
|
||||||
|
<el-option label="已支付" value="paid" />
|
||||||
|
<el-option label="支付失败" value="failed" />
|
||||||
|
<el-option label="已取消" value="cancelled" />
|
||||||
|
<el-option label="已退款" value="refunded" />
|
||||||
|
<el-option label="已关闭" value="closed" />
|
||||||
|
</el-select>
|
||||||
|
</FilterItem>
|
||||||
|
|
||||||
|
<FilterItem label="产品名称" v-if="!singleUserMode">
|
||||||
|
<el-input
|
||||||
|
v-model="filters.product_name"
|
||||||
|
placeholder="输入产品名称"
|
||||||
|
clearable
|
||||||
|
@input="handleFilterChange"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</FilterItem>
|
||||||
|
|
||||||
|
<FilterItem label="购买时间" class="col-span-1 md:col-span-2">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="datetimerange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始时间"
|
||||||
|
end-placeholder="结束时间"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
@change="handleTimeRangeChange"
|
||||||
|
class="w-full"
|
||||||
|
:size="isMobile ? 'small' : 'default'"
|
||||||
|
/>
|
||||||
|
</FilterItem>
|
||||||
|
|
||||||
|
<FilterItem label="金额范围">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<el-input
|
||||||
|
v-model="filters.min_amount"
|
||||||
|
placeholder="最小金额"
|
||||||
|
clearable
|
||||||
|
@input="handleFilterChange"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<span class="text-gray-400 self-center">-</span>
|
||||||
|
<el-input
|
||||||
|
v-model="filters.max_amount"
|
||||||
|
placeholder="最大金额"
|
||||||
|
clearable
|
||||||
|
@input="handleFilterChange"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FilterItem>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #stats> 共找到 {{ total }} 条购买记录 </template>
|
||||||
|
|
||||||
|
<template #buttons>
|
||||||
|
<el-button @click="resetFilters">重置筛选</el-button>
|
||||||
|
<el-button type="primary" @click="loadPurchaseRecords">应用筛选</el-button>
|
||||||
|
<el-button type="success" @click="showExportDialog">
|
||||||
|
<Download class="w-4 h-4 mr-1" />
|
||||||
|
导出数据
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</FilterSection>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 表格区域 -->
|
||||||
|
<template #table>
|
||||||
|
<div v-if="loading" class="flex justify-center items-center py-12">
|
||||||
|
<el-loading size="large" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<el-table
|
||||||
|
:data="purchaseRecords"
|
||||||
|
style="width: 100%"
|
||||||
|
:header-cell-style="{
|
||||||
|
background: '#f8fafc',
|
||||||
|
color: '#475569',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '14px',
|
||||||
|
}"
|
||||||
|
:cell-style="{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#1e293b',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<el-table-column prop="order_no" label="订单号" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="text-sm">
|
||||||
|
<div class="text-gray-500">商户订单:</div>
|
||||||
|
<div class="font-mono">{{ row.order_no }}</div>
|
||||||
|
<div v-if="row.trade_no" class="text-gray-500 mt-1">交易号:</div>
|
||||||
|
<div v-if="row.trade_no" class="font-mono">{{ row.trade_no }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column
|
||||||
|
prop="company_name"
|
||||||
|
label="企业名称"
|
||||||
|
min-width="150"
|
||||||
|
v-if="!singleUserMode"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-blue-600">{{ row.company_name || '未知企业' }}</div>
|
||||||
|
<div class="text-xs text-gray-500">{{ row.user?.phone }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="product_name" label="产品信息" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-sm font-medium">{{ row.product_name }}</div>
|
||||||
|
<div class="text-xs text-gray-500">{{ row.product_code }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="amount" label="订单金额" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="font-semibold text-green-600">¥{{ formatPrice(row.amount) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="payment_type" label="支付类型" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getPaymentTypeTagType(row.payment_type)" size="small" effect="light">
|
||||||
|
{{ getPaymentTypeText(row.payment_type) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="pay_channel" label="支付渠道" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getPayChannelTagType(row.pay_channel)" size="small" effect="light">
|
||||||
|
{{ getPayChannelText(row.pay_channel) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="status" label="订单状态" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getStatusTagType(row.status)" size="small" effect="light">
|
||||||
|
{{ getStatusText(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="pay_time" label="支付时间" width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="text-sm">
|
||||||
|
<div v-if="row.pay_time" class="text-gray-900">{{ formatDate(row.pay_time) }}</div>
|
||||||
|
<div v-if="row.pay_time" class="text-gray-500">{{ formatTime(row.pay_time) }}</div>
|
||||||
|
<div v-else class="text-gray-400">-</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="操作" width="120" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<el-button size="small" type="primary" @click="handleViewDetail(row)">
|
||||||
|
查看详情
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<template #pagination>
|
||||||
|
<el-pagination
|
||||||
|
v-if="total > 0"
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #extra>
|
||||||
|
<!-- 购买记录详情弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="detailDialogVisible"
|
||||||
|
title="购买记录详情"
|
||||||
|
width="800px"
|
||||||
|
class="purchase-detail-dialog"
|
||||||
|
>
|
||||||
|
<div v-if="selectedPurchaseRecord" class="space-y-6">
|
||||||
|
<!-- 基本信息 -->
|
||||||
|
<div class="info-section">
|
||||||
|
<h4 class="text-lg font-semibold text-gray-900 mb-4">基本信息</h4>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">订单号</span>
|
||||||
|
<span class="info-value font-mono">{{ selectedPurchaseRecord?.order_no || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" v-if="selectedPurchaseRecord?.trade_no">
|
||||||
|
<span class="info-label">交易号</span>
|
||||||
|
<span class="info-value font-mono">{{ selectedPurchaseRecord?.trade_no || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">产品名称</span>
|
||||||
|
<span class="info-value">{{ selectedPurchaseRecord?.product_name || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">产品代码</span>
|
||||||
|
<span class="info-value">{{ selectedPurchaseRecord?.product_code || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">订单金额</span>
|
||||||
|
<span class="info-value text-green-600 font-semibold"
|
||||||
|
>¥{{ formatPrice(selectedPurchaseRecord?.amount) }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">支付类型</span>
|
||||||
|
<span class="info-value">
|
||||||
|
<el-tag
|
||||||
|
:type="getPaymentTypeTagType(selectedPurchaseRecord?.payment_type)"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ getPaymentTypeText(selectedPurchaseRecord?.payment_type) }}
|
||||||
|
</el-tag>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">支付渠道</span>
|
||||||
|
<span class="info-value">
|
||||||
|
<el-tag
|
||||||
|
:type="getPayChannelTagType(selectedPurchaseRecord?.pay_channel)"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ getPayChannelText(selectedPurchaseRecord?.pay_channel) }}
|
||||||
|
</el-tag>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">订单状态</span>
|
||||||
|
<span class="info-value">
|
||||||
|
<el-tag :type="getStatusTagType(selectedPurchaseRecord?.status)" size="small">
|
||||||
|
{{ getStatusText(selectedPurchaseRecord?.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" v-if="!singleUserMode">
|
||||||
|
<span class="info-label">企业名称</span>
|
||||||
|
<span class="info-value">{{
|
||||||
|
selectedPurchaseRecord?.company_name || '未知企业'
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 时间信息 -->
|
||||||
|
<div class="info-section">
|
||||||
|
<h4 class="text-lg font-semibold text-gray-900 mb-4">时间信息</h4>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">创建时间</span>
|
||||||
|
<span class="info-value">{{
|
||||||
|
formatDateTime(selectedPurchaseRecord?.created_at)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">支付时间</span>
|
||||||
|
<span class="info-value">{{
|
||||||
|
formatDateTime(selectedPurchaseRecord?.pay_time)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">更新时间</span>
|
||||||
|
<span class="info-value">{{
|
||||||
|
formatDateTime(selectedPurchaseRecord?.updated_at)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex justify-center items-center py-8">
|
||||||
|
<el-loading size="large" />
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
</ListPageLayout>
|
||||||
|
|
||||||
|
<!-- 导出弹窗 -->
|
||||||
|
<ExportDialog
|
||||||
|
v-model="exportDialogVisible"
|
||||||
|
title="导出购买记录"
|
||||||
|
:loading="exportLoading"
|
||||||
|
:show-company-select="true"
|
||||||
|
:show-product-select="true"
|
||||||
|
:show-payment-type-select="true"
|
||||||
|
:show-pay-channel-select="true"
|
||||||
|
:show-status-select="true"
|
||||||
|
:show-date-range="true"
|
||||||
|
@confirm="handleExport"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { financeApi, userApi } from '@/api'
|
||||||
|
import ExportDialog from '@/components/common/ExportDialog.vue'
|
||||||
|
import FilterItem from '@/components/common/FilterItem.vue'
|
||||||
|
import FilterSection from '@/components/common/FilterSection.vue'
|
||||||
|
import ListPageLayout from '@/components/common/ListPageLayout.vue'
|
||||||
|
import { useMobileTable } from '@/composables/useMobileTable'
|
||||||
|
import { Back, Close, Download, User } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
// 移动端检测
|
||||||
|
const { isMobile } = useMobileTable()
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const loading = ref(false)
|
||||||
|
const purchaseRecords = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const detailDialogVisible = ref(false)
|
||||||
|
const selectedPurchaseRecord = ref(null)
|
||||||
|
const dateRange = ref([])
|
||||||
|
|
||||||
|
// 单用户模式
|
||||||
|
const singleUserMode = ref(false)
|
||||||
|
const currentUser = ref(null)
|
||||||
|
|
||||||
|
// 导出相关
|
||||||
|
const exportDialogVisible = ref(false)
|
||||||
|
const exportLoading = ref(false)
|
||||||
|
|
||||||
|
// 筛选条件
|
||||||
|
const filters = reactive({
|
||||||
|
company_name: '',
|
||||||
|
product_name: '',
|
||||||
|
payment_type: '',
|
||||||
|
pay_channel: '',
|
||||||
|
status: '',
|
||||||
|
min_amount: '',
|
||||||
|
max_amount: '',
|
||||||
|
start_time: '',
|
||||||
|
end_time: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(async () => {
|
||||||
|
await checkSingleUserMode()
|
||||||
|
await loadPurchaseRecords()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查单用户模式
|
||||||
|
const checkSingleUserMode = async () => {
|
||||||
|
const userId = route.query.user_id
|
||||||
|
if (userId) {
|
||||||
|
singleUserMode.value = true
|
||||||
|
await loadUserInfo(userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载用户信息
|
||||||
|
const loadUserInfo = async (userId) => {
|
||||||
|
try {
|
||||||
|
const response = await userApi.getUserDetail(userId)
|
||||||
|
currentUser.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载用户信息失败:', error)
|
||||||
|
ElMessage.error('加载用户信息失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载购买记录
|
||||||
|
const loadPurchaseRecords = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: currentPage.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只传递非空的筛选条件
|
||||||
|
if (filters.company_name) {
|
||||||
|
params.company_name = filters.company_name
|
||||||
|
}
|
||||||
|
if (filters.product_name) {
|
||||||
|
params.product_name = filters.product_name
|
||||||
|
}
|
||||||
|
if (filters.payment_type) {
|
||||||
|
params.payment_type = filters.payment_type
|
||||||
|
}
|
||||||
|
if (filters.pay_channel) {
|
||||||
|
params.pay_channel = filters.pay_channel
|
||||||
|
}
|
||||||
|
if (filters.status) {
|
||||||
|
params.status = filters.status
|
||||||
|
}
|
||||||
|
if (filters.min_amount) {
|
||||||
|
params.min_amount = filters.min_amount
|
||||||
|
}
|
||||||
|
if (filters.max_amount) {
|
||||||
|
params.max_amount = filters.max_amount
|
||||||
|
}
|
||||||
|
if (filters.start_time) {
|
||||||
|
params.start_time = filters.start_time
|
||||||
|
}
|
||||||
|
if (filters.end_time) {
|
||||||
|
params.end_time = filters.end_time
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单用户模式添加用户ID筛选
|
||||||
|
if (singleUserMode.value && currentUser.value?.id) {
|
||||||
|
params.user_id = currentUser.value.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await financeApi.getAdminPurchaseRecords(params)
|
||||||
|
purchaseRecords.value = response.data?.items || []
|
||||||
|
total.value = response.data?.total || 0
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载购买记录失败:', error)
|
||||||
|
ElMessage.error('加载购买记录失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化价格
|
||||||
|
const formatPrice = (price) => {
|
||||||
|
if (!price) return '0.00'
|
||||||
|
return Number(price).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (date) => {
|
||||||
|
if (!date) return '-'
|
||||||
|
return new Date(date).toLocaleDateString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (date) => {
|
||||||
|
if (!date) return '-'
|
||||||
|
return new Date(date).toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期时间
|
||||||
|
const formatDateTime = (date) => {
|
||||||
|
if (!date) return '-'
|
||||||
|
return new Date(date).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取支付类型标签样式
|
||||||
|
const getPaymentTypeTagType = (type) => {
|
||||||
|
const typeMap = {
|
||||||
|
alipay: 'primary',
|
||||||
|
wechat: 'success',
|
||||||
|
free: 'info'
|
||||||
|
}
|
||||||
|
return typeMap[type] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取支付类型文本
|
||||||
|
const getPaymentTypeText = (type) => {
|
||||||
|
const typeMap = {
|
||||||
|
alipay: '支付宝',
|
||||||
|
wechat: '微信',
|
||||||
|
free: '免费'
|
||||||
|
}
|
||||||
|
return typeMap[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取支付渠道标签样式
|
||||||
|
const getPayChannelTagType = (channel) => {
|
||||||
|
const channelMap = {
|
||||||
|
alipay: 'primary',
|
||||||
|
wechat: 'success'
|
||||||
|
}
|
||||||
|
return channelMap[channel] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取支付渠道文本
|
||||||
|
const getPayChannelText = (channel) => {
|
||||||
|
const channelMap = {
|
||||||
|
alipay: '支付宝',
|
||||||
|
wechat: '微信'
|
||||||
|
}
|
||||||
|
return channelMap[channel] || channel
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取状态标签样式
|
||||||
|
const getStatusTagType = (status) => {
|
||||||
|
const statusMap = {
|
||||||
|
created: 'info',
|
||||||
|
paid: 'success',
|
||||||
|
failed: 'danger',
|
||||||
|
cancelled: 'warning',
|
||||||
|
refunded: 'info',
|
||||||
|
closed: 'info'
|
||||||
|
}
|
||||||
|
return statusMap[status] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取状态文本
|
||||||
|
const getStatusText = (status) => {
|
||||||
|
const statusMap = {
|
||||||
|
created: '已创建',
|
||||||
|
paid: '已支付',
|
||||||
|
failed: '支付失败',
|
||||||
|
cancelled: '已取消',
|
||||||
|
refunded: '已退款',
|
||||||
|
closed: '已关闭'
|
||||||
|
}
|
||||||
|
return statusMap[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理筛选变化
|
||||||
|
const handleFilterChange = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
loadPurchaseRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理时间范围变化
|
||||||
|
const handleTimeRangeChange = (range) => {
|
||||||
|
if (range && range.length === 2) {
|
||||||
|
filters.start_time = range[0]
|
||||||
|
filters.end_time = range[1]
|
||||||
|
} else {
|
||||||
|
filters.start_time = ''
|
||||||
|
filters.end_time = ''
|
||||||
|
}
|
||||||
|
currentPage.value = 1
|
||||||
|
loadPurchaseRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置筛选
|
||||||
|
const resetFilters = () => {
|
||||||
|
Object.keys(filters).forEach((key) => {
|
||||||
|
filters[key] = ''
|
||||||
|
})
|
||||||
|
dateRange.value = []
|
||||||
|
currentPage.value = 1
|
||||||
|
loadPurchaseRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理分页大小变化
|
||||||
|
const handleSizeChange = (size) => {
|
||||||
|
pageSize.value = size
|
||||||
|
currentPage.value = 1
|
||||||
|
loadPurchaseRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理当前页变化
|
||||||
|
const handleCurrentChange = (page) => {
|
||||||
|
currentPage.value = page
|
||||||
|
loadPurchaseRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退出单用户模式
|
||||||
|
const exitSingleUserMode = () => {
|
||||||
|
singleUserMode.value = false
|
||||||
|
currentUser.value = null
|
||||||
|
router.replace({ name: 'AdminPurchaseRecords' })
|
||||||
|
loadPurchaseRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回用户管理
|
||||||
|
const goBackToUsers = () => {
|
||||||
|
const query = { user_id: currentUser.value?.id }
|
||||||
|
|
||||||
|
// 如果当前用户有企业名称,添加到查询参数
|
||||||
|
if (currentUser.value?.enterprise_info?.company_name) {
|
||||||
|
query.company_name = currentUser.value.enterprise_info.company_name
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果当前用户有手机号,添加到查询参数
|
||||||
|
if (currentUser.value?.phone) {
|
||||||
|
query.phone = currentUser.value.phone
|
||||||
|
}
|
||||||
|
console.log('query', query)
|
||||||
|
router.push({
|
||||||
|
name: 'AdminUsers',
|
||||||
|
query
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看详情
|
||||||
|
const handleViewDetail = (purchaseRecord) => {
|
||||||
|
selectedPurchaseRecord.value = purchaseRecord
|
||||||
|
detailDialogVisible.value = true
|
||||||
|
console.log('detailDialogVisible', detailDialogVisible.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听路由变化
|
||||||
|
watch(
|
||||||
|
() => route.query.user_id,
|
||||||
|
async (newUserId) => {
|
||||||
|
if (newUserId) {
|
||||||
|
singleUserMode.value = true
|
||||||
|
await loadUserInfo(newUserId)
|
||||||
|
} else {
|
||||||
|
singleUserMode.value = false
|
||||||
|
currentUser.value = null
|
||||||
|
}
|
||||||
|
await loadPurchaseRecords()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 导出相关方法
|
||||||
|
const showExportDialog = () => {
|
||||||
|
exportDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = async (options) => {
|
||||||
|
try {
|
||||||
|
exportLoading.value = true
|
||||||
|
|
||||||
|
// 构建导出参数
|
||||||
|
const params = {
|
||||||
|
format: options.format
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加企业筛选
|
||||||
|
if (options.companyIds.length > 0) {
|
||||||
|
params.user_ids = options.companyIds.join(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加产品筛选
|
||||||
|
if (options.productIds.length > 0) {
|
||||||
|
params.product_ids = options.productIds.join(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加支付类型筛选
|
||||||
|
if (options.paymentType) {
|
||||||
|
params.payment_type = options.paymentType
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加支付渠道筛选
|
||||||
|
if (options.payChannel) {
|
||||||
|
params.pay_channel = options.payChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加状态筛选
|
||||||
|
if (options.status) {
|
||||||
|
params.status = options.status
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加时间范围筛选
|
||||||
|
if (options.dateRange && options.dateRange.length === 2) {
|
||||||
|
params.start_time = options.dateRange[0]
|
||||||
|
params.end_time = options.dateRange[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用导出API(需要在后端添加相应的API)
|
||||||
|
const response = await financeApi.exportAdminPurchaseRecords(params)
|
||||||
|
|
||||||
|
// 创建下载链接(二进制在 response.data,见 request 响应拦截器包装)
|
||||||
|
const blob = new Blob([response.data], {
|
||||||
|
type: options.format === 'excel'
|
||||||
|
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
: 'text/csv;charset=utf-8'
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `购买记录.${options.format === 'excel' ? 'xlsx' : 'csv'}`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
|
||||||
|
ElMessage.success('导出成功')
|
||||||
|
exportDialogVisible.value = false
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导出失败:', error)
|
||||||
|
ElMessage.error('导出失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
exportLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.info-section {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #111827;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase-detail-dialog :deep(.el-dialog) {
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase-detail-dialog :deep(.el-dialog__header) {
|
||||||
|
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||||
|
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase-detail-dialog :deep(.el-dialog__title) {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase-detail-dialog :deep(.el-dialog__body) {
|
||||||
|
padding: 24px;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格样式优化 */
|
||||||
|
:deep(.el-table) {
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table th) {
|
||||||
|
background: #f8fafc !important;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table td) {
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table tr:hover > td) {
|
||||||
|
background: #f8fafc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.info-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<template #stats v-if="singleUserMode">
|
<template #stats v-if="singleUserMode">
|
||||||
<div class="flex items-center gap-2 text-sm text-gray-600">
|
<div class="flex items-center gap-2 text-sm text-gray-600">
|
||||||
<User class="w-4 h-4" />
|
<User class="w-4 h-4" />
|
||||||
<span>当前用户:{{ currentUser?.enterprise_info?.company_name || currentUser?.phone }}</span>
|
<span>当前用户:{{ currentUser?.company_name || currentUser?.phone }}</span>
|
||||||
<span class="text-gray-400">(仅显示当前用户)</span>
|
<span class="text-gray-400">(仅显示当前用户)</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -701,7 +701,7 @@ const handleExport = async (options) => {
|
|||||||
const response = await rechargeRecordApi.exportAdminRechargeRecords(params)
|
const response = await rechargeRecordApi.exportAdminRechargeRecords(params)
|
||||||
|
|
||||||
// 创建下载链接
|
// 创建下载链接
|
||||||
const blob = new Blob([response], {
|
const blob = new Blob([response.data], {
|
||||||
type: options.format === 'excel'
|
type: options.format === 'excel'
|
||||||
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
: 'text/csv;charset=utf-8'
|
: 'text/csv;charset=utf-8'
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="list-page-card">
|
<div class="list-page-card">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="text-sm text-gray-500">统计视图切换</div>
|
||||||
|
<el-segmented
|
||||||
|
v-model="viewMode"
|
||||||
|
:options="[
|
||||||
|
{ label: '统计仪表盘', value: 'dashboard' },
|
||||||
|
{ label: '请求流可视化', value: 'request-globe' }
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<RequestFlowGlobe v-if="viewMode === 'request-globe'" />
|
||||||
|
<template v-else>
|
||||||
<!-- 加载状态 -->
|
<!-- 加载状态 -->
|
||||||
<div v-if="loading" class="text-center py-8">
|
<div v-if="loading" class="text-center py-8">
|
||||||
<el-icon size="32" class="animate-spin">
|
<el-icon size="32" class="animate-spin">
|
||||||
@@ -466,6 +478,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -480,6 +493,7 @@ import {
|
|||||||
adminGetUserCallRanking,
|
adminGetUserCallRanking,
|
||||||
adminGetUserDomainStatistics
|
adminGetUserDomainStatistics
|
||||||
} from '@/api/statistics'
|
} from '@/api/statistics'
|
||||||
|
import RequestFlowGlobe from '@/pages/admin/statistics/components/RequestFlowGlobe.vue'
|
||||||
import DanmakuBar from '@/components/common/DanmakuBar.vue'
|
import DanmakuBar from '@/components/common/DanmakuBar.vue'
|
||||||
import { Check, Loading, Money, Refresh, TrendCharts, User } from '@element-plus/icons-vue'
|
import { Check, Loading, Money, Refresh, TrendCharts, User } from '@element-plus/icons-vue'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
@@ -488,6 +502,7 @@ import { nextTick, onMounted, onUnmounted, ref } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const viewMode = ref('dashboard')
|
||||||
// 响应式数据
|
// 响应式数据
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|||||||
337
src/pages/admin/statistics/components/RequestFlowGlobe.vue
Normal file
337
src/pages/admin/statistics/components/RequestFlowGlobe.vue
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
<template>
|
||||||
|
<div class="request-flow-page">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="datetimerange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始时间"
|
||||||
|
end-placeholder="结束时间"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
class="toolbar-item"
|
||||||
|
/>
|
||||||
|
<el-input-number v-model="topN" :min="10" :max="1000" :step="10" class="toolbar-item" />
|
||||||
|
<el-button type="primary" :loading="loading" @click="loadData">刷新请求流</el-button>
|
||||||
|
<el-button v-if="selectedIP || selectedPath" @click="clearFilter">清空筛选</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div ref="chartRef" class="chart"></div>
|
||||||
|
<div class="side-list">
|
||||||
|
<h4>TOP 可疑来源</h4>
|
||||||
|
<div v-if="rows.length === 0" class="empty">暂无数据</div>
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in rows.slice(0, 20)"
|
||||||
|
:key="`${item.ip}-${item.path}-${index}`"
|
||||||
|
class="row clickable"
|
||||||
|
@click="selectFlow(item)"
|
||||||
|
>
|
||||||
|
<div class="name">{{ item.from_name }} -> {{ item.path }}</div>
|
||||||
|
<div class="value">{{ item.value }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-card">
|
||||||
|
<div class="table-title">
|
||||||
|
可疑IP明细
|
||||||
|
<span v-if="selectedIP || selectedPath" class="hint">
|
||||||
|
(筛选:{{ selectedIP || '-' }} / {{ selectedPath || '-' }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<el-table :data="listRows" border size="small" height="360">
|
||||||
|
<el-table-column prop="ip" label="IP" width="150" />
|
||||||
|
<el-table-column prop="path" label="接口路径" min-width="240" />
|
||||||
|
<el-table-column prop="method" label="方法" width="90" />
|
||||||
|
<el-table-column prop="request_count" label="次数" width="90" />
|
||||||
|
<el-table-column prop="trigger_reason" label="触发原因" width="160" />
|
||||||
|
<el-table-column prop="created_at" label="时间" width="180" />
|
||||||
|
</el-table>
|
||||||
|
<div class="pager">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="page"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@current-change="loadList"
|
||||||
|
@size-change="handlePageSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { adminGetSuspiciousIPGeoStream, adminGetSuspiciousIPList } from '@/api/statistics'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const topN = ref(200)
|
||||||
|
const dateRange = ref([])
|
||||||
|
const rows = ref([])
|
||||||
|
const listRows = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const selectedIP = ref('')
|
||||||
|
const selectedPath = ref('')
|
||||||
|
const chartRef = ref(null)
|
||||||
|
let chart = null
|
||||||
|
|
||||||
|
const getDefaultRange = () => {
|
||||||
|
const now = new Date()
|
||||||
|
const start = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||||
|
const format = (d) => {
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = `${d.getMonth() + 1}`.padStart(2, '0')
|
||||||
|
const day = `${d.getDate()}`.padStart(2, '0')
|
||||||
|
const h = `${d.getHours()}`.padStart(2, '0')
|
||||||
|
const mi = `${d.getMinutes()}`.padStart(2, '0')
|
||||||
|
const s = `${d.getSeconds()}`.padStart(2, '0')
|
||||||
|
return `${y}-${m}-${day} ${h}:${mi}:${s}`
|
||||||
|
}
|
||||||
|
return [format(start), format(now)]
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderChart = () => {
|
||||||
|
if (!chartRef.value) return
|
||||||
|
if (!chart) chart = echarts.init(chartRef.value)
|
||||||
|
|
||||||
|
const lineData = rows.value.map(item => ({
|
||||||
|
coords: [
|
||||||
|
[item.from_lng, item.from_lat],
|
||||||
|
[item.to_lng, item.to_lat]
|
||||||
|
],
|
||||||
|
value: item.value
|
||||||
|
}))
|
||||||
|
|
||||||
|
const pointData = rows.value.map(item => ({
|
||||||
|
name: item.from_name,
|
||||||
|
value: [item.from_lng, item.from_lat, item.value]
|
||||||
|
}))
|
||||||
|
|
||||||
|
chart.setOption({
|
||||||
|
backgroundColor: '#040b1b',
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item'
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'value',
|
||||||
|
min: -180,
|
||||||
|
max: 180,
|
||||||
|
axisLabel: { color: '#6b7280' },
|
||||||
|
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.08)' } }
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
min: -90,
|
||||||
|
max: 90,
|
||||||
|
axisLabel: { color: '#6b7280' },
|
||||||
|
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.08)' } }
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: '请求流',
|
||||||
|
type: 'lines',
|
||||||
|
coordinateSystem: 'cartesian2d',
|
||||||
|
zlevel: 2,
|
||||||
|
effect: {
|
||||||
|
show: true,
|
||||||
|
period: 4,
|
||||||
|
symbol: 'arrow',
|
||||||
|
symbolSize: 6
|
||||||
|
},
|
||||||
|
lineStyle: {
|
||||||
|
width: 1,
|
||||||
|
color: '#4cc9f0',
|
||||||
|
curveness: 0.2
|
||||||
|
},
|
||||||
|
data: lineData
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '来源点',
|
||||||
|
type: 'scatter',
|
||||||
|
coordinateSystem: 'cartesian2d',
|
||||||
|
symbolSize: (val) => Math.max(6, Math.min(18, (val[2] || 1) / 2)),
|
||||||
|
itemStyle: {
|
||||||
|
color: '#f72585'
|
||||||
|
},
|
||||||
|
data: pointData
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
chart.off('click')
|
||||||
|
chart.on('click', (params) => {
|
||||||
|
if (params.seriesName === '来源点' && params.dataIndex >= 0) {
|
||||||
|
const item = rows.value[params.dataIndex]
|
||||||
|
if (item) selectFlow(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectFlow = (item) => {
|
||||||
|
selectedIP.value = item.ip || ''
|
||||||
|
selectedPath.value = item.path || ''
|
||||||
|
page.value = 1
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearFilter = () => {
|
||||||
|
selectedIP.value = ''
|
||||||
|
selectedPath.value = ''
|
||||||
|
page.value = 1
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadList = async () => {
|
||||||
|
try {
|
||||||
|
if (!dateRange.value || dateRange.value.length !== 2) {
|
||||||
|
dateRange.value = getDefaultRange()
|
||||||
|
}
|
||||||
|
const res = await adminGetSuspiciousIPList({
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize.value,
|
||||||
|
start_time: dateRange.value[0],
|
||||||
|
end_time: dateRange.value[1],
|
||||||
|
ip: selectedIP.value || undefined,
|
||||||
|
path: selectedPath.value || undefined
|
||||||
|
})
|
||||||
|
listRows.value = res.data?.items || []
|
||||||
|
total.value = res.data?.total || 0
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载可疑IP明细失败:', error)
|
||||||
|
ElMessage.error('加载可疑IP明细失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageSizeChange = (size) => {
|
||||||
|
pageSize.value = size
|
||||||
|
page.value = 1
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
if (!dateRange.value || dateRange.value.length !== 2) {
|
||||||
|
dateRange.value = getDefaultRange()
|
||||||
|
}
|
||||||
|
const res = await adminGetSuspiciousIPGeoStream({
|
||||||
|
start_time: dateRange.value[0],
|
||||||
|
end_time: dateRange.value[1],
|
||||||
|
top_n: topN.value
|
||||||
|
})
|
||||||
|
rows.value = res.data || []
|
||||||
|
await nextTick()
|
||||||
|
renderChart()
|
||||||
|
loadList()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载请求流失败:', error)
|
||||||
|
ElMessage.error('加载请求流失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
dateRange.value = getDefaultRange()
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (chart) {
|
||||||
|
chart.dispose()
|
||||||
|
chart = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.request-flow-page {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.toolbar-item {
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 320px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.chart {
|
||||||
|
height: 620px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.side-list {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
max-height: 620px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.clickable:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
color: #374151;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.value {
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
.table-card {
|
||||||
|
margin-top: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.table-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.pager {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -9,24 +9,22 @@
|
|||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<el-icon class="user-icon"><user /></el-icon>
|
<el-icon class="user-icon"><user /></el-icon>
|
||||||
<div class="user-details">
|
<div class="user-details">
|
||||||
<div class="company-name">{{ currentUser?.enterprise_info?.company_name || '未知公司' }}</div>
|
<div class="company-name">{{ currentUser?.company_name || '未知公司' }}</div>
|
||||||
<div class="user-phone">{{ currentUser?.phone || '-' }}</div>
|
<div class="user-phone">{{ currentUser?.phone || '-' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div :class="['user-actions', isMobile ? 'w-full flex-wrap' : '']">
|
<div :class="['user-actions', isMobile ? 'w-full flex-wrap' : '']">
|
||||||
<el-button :size="isMobile ? 'small' : 'small'" @click="showBatchPriceDialog" type="warning">
|
<el-button :size="isMobile ? 'small' : 'small'" @click="showBatchPriceDialog" type="warning">
|
||||||
<el-icon><edit /></el-icon>
|
<el-icon><edit /></el-icon>
|
||||||
<span :class="isMobile ? 'hidden sm:inline' : ''">一键改价</span>
|
<span>一键改价</span>
|
||||||
<span :class="isMobile ? 'sm:hidden' : 'hidden'">改价</span>
|
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button :size="isMobile ? 'small' : 'small'" @click="exitSingleUserMode" type="info">
|
<el-button :size="isMobile ? 'small' : 'small'" @click="exitSingleUserMode" type="info">
|
||||||
<el-icon><close /></el-icon>
|
<el-icon><close /></el-icon>
|
||||||
<span :class="isMobile ? 'hidden sm:inline' : ''">取消</span>
|
<span>取消</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button :size="isMobile ? 'small' : 'small'" @click="goBackToUsers" type="primary">
|
<el-button :size="isMobile ? 'small' : 'small'" @click="goBackToUsers" type="primary">
|
||||||
<el-icon><back /></el-icon>
|
<el-icon><back /></el-icon>
|
||||||
<span :class="isMobile ? 'hidden sm:inline' : ''">返回用户管理</span>
|
<span>返回用户管理</span>
|
||||||
<span :class="isMobile ? 'sm:hidden' : 'hidden'">返回</span>
|
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,6 +137,10 @@
|
|||||||
<span class="card-label">成本价</span>
|
<span class="card-label">成本价</span>
|
||||||
<span class="card-value text-gray-600">¥{{ formatPrice(subscription.product_admin?.cost_price) }}</span>
|
<span class="card-value text-gray-600">¥{{ formatPrice(subscription.product_admin?.cost_price) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="subscription.product?.is_package || subscription.product_admin?.is_package" class="card-row">
|
||||||
|
<span class="card-label">UI组件价格</span>
|
||||||
|
<span class="card-value text-purple-600 font-semibold">¥{{ formatPrice(subscription.ui_component_price) }}</span>
|
||||||
|
</div>
|
||||||
<div class="card-row">
|
<div class="card-row">
|
||||||
<span class="card-label">订阅时间</span>
|
<span class="card-label">订阅时间</span>
|
||||||
<span class="card-value text-sm">{{ formatDate(subscription.created_at) }} {{ formatTime(subscription.created_at) }}</span>
|
<span class="card-value text-sm">{{ formatDate(subscription.created_at) }} {{ formatTime(subscription.created_at) }}</span>
|
||||||
@@ -226,6 +228,13 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column v-if="hasAnyPackageProducts" label="UI组件价格" width="130">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.product?.is_package || row.product_admin?.is_package" class="font-medium text-purple-600">¥{{ formatPrice(row.ui_component_price) }}</span>
|
||||||
|
<span v-else class="text-gray-400">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column prop="created_at" label="订阅时间" width="160">
|
<el-table-column prop="created_at" label="订阅时间" width="160">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
@@ -309,6 +318,14 @@
|
|||||||
</span>
|
</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="selectedSubscription?.product?.is_package || selectedSubscription?.product_admin?.is_package" label="UI组件价格">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-lg font-semibold text-purple-600">
|
||||||
|
¥{{ formatPrice(selectedSubscription?.ui_component_price) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="当前价格">
|
<el-form-item label="当前价格">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<span class="text-lg font-semibold text-red-600">
|
<span class="text-lg font-semibold text-red-600">
|
||||||
@@ -364,6 +381,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="(selectedSubscription?.product?.is_package || selectedSubscription?.product_admin?.is_package) && priceForm.adjustmentType === 'price'" label="UI组件价格" prop="ui_component_price">
|
||||||
|
<el-input-number
|
||||||
|
v-model="priceForm.ui_component_price"
|
||||||
|
:precision="2"
|
||||||
|
:min="0"
|
||||||
|
:step="0.01"
|
||||||
|
placeholder="请输入UI组件价格"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<div class="text-sm text-gray-500 mt-1">
|
||||||
|
<div class="text-purple-600">
|
||||||
|
组合包UI组件的购买报告价格
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item v-if="priceForm.adjustmentType === 'discount'" label="折扣比例" prop="discount">
|
<el-form-item v-if="priceForm.adjustmentType === 'discount'" label="折扣比例" prop="discount">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
@@ -585,6 +618,7 @@ const updatingPrice = ref(false)
|
|||||||
const priceFormRef = ref(null)
|
const priceFormRef = ref(null)
|
||||||
const priceForm = reactive({
|
const priceForm = reactive({
|
||||||
price: 0,
|
price: 0,
|
||||||
|
ui_component_price: 0,
|
||||||
discount: 10,
|
discount: 10,
|
||||||
cost_multiple: 1.0,
|
cost_multiple: 1.0,
|
||||||
adjustmentType: 'price' // 'price'、'discount' 或 'cost_multiple'
|
adjustmentType: 'price' // 'price'、'discount' 或 'cost_multiple'
|
||||||
@@ -606,6 +640,10 @@ const priceRules = {
|
|||||||
{ required: true, message: '请输入新价格', trigger: 'blur' },
|
{ required: true, message: '请输入新价格', trigger: 'blur' },
|
||||||
{ type: 'number', min: 0, message: '价格不能小于0', trigger: 'blur' }
|
{ type: 'number', min: 0, message: '价格不能小于0', trigger: 'blur' }
|
||||||
],
|
],
|
||||||
|
ui_component_price: [
|
||||||
|
{ required: false, message: '请输入UI组件价格', trigger: 'blur' },
|
||||||
|
{ type: 'number', min: 0, message: 'UI组件价格不能小于0', trigger: 'blur' }
|
||||||
|
],
|
||||||
discount: [
|
discount: [
|
||||||
{ required: true, message: '请输入折扣比例', trigger: 'blur' },
|
{ required: true, message: '请输入折扣比例', trigger: 'blur' },
|
||||||
{ type: 'number', min: 0.1, max: 10, message: '折扣比例必须在0.1-10之间', trigger: 'blur' }
|
{ type: 'number', min: 0.1, max: 10, message: '折扣比例必须在0.1-10之间', trigger: 'blur' }
|
||||||
@@ -671,6 +709,11 @@ const hasValidCostPrice = computed(() => {
|
|||||||
return costPrice !== undefined && costPrice !== null && costPrice > 0
|
return costPrice !== undefined && costPrice !== null && costPrice > 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 计算属性:检查是否有任何组合包产品
|
||||||
|
const hasAnyPackageProducts = computed(() => {
|
||||||
|
return subscriptions.value.some(sub => sub.product?.is_package || sub.product_admin?.is_package)
|
||||||
|
})
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await checkSingleUserMode()
|
await checkSingleUserMode()
|
||||||
@@ -907,6 +950,14 @@ const handleCurrentChange = (page) => {
|
|||||||
const handleEditPrice = (subscription) => {
|
const handleEditPrice = (subscription) => {
|
||||||
selectedSubscription.value = subscription
|
selectedSubscription.value = subscription
|
||||||
priceForm.price = subscription.price
|
priceForm.price = subscription.price
|
||||||
|
|
||||||
|
// 获取UI组件价格,如果订阅中没有设置,则从产品中获取
|
||||||
|
let uiComponentPrice = subscription.ui_component_price || 0
|
||||||
|
if (uiComponentPrice === 0 && (subscription.product?.is_package || subscription.product_admin?.is_package)) {
|
||||||
|
uiComponentPrice = subscription.product?.ui_component_price || subscription.product_admin?.ui_component_price || 0
|
||||||
|
}
|
||||||
|
priceForm.ui_component_price = uiComponentPrice
|
||||||
|
|
||||||
const productPrice = subscription.product?.price || subscription.product_admin?.price
|
const productPrice = subscription.product?.price || subscription.product_admin?.price
|
||||||
priceForm.discount = calculateDiscount(productPrice, subscription.price)
|
priceForm.discount = calculateDiscount(productPrice, subscription.price)
|
||||||
if (subscription.product_admin?.cost_price) {
|
if (subscription.product_admin?.cost_price) {
|
||||||
@@ -1014,9 +1065,20 @@ const handleUpdatePrice = async () => {
|
|||||||
priceForm.price = finalPrice
|
priceForm.price = finalPrice
|
||||||
}
|
}
|
||||||
|
|
||||||
await productAdminApi.updateSubscriptionPrice(selectedSubscription.value.id, {
|
// 构建请求数据
|
||||||
|
const requestData = {
|
||||||
price: finalPrice
|
price: finalPrice
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// 如果是组合包,包含UI组件价格
|
||||||
|
if (selectedSubscription.value.product?.is_package || selectedSubscription.value.product_admin?.is_package) {
|
||||||
|
// 只有在直接输入价格模式下才使用表单中的UI组件价格
|
||||||
|
if (priceForm.adjustmentType === 'price') {
|
||||||
|
requestData.ui_component_price = priceForm.ui_component_price
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await productAdminApi.updateSubscriptionPrice(selectedSubscription.value.id, requestData)
|
||||||
|
|
||||||
ElMessage.success('价格调整成功')
|
ElMessage.success('价格调整成功')
|
||||||
priceDialogVisible.value = false
|
priceDialogVisible.value = false
|
||||||
|
|||||||
@@ -575,7 +575,7 @@ const handleExport = async (options) => {
|
|||||||
const response = await walletTransactionApi.exportAdminWalletTransactions(params)
|
const response = await walletTransactionApi.exportAdminWalletTransactions(params)
|
||||||
|
|
||||||
// 创建下载链接
|
// 创建下载链接
|
||||||
const blob = new Blob([response], {
|
const blob = new Blob([response.data], {
|
||||||
type: options.format === 'excel'
|
type: options.format === 'excel'
|
||||||
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
: 'text/csv;charset=utf-8'
|
: 'text/csv;charset=utf-8'
|
||||||
|
|||||||
@@ -1,126 +1,328 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="ui-components-page">
|
<ListPageLayout
|
||||||
<el-card class="filter-card">
|
title="UI组件管理"
|
||||||
<el-form :model="filterForm" inline>
|
subtitle="管理系统中的UI组件和文件资源"
|
||||||
<el-form-item label="关键词">
|
>
|
||||||
<el-input
|
<!-- 筛选区域 -->
|
||||||
v-model="filterForm.keyword"
|
<template #filters>
|
||||||
placeholder="请输入关键词搜索"
|
<FilterSection>
|
||||||
clearable
|
<div :class="['grid gap-4', isMobile ? 'grid-cols-1' : 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4']">
|
||||||
@keyup.enter="handleSearch"
|
<FilterItem label="关键词">
|
||||||
/>
|
<el-input
|
||||||
</el-form-item>
|
v-model="filterForm.keyword"
|
||||||
<el-form-item label="状态">
|
placeholder="请输入关键词搜索"
|
||||||
<el-select v-model="filterForm.is_active" placeholder="请选择状态" clearable>
|
clearable
|
||||||
<el-option label="启用" :value="true" />
|
@input="handleFilterChange"
|
||||||
<el-option label="禁用" :value="false" />
|
class="w-full"
|
||||||
</el-select>
|
/>
|
||||||
</el-form-item>
|
</FilterItem>
|
||||||
<el-form-item>
|
|
||||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
|
||||||
<el-button @click="handleReset">重置</el-button>
|
|
||||||
<el-button type="success" @click="handleCreate">新增UI组件</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card class="table-card">
|
<FilterItem label="状态">
|
||||||
<el-table
|
<el-select
|
||||||
v-loading="loading"
|
v-model="filterForm.is_active"
|
||||||
:data="componentList"
|
placeholder="请选择状态"
|
||||||
stripe
|
clearable
|
||||||
border
|
@change="handleFilterChange"
|
||||||
>
|
class="w-full"
|
||||||
<el-table-column prop="component_code" label="组件编码" width="150" />
|
|
||||||
<el-table-column prop="component_name" label="组件名称" width="200" />
|
|
||||||
<el-table-column prop="description" label="描述" show-overflow-tooltip />
|
|
||||||
<el-table-column prop="version" label="版本" width="100" />
|
|
||||||
<el-table-column label="文件状态" width="120">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag v-if="row.is_extracted" type="success">已解压</el-tag>
|
|
||||||
<el-tag v-else-if="row.file_path" type="warning">已上传</el-tag>
|
|
||||||
<el-tag v-else type="info">未上传</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="文件大小" width="120">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<span v-if="row.file_size">{{ formatFileSize(row.file_size) }}</span>
|
|
||||||
<span v-else>-</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="状态" width="100">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tag :type="row.is_active ? 'success' : 'danger'">
|
|
||||||
{{ row.is_active ? '启用' : '禁用' }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="sort_order" label="排序" width="80" />
|
|
||||||
<el-table-column prop="created_at" label="创建时间" width="180">
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ formatDateTime(row.created_at) }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" width="320" fixed="right">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
|
|
||||||
<el-button
|
|
||||||
v-if="!row.file_path"
|
|
||||||
size="small"
|
|
||||||
type="primary"
|
|
||||||
@click="handleUpload(row)"
|
|
||||||
>
|
>
|
||||||
上传文件
|
<el-option label="启用" :value="true" />
|
||||||
</el-button>
|
<el-option label="禁用" :value="false" />
|
||||||
<el-button
|
</el-select>
|
||||||
v-if="row.file_path && !row.is_extracted"
|
</FilterItem>
|
||||||
size="small"
|
</div>
|
||||||
type="warning"
|
|
||||||
@click="handleUploadExtract(row)"
|
|
||||||
>
|
|
||||||
上传并解压
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
|
||||||
v-if="row.is_extracted"
|
|
||||||
size="small"
|
|
||||||
type="success"
|
|
||||||
@click="handleViewFolder(row)"
|
|
||||||
>
|
|
||||||
查看文件夹
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
|
||||||
v-if="row.file_path"
|
|
||||||
size="small"
|
|
||||||
type="info"
|
|
||||||
@click="handleDownload(row)"
|
|
||||||
>
|
|
||||||
下载文件
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
|
||||||
v-if="row.is_extracted"
|
|
||||||
size="small"
|
|
||||||
type="danger"
|
|
||||||
@click="handleDeleteFolder(row)"
|
|
||||||
>
|
|
||||||
删除文件夹
|
|
||||||
</el-button>
|
|
||||||
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<div class="pagination-container">
|
<template #stats>
|
||||||
<el-pagination
|
共找到 {{ pagination.total }} 个UI组件
|
||||||
v-model:current-page="pagination.page"
|
</template>
|
||||||
v-model:page-size="pagination.pageSize"
|
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
<template #buttons>
|
||||||
:total="pagination.total"
|
<div :class="['flex gap-2', isMobile ? 'flex-wrap w-full' : '']">
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
<el-button :size="isMobile ? 'small' : 'default'" @click="handleReset" :class="isMobile ? 'flex-1' : ''">
|
||||||
@size-change="handleSizeChange"
|
重置筛选
|
||||||
@current-change="handleCurrentChange"
|
</el-button>
|
||||||
/>
|
<el-button :size="isMobile ? 'small' : 'default'" type="primary" @click="handleSearch" :class="isMobile ? 'flex-1' : ''">
|
||||||
|
应用筛选
|
||||||
|
</el-button>
|
||||||
|
<el-button :size="isMobile ? 'small' : 'default'" type="success" @click="handleCreate" :class="isMobile ? 'w-full' : ''">
|
||||||
|
<Plus class="w-4 h-4 mr-1" />
|
||||||
|
<span :class="isMobile ? 'hidden sm:inline' : ''">新增UI组件</span>
|
||||||
|
<span :class="isMobile ? 'sm:hidden' : 'hidden'">新增</span>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FilterSection>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 表格区域 -->
|
||||||
|
<template #table>
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="loading" class="flex justify-center items-center py-12">
|
||||||
|
<el-loading size="large" />
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
|
||||||
|
<!-- 移动端卡片布局 -->
|
||||||
|
<div v-else-if="isMobile && componentList.length > 0" class="component-cards">
|
||||||
|
<div
|
||||||
|
v-for="component in componentList"
|
||||||
|
:key="component.id"
|
||||||
|
class="component-card"
|
||||||
|
>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="font-semibold text-base text-blue-600">{{ component.component_name || '未知组件' }}</span>
|
||||||
|
<el-tag
|
||||||
|
:type="component.is_active ? 'success' : 'danger'"
|
||||||
|
size="small"
|
||||||
|
effect="light"
|
||||||
|
>
|
||||||
|
{{ component.is_active ? '启用' : '禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 font-mono">编码: {{ component.component_code }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-row">
|
||||||
|
<span class="card-label">描述</span>
|
||||||
|
<span class="card-value">{{ component.description || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-row">
|
||||||
|
<span class="card-label">版本</span>
|
||||||
|
<span class="card-value">{{ component.version || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-row">
|
||||||
|
<span class="card-label">文件状态</span>
|
||||||
|
<span class="card-value">
|
||||||
|
<el-tag v-if="component.is_extracted" type="success" size="small">已解压</el-tag>
|
||||||
|
<el-tag v-else-if="component.file_path" type="warning" size="small">已上传</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small">未上传</el-tag>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-row">
|
||||||
|
<span class="card-label">文件大小</span>
|
||||||
|
<span class="card-value">{{ component.file_size ? formatFileSize(component.file_size) : '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-row">
|
||||||
|
<span class="card-label">排序</span>
|
||||||
|
<span class="card-value">{{ component.sort_order || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-row">
|
||||||
|
<span class="card-label">创建时间</span>
|
||||||
|
<span class="card-value text-sm">{{ formatDateTime(component.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
@click="handleEdit(component)"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="!component.file_path"
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
@click="handleUpload(component)"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
上传文件
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="component.file_path && !component.is_extracted && isZipFileFromPath(component.file_path)"
|
||||||
|
size="small"
|
||||||
|
type="warning"
|
||||||
|
@click="handleUploadExtract(component)"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
上传并解压
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="component.is_extracted"
|
||||||
|
size="small"
|
||||||
|
type="success"
|
||||||
|
@click="handleViewFolder(component)"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
查看文件夹
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="component.file_path"
|
||||||
|
size="small"
|
||||||
|
type="info"
|
||||||
|
@click="handleDownload(component)"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
下载文件
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="component.is_extracted"
|
||||||
|
size="small"
|
||||||
|
type="danger"
|
||||||
|
@click="handleDeleteFolder(component)"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
删除文件夹
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="danger"
|
||||||
|
@click="handleDelete(component)"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 桌面端表格布局 -->
|
||||||
|
<div v-else-if="!isMobile" class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div class="table-container">
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="componentList"
|
||||||
|
style="width: 100%"
|
||||||
|
:header-cell-style="{
|
||||||
|
background: '#f8fafc',
|
||||||
|
color: '#475569',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '14px'
|
||||||
|
}"
|
||||||
|
:cell-style="{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#1e293b'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<el-table-column prop="component_code" label="组件编码" width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="font-mono text-sm text-gray-600">{{ row.component_code }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="component_name" label="组件名称" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="font-medium text-blue-600">{{ row.component_name || '未知组件' }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
|
||||||
|
|
||||||
|
<el-table-column prop="version" label="版本" width="100" />
|
||||||
|
|
||||||
|
<el-table-column label="文件状态" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.is_extracted" type="success" size="small">已解压</el-tag>
|
||||||
|
<el-tag v-else-if="row.file_path" type="warning" size="small">已上传</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small">未上传</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="文件大小" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.file_size">{{ formatFileSize(row.file_size) }}</span>
|
||||||
|
<span v-else class="text-gray-400">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_active ? 'success' : 'danger'" size="small">
|
||||||
|
{{ row.is_active ? '启用' : '禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="sort_order" label="排序" width="80" />
|
||||||
|
|
||||||
|
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="text-sm">
|
||||||
|
<div class="text-gray-900">{{ formatDate(row.created_at) }}</div>
|
||||||
|
<div class="text-gray-500">{{ formatTime(row.created_at) }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="操作" width="340" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="!row.file_path"
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
@click="handleUpload(row)"
|
||||||
|
>
|
||||||
|
上传文件
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="row.file_path && !row.is_extracted && isZipFileFromPath(row.file_path)"
|
||||||
|
size="small"
|
||||||
|
type="warning"
|
||||||
|
@click="handleUploadExtract(row)"
|
||||||
|
>
|
||||||
|
上传并解压
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="row.is_extracted"
|
||||||
|
size="small"
|
||||||
|
type="success"
|
||||||
|
@click="handleViewFolder(row)"
|
||||||
|
>
|
||||||
|
查看文件夹
|
||||||
|
</el-button>
|
||||||
|
<el-dropdown v-if="row.file_path || row.is_extracted">
|
||||||
|
<el-button size="small" type="info">
|
||||||
|
更多<el-icon class="el-icon--right"><arrow-down /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item v-if="row.file_path" @click="handleDownload(row)">
|
||||||
|
下载文件
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item v-if="row.is_extracted" @click="handleDeleteFolder(row)">
|
||||||
|
删除文件夹
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item @click="handleDelete(row)" divided>
|
||||||
|
删除组件
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
<el-button v-else size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-if="!loading && componentList.length === 0" class="text-center py-12">
|
||||||
|
<el-empty description="暂无UI组件" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<template #pagination>
|
||||||
|
<el-pagination
|
||||||
|
v-if="pagination.total > 0"
|
||||||
|
v-model:current-page="pagination.page"
|
||||||
|
v-model:page-size="pagination.pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="pagination.total"
|
||||||
|
:layout="isMobile ? 'prev, pager, next' : 'total, sizes, prev, pager, next, jumper'"
|
||||||
|
:small="isMobile"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ListPageLayout>
|
||||||
|
|
||||||
<!-- 创建/编辑对话框 -->
|
<!-- 创建/编辑对话框 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
@@ -162,6 +364,76 @@
|
|||||||
<el-form-item label="排序">
|
<el-form-item label="排序">
|
||||||
<el-input-number v-model="form.sort_order" :min="0" />
|
<el-input-number v-model="form.sort_order" :min="0" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<!-- 编辑时显示当前文件状态 -->
|
||||||
|
<el-form-item label="当前文件状态" v-if="isEdit && currentComponent">
|
||||||
|
<div class="file-status-display">
|
||||||
|
<el-tag v-if="currentComponent.is_extracted" type="success" size="small">已解压</el-tag>
|
||||||
|
<el-tag v-else-if="currentComponent.file_path" type="warning" size="small">已上传</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small">未上传</el-tag>
|
||||||
|
<div v-if="currentComponent.file_path" class="file-path">
|
||||||
|
<span class="file-label">文件路径:</span>
|
||||||
|
<span class="file-value">{{ currentComponent.file_path }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="currentComponent.is_extracted && currentComponent.folder_path" class="folder-path">
|
||||||
|
<span class="file-label">文件夹路径:</span>
|
||||||
|
<span class="file-value">{{ currentComponent.folder_path }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="上传模式" v-if="!isEdit">
|
||||||
|
<el-radio-group v-model="uploadMode">
|
||||||
|
<el-radio label="files">文件上传</el-radio>
|
||||||
|
<el-radio label="folder">文件夹上传</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 编辑时可以重新上传文件 -->
|
||||||
|
<el-form-item label="重新上传文件" v-if="isEdit">
|
||||||
|
<el-upload
|
||||||
|
ref="editUploadRef"
|
||||||
|
:auto-upload="false"
|
||||||
|
:multiple="true"
|
||||||
|
:on-change="handleEditFileChange"
|
||||||
|
:on-remove="handleEditFileRemove"
|
||||||
|
drag
|
||||||
|
>
|
||||||
|
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
||||||
|
<div class="el-upload__text">
|
||||||
|
拖放新文件到此处,或<em>点击上传</em>
|
||||||
|
</div>
|
||||||
|
<template #tip>
|
||||||
|
<div class="el-upload__tip">
|
||||||
|
可以上传多个文件替换当前文件。ZIP文件可以自动解压,其他文件类型仅保存。
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-upload>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="组件文件" v-if="!isEdit">
|
||||||
|
<el-upload
|
||||||
|
ref="createUploadRef"
|
||||||
|
:auto-upload="false"
|
||||||
|
:multiple="uploadMode === 'files'"
|
||||||
|
:webkitdirectory="uploadMode === 'folder'"
|
||||||
|
:on-change="handleCreateFileChange"
|
||||||
|
:on-remove="handleCreateFileRemove"
|
||||||
|
drag
|
||||||
|
>
|
||||||
|
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
||||||
|
<div class="el-upload__text">
|
||||||
|
将文件拖到此处,或<em>点击上传</em>
|
||||||
|
</div>
|
||||||
|
<template #tip>
|
||||||
|
<div class="el-upload__tip" v-if="uploadMode === 'files'">
|
||||||
|
可以上传多个文件,每个文件不超过100MB。ZIP文件可以自动解压,其他文件类型仅保存。
|
||||||
|
</div>
|
||||||
|
<div class="el-upload__tip" v-else>
|
||||||
|
。ZIP文件可以自动解压,每个文件不超过100MB。
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-upload>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="dialog-footer">
|
<span class="dialog-footer">
|
||||||
@@ -183,7 +455,6 @@
|
|||||||
ref="uploadRef"
|
ref="uploadRef"
|
||||||
:auto-upload="false"
|
:auto-upload="false"
|
||||||
:limit="1"
|
:limit="1"
|
||||||
accept=".zip"
|
|
||||||
:on-change="handleFileChange"
|
:on-change="handleFileChange"
|
||||||
:on-remove="handleFileRemove"
|
:on-remove="handleFileRemove"
|
||||||
drag
|
drag
|
||||||
@@ -194,7 +465,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<template #tip>
|
<template #tip>
|
||||||
<div class="el-upload__tip">
|
<div class="el-upload__tip">
|
||||||
只能上传zip文件,且不超过100MB
|
文件夹使用压缩为zip格式,可以批量上传文件。
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
@@ -243,15 +514,20 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { uiComponentApi } from '@/api/ui-component'
|
import { uiComponentApi } from '@/api/ui-component'
|
||||||
import { Document, Folder, UploadFilled } from '@element-plus/icons-vue'
|
import FilterItem from '@/components/common/FilterItem.vue'
|
||||||
|
import FilterSection from '@/components/common/FilterSection.vue'
|
||||||
|
import ListPageLayout from '@/components/common/ListPageLayout.vue'
|
||||||
|
import { ArrowDown, Document, Folder, Plus, UploadFilled } from '@element-plus/icons-vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
|
||||||
|
// 移动端检测
|
||||||
|
const { isMobile, isTablet } = useMobileTable()
|
||||||
|
|
||||||
// 响应式数据
|
// 响应式数据
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const componentList = ref([])
|
const componentList = ref([])
|
||||||
@@ -263,8 +539,12 @@ const uploading = ref(false)
|
|||||||
const isEdit = ref(false)
|
const isEdit = ref(false)
|
||||||
const currentComponent = ref(null)
|
const currentComponent = ref(null)
|
||||||
const selectedFile = ref(null)
|
const selectedFile = ref(null)
|
||||||
|
const selectedCreateFile = ref(null)
|
||||||
|
const selectedCreateFiles = ref([])
|
||||||
|
const uploadMode = ref('files') // 'files' 或 'folder'
|
||||||
const formRef = ref(null)
|
const formRef = ref(null)
|
||||||
const uploadRef = ref(null)
|
const uploadRef = ref(null)
|
||||||
|
const createUploadRef = ref(null)
|
||||||
const folderTree = ref([])
|
const folderTree = ref([])
|
||||||
|
|
||||||
// 筛选表单
|
// 筛选表单
|
||||||
@@ -333,6 +613,27 @@ const fetchComponentList = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (date) => {
|
||||||
|
if (!date) return '-'
|
||||||
|
return new Date(date).toLocaleDateString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (date) => {
|
||||||
|
if (!date) return '-'
|
||||||
|
return new Date(date).toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理筛选变化
|
||||||
|
const handleFilterChange = () => {
|
||||||
|
pagination.page = 1
|
||||||
|
fetchComponentList()
|
||||||
|
}
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
pagination.page = 1
|
pagination.page = 1
|
||||||
fetchComponentList()
|
fetchComponentList()
|
||||||
@@ -379,7 +680,7 @@ const handleEdit = (row) => {
|
|||||||
|
|
||||||
const handleDelete = (row) => {
|
const handleDelete = (row) => {
|
||||||
ElMessageBox.confirm(
|
ElMessageBox.confirm(
|
||||||
`确定要删除UI组件"${row.component_name}"吗?`,
|
`确定要删除UI组件"${row.component_name}"吗?这将同时删除组件记录和所有相关文件。`,
|
||||||
'删除确认',
|
'删除确认',
|
||||||
{
|
{
|
||||||
confirmButtonText: '确定',
|
confirmButtonText: '确定',
|
||||||
@@ -388,12 +689,24 @@ const handleDelete = (row) => {
|
|||||||
}
|
}
|
||||||
).then(async () => {
|
).then(async () => {
|
||||||
try {
|
try {
|
||||||
|
// 记录删除操作的详细信息
|
||||||
|
console.log('正在删除UI组件:', {
|
||||||
|
id: row.id,
|
||||||
|
name: row.component_name,
|
||||||
|
code: row.component_code,
|
||||||
|
file_path: row.file_path,
|
||||||
|
is_extracted: row.is_extracted,
|
||||||
|
folder_path: row.folder_path
|
||||||
|
})
|
||||||
|
|
||||||
await uiComponentApi.deleteUIComponent(row.id)
|
await uiComponentApi.deleteUIComponent(row.id)
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
fetchComponentList()
|
fetchComponentList()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error('删除失败')
|
|
||||||
console.error('删除UI组件失败:', error)
|
console.error('删除UI组件失败:', error)
|
||||||
|
// 显示更详细的错误信息
|
||||||
|
const errorMsg = error.response?.data?.message || error.message || '删除失败'
|
||||||
|
ElMessage.error(`删除失败: ${errorMsg}`)
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// 用户取消删除
|
// 用户取消删除
|
||||||
@@ -411,8 +724,32 @@ const handleSubmit = async () => {
|
|||||||
await uiComponentApi.updateUIComponent(form.id, form)
|
await uiComponentApi.updateUIComponent(form.id, form)
|
||||||
ElMessage.success('更新成功')
|
ElMessage.success('更新成功')
|
||||||
} else {
|
} else {
|
||||||
await uiComponentApi.createUIComponent(form)
|
// 检查是否上传了文件
|
||||||
ElMessage.success('创建成功')
|
if (selectedCreateFiles.value && selectedCreateFiles.value.length > 0) {
|
||||||
|
// 使用合并接口,同时创建组件和上传文件
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('component_code', form.component_code)
|
||||||
|
formData.append('component_name', form.component_name)
|
||||||
|
formData.append('description', form.description || '')
|
||||||
|
formData.append('version', form.version || '')
|
||||||
|
formData.append('is_active', form.is_active ? 'true' : 'false')
|
||||||
|
formData.append('sort_order', form.sort_order.toString())
|
||||||
|
|
||||||
|
// 添加所有文件
|
||||||
|
selectedCreateFiles.value.forEach(file => {
|
||||||
|
formData.append('files', file.raw)
|
||||||
|
if (uploadMode.value === 'folder') {
|
||||||
|
formData.append('paths', file.path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await uiComponentApi.createUIComponentWithFile(formData)
|
||||||
|
ElMessage.success('创建并上传成功')
|
||||||
|
} else {
|
||||||
|
// 只创建组件,不上传文件
|
||||||
|
await uiComponentApi.createUIComponent(form)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
@@ -441,9 +778,15 @@ const resetForm = () => {
|
|||||||
is_active: true,
|
is_active: true,
|
||||||
sort_order: 0
|
sort_order: 0
|
||||||
})
|
})
|
||||||
|
selectedCreateFile.value = null
|
||||||
|
selectedCreateFiles.value = []
|
||||||
|
uploadMode.value = 'files' // 重置为文件上传模式
|
||||||
if (formRef.value) {
|
if (formRef.value) {
|
||||||
formRef.value.resetFields()
|
formRef.value.resetFields()
|
||||||
}
|
}
|
||||||
|
if (createUploadRef.value) {
|
||||||
|
createUploadRef.value.clearFiles()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpload = (row) => {
|
const handleUpload = (row) => {
|
||||||
@@ -472,6 +815,20 @@ const handleFileRemove = () => {
|
|||||||
selectedFile.value = null
|
selectedFile.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCreateFileChange = (file, fileList) => {
|
||||||
|
selectedCreateFiles.value = fileList.map(f => ({
|
||||||
|
raw: f.raw,
|
||||||
|
name: f.name,
|
||||||
|
path: uploadMode.value === 'folder' ? (f.raw.webkitRelativePath || f.name) : f.name
|
||||||
|
}))
|
||||||
|
selectedCreateFile.value = fileList.length > 0 ? fileList[0].raw : null // 保留兼容性
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateFileRemove = () => {
|
||||||
|
selectedCreateFiles.value = []
|
||||||
|
selectedCreateFile.value = null // 保留兼容性
|
||||||
|
}
|
||||||
|
|
||||||
const handleFileSubmit = async () => {
|
const handleFileSubmit = async () => {
|
||||||
if (!selectedFile.value) {
|
if (!selectedFile.value) {
|
||||||
ElMessage.warning('请选择要上传的文件')
|
ElMessage.warning('请选择要上传的文件')
|
||||||
@@ -512,7 +869,17 @@ const handleDownload = async (row) => {
|
|||||||
const url = window.URL.createObjectURL(new Blob([response.data]))
|
const url = window.URL.createObjectURL(new Blob([response.data]))
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
link.setAttribute('download', `${row.component_name}.zip`)
|
|
||||||
|
// 根据文件类型确定下载文件名
|
||||||
|
let fileName = row.component_name
|
||||||
|
if (row.file_path) {
|
||||||
|
const fileExtension = row.file_path.substring(row.file_path.lastIndexOf('.'))
|
||||||
|
fileName += fileExtension
|
||||||
|
} else {
|
||||||
|
fileName += '.zip' // 默认扩展名
|
||||||
|
}
|
||||||
|
|
||||||
|
link.setAttribute('download', fileName)
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
document.body.removeChild(link)
|
document.body.removeChild(link)
|
||||||
@@ -537,7 +904,7 @@ const handleViewFolder = async (row) => {
|
|||||||
|
|
||||||
const handleDeleteFolder = (row) => {
|
const handleDeleteFolder = (row) => {
|
||||||
ElMessageBox.confirm(
|
ElMessageBox.confirm(
|
||||||
`确定要删除UI组件"${row.component_name}"的文件夹吗?`,
|
`确定要删除UI组件"${row.component_name}"的文件夹吗?这将只删除文件夹,保留组件记录。`,
|
||||||
'删除确认',
|
'删除确认',
|
||||||
{
|
{
|
||||||
confirmButtonText: '确定',
|
confirmButtonText: '确定',
|
||||||
@@ -546,12 +913,24 @@ const handleDeleteFolder = (row) => {
|
|||||||
}
|
}
|
||||||
).then(async () => {
|
).then(async () => {
|
||||||
try {
|
try {
|
||||||
|
// 记录删除操作的详细信息
|
||||||
|
console.log('正在删除UI组件文件夹:', {
|
||||||
|
id: row.id,
|
||||||
|
name: row.component_name,
|
||||||
|
code: row.component_code,
|
||||||
|
file_path: row.file_path,
|
||||||
|
is_extracted: row.is_extracted,
|
||||||
|
folder_path: row.folder_path
|
||||||
|
})
|
||||||
|
|
||||||
await uiComponentApi.deleteUIComponentFolder(row.id)
|
await uiComponentApi.deleteUIComponentFolder(row.id)
|
||||||
ElMessage.success('文件夹删除成功')
|
ElMessage.success('文件夹删除成功')
|
||||||
fetchComponentList()
|
fetchComponentList()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error('文件夹删除失败')
|
|
||||||
console.error('删除UI组件文件夹失败:', error)
|
console.error('删除UI组件文件夹失败:', error)
|
||||||
|
// 显示更详细的错误信息
|
||||||
|
const errorMsg = error.response?.data?.message || error.message || '文件夹删除失败'
|
||||||
|
ElMessage.error(`文件夹删除失败: ${errorMsg}`)
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// 用户取消删除
|
// 用户取消删除
|
||||||
@@ -620,6 +999,18 @@ const formatDateTime = (dateTime) => {
|
|||||||
return date.toLocaleString('zh-CN')
|
return date.toLocaleString('zh-CN')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 判断文件是否为ZIP类型
|
||||||
|
const isZipFile = (file) => {
|
||||||
|
if (!file) return false
|
||||||
|
return file.toLowerCase().endsWith('.zip')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断文件路径是否为ZIP类型
|
||||||
|
const isZipFileFromPath = (path) => {
|
||||||
|
if (!path) return false
|
||||||
|
return path.toLowerCase().endsWith('.zip')
|
||||||
|
}
|
||||||
|
|
||||||
// 生命周期
|
// 生命周期
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchComponentList()
|
fetchComponentList()
|
||||||
@@ -627,24 +1018,7 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ui-components-page {
|
/* 对话框样式 */
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-card {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-card {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-footer {
|
.dialog-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -663,4 +1037,195 @@ onMounted(() => {
|
|||||||
color: #999;
|
color: #999;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 移动端卡片布局 */
|
||||||
|
.component-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 80px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1f2937;
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格容器 */
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格样式优化 */
|
||||||
|
:deep(.el-table) {
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table th) {
|
||||||
|
background: #f8fafc !important;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table td) {
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table tr:hover > td) {
|
||||||
|
background: #f8fafc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 对话框样式优化 */
|
||||||
|
:deep(.el-dialog) {
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-dialog__header) {
|
||||||
|
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||||
|
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-dialog__title) {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-dialog__body) {
|
||||||
|
padding: 24px;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.card-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格在移动端优化 */
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table) {
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table th),
|
||||||
|
:deep(.el-table td) {
|
||||||
|
padding: 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table .cell) {
|
||||||
|
padding: 0 4px;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分页组件在移动端优化 */
|
||||||
|
:deep(.el-pagination) {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-pagination .el-pagination__sizes) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-pagination .el-pagination__total) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-pagination .el-pagination__jump) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 对话框在移动端优化 */
|
||||||
|
:deep(.el-dialog__body) {
|
||||||
|
padding: 16px;
|
||||||
|
max-height: 80vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 超小屏幕进一步优化 */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.component-card {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-label {
|
||||||
|
font-size: 11px;
|
||||||
|
min-width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -21,12 +21,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<el-button :size="isMobile ? 'small' : 'small'" @click="exitSingleUserMode">
|
<el-button :size="isMobile ? 'small' : 'small'" @click="exitSingleUserMode">
|
||||||
<Close class="w-4 h-4 mr-1" />
|
<Close class="w-4 h-4 mr-1" />
|
||||||
<span :class="isMobile ? 'hidden sm:inline' : ''">取消</span>
|
<span>取消</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button :size="isMobile ? 'small' : 'small'" type="primary" @click="goBackToUsers">
|
<el-button :size="isMobile ? 'small' : 'small'" type="primary" @click="goBackToUsers">
|
||||||
<Back class="w-4 h-4 mr-1" />
|
<Back class="w-4 h-4 mr-1" />
|
||||||
<span :class="isMobile ? 'hidden sm:inline' : ''">返回用户管理</span>
|
<span>返回用户管理</span>
|
||||||
<span :class="isMobile ? 'sm:hidden' : 'hidden'">返回</span>
|
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -317,6 +316,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 请求参数 -->
|
||||||
|
<!-- <div class="info-section">
|
||||||
|
<h4 class="text-lg font-semibold text-gray-900 mb-4">请求参数</h4>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-value">{{ selectedApiCall?.request_params || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex justify-center items-center py-8">
|
<div v-else class="flex justify-center items-center py-8">
|
||||||
<el-loading size="large" />
|
<el-loading size="large" />
|
||||||
@@ -464,6 +471,7 @@ const formatDateTime = (date) => {
|
|||||||
return new Date(date).toLocaleString('zh-CN')
|
return new Date(date).toLocaleString('zh-CN')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 获取状态类型
|
// 获取状态类型
|
||||||
const getStatusType = (status) => {
|
const getStatusType = (status) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -541,10 +549,24 @@ const exitSingleUserMode = () => {
|
|||||||
router.replace({ name: 'AdminUsage' })
|
router.replace({ name: 'AdminUsage' })
|
||||||
loadApiCalls()
|
loadApiCalls()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回用户管理
|
// 返回用户管理
|
||||||
const goBackToUsers = () => {
|
const goBackToUsers = () => {
|
||||||
router.push({ name: 'AdminUsers' })
|
const query = { user_id: currentUser.value?.id }
|
||||||
|
|
||||||
|
// 如果当前用户有手机号,添加到查询参数
|
||||||
|
if (currentUser.value?.phone) {
|
||||||
|
query.phone = currentUser.value.phone
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果当前用户有企业名称,添加到查询参数
|
||||||
|
if (currentUser.value?.enterprise_info?.company_name) {
|
||||||
|
query.company_name = currentUser.value.enterprise_info.company_name
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push({
|
||||||
|
name: 'AdminUsers',
|
||||||
|
query
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查看详情
|
// 查看详情
|
||||||
@@ -598,8 +620,8 @@ const handleExport = async (options) => {
|
|||||||
// 调用导出API
|
// 调用导出API
|
||||||
const response = await apiCallApi.exportAdminApiCalls(params)
|
const response = await apiCallApi.exportAdminApiCalls(params)
|
||||||
|
|
||||||
// 创建下载链接
|
// 创建下载链接(二进制在 response.data,见 request 响应拦截器包装)
|
||||||
const blob = new Blob([response], {
|
const blob = new Blob([response.data], {
|
||||||
type: options.format === 'excel'
|
type: options.format === 'excel'
|
||||||
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
: 'text/csv;charset=utf-8'
|
: 'text/csv;charset=utf-8'
|
||||||
|
|||||||
@@ -178,6 +178,10 @@
|
|||||||
<el-icon><wallet /></el-icon>
|
<el-icon><wallet /></el-icon>
|
||||||
充值记录
|
充值记录
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item :command="{ action: 'purchase_records', user }">
|
||||||
|
<el-icon><shopping-cart /></el-icon>
|
||||||
|
购买记录
|
||||||
|
</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
@@ -367,6 +371,10 @@
|
|||||||
<el-icon><wallet /></el-icon>
|
<el-icon><wallet /></el-icon>
|
||||||
充值记录
|
充值记录
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item :command="{ action: 'purchase_records', user: row }">
|
||||||
|
<el-icon><shopping-cart /></el-icon>
|
||||||
|
购买记录
|
||||||
|
</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
@@ -461,42 +469,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 企业信息 -->
|
<!-- 企业与合同信息(仅在用户已完成企业认证时展示) -->
|
||||||
<div v-if="selectedUser.enterprise_info" class="enterprise-info">
|
<div v-if="selectedUser.is_certified" class="enterprise-info">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">企业信息</h3>
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">企业信息</h3>
|
||||||
<div :class="['grid gap-4', isMobile ? 'grid-cols-1' : 'grid-cols-2']">
|
<div :class="['grid gap-4', isMobile ? 'grid-cols-1' : 'grid-cols-2']">
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-label">企业名称:</span>
|
<span class="info-label">企业名称:</span>
|
||||||
<span class="info-value">{{ selectedUser.enterprise_info.company_name }}</span>
|
<span class="info-value">{{ selectedUser.enterprise_info?.company_name || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-label">统一社会信用代码:</span>
|
<span class="info-label">统一社会信用代码:</span>
|
||||||
<span class="info-value">{{ selectedUser.enterprise_info.unified_social_code }}</span>
|
<span class="info-value">{{ selectedUser.enterprise_info?.unified_social_code || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-label">法定代表人:</span>
|
<span class="info-label">法定代表人:</span>
|
||||||
<span class="info-value">{{ selectedUser.enterprise_info.legal_person_name }}</span>
|
<span class="info-value">{{ selectedUser.enterprise_info?.legal_person_name || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-label">法定代表人手机:</span>
|
<span class="info-label">法定代表人手机:</span>
|
||||||
<span class="info-value">{{ formatPhone(selectedUser.enterprise_info.legal_person_phone) }}</span>
|
<span class="info-value">{{ selectedUser.enterprise_info?.legal_person_phone ?
|
||||||
|
formatPhone(selectedUser.enterprise_info.legal_person_phone) : '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item col-span-2">
|
<div class="info-item col-span-2">
|
||||||
<span class="info-label">企业地址:</span>
|
<span class="info-label">企业地址:</span>
|
||||||
<span class="info-value">{{ selectedUser.enterprise_info.enterprise_address }}</span>
|
<span class="info-value">{{ selectedUser.enterprise_info?.enterprise_address || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-label">企业邮箱:</span>
|
<span class="info-label">企业邮箱:</span>
|
||||||
<span class="info-value">{{ selectedUser.enterprise_info.enterprise_email || '-' }}</span>
|
<span class="info-value">{{ selectedUser.enterprise_info?.enterprise_email || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-label">认证时间:</span>
|
<span class="info-label">认证时间:</span>
|
||||||
<span class="info-value">{{ formatDate(selectedUser.enterprise_info.created_at) }}</span>
|
<span class="info-value">{{ selectedUser.enterprise_info?.created_at ?
|
||||||
|
formatDate(selectedUser.enterprise_info.created_at) : '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 合同信息部分 -->
|
<!-- 合同信息部分 -->
|
||||||
<div v-if="selectedUser.enterprise_info.contracts && selectedUser.enterprise_info.contracts.length > 0" class="contracts-section mt-6">
|
<div
|
||||||
|
v-if="selectedUser.enterprise_info && selectedUser.enterprise_info.contracts && selectedUser.enterprise_info.contracts.length > 0"
|
||||||
|
class="contracts-section mt-6">
|
||||||
<h4 class="text-md font-semibold text-gray-900 mb-3">合同信息</h4>
|
<h4 class="text-md font-semibold text-gray-900 mb-3">合同信息</h4>
|
||||||
<div class="contracts-list space-y-3">
|
<div class="contracts-list space-y-3">
|
||||||
<div
|
<div
|
||||||
@@ -538,6 +550,11 @@
|
|||||||
<el-icon class="text-4xl text-gray-300 mb-2"><warning /></el-icon>
|
<el-icon class="text-4xl text-gray-300 mb-2"><warning /></el-icon>
|
||||||
<div class="text-lg font-medium text-gray-600">该用户尚未完成企业认证</div>
|
<div class="text-lg font-medium text-gray-600">该用户尚未完成企业认证</div>
|
||||||
<div class="text-sm text-gray-500 mt-1">完成企业认证后可查看详细信息</div>
|
<div class="text-sm text-gray-500 mt-1">完成企业认证后可查看详细信息</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<el-button type="primary" @click="openAdminCompleteCertDialog" size="small">
|
||||||
|
代用户完成认证
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -633,17 +650,66 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 管理员代用户完成认证弹窗 -->
|
||||||
|
<el-dialog v-model="adminCertDialogVisible" title="代用户完成企业认证" :width="isMobile ? '90%' : '600px'"
|
||||||
|
class="enterprise-dialog">
|
||||||
|
<div v-if="selectedUser" class="space-y-4">
|
||||||
|
<div class="user-info mb-4">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">用户手机:</span>
|
||||||
|
<span class="info-value">{{ formatPhone(selectedUser.phone) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form ref="adminCertFormRef" :model="adminCertForm" :rules="adminCertRules" label-width="120px">
|
||||||
|
<el-form-item label="企业名称" prop="company_name">
|
||||||
|
<el-input v-model="adminCertForm.company_name" placeholder="请输入企业名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="统一社会信用代码" prop="unified_social_code">
|
||||||
|
<el-input v-model="adminCertForm.unified_social_code" placeholder="请输入统一社会信用代码" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="法定代表人姓名" prop="legal_person_name">
|
||||||
|
<el-input v-model="adminCertForm.legal_person_name" placeholder="请输入法定代表人姓名" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="法定代表人身份证" prop="legal_person_id">
|
||||||
|
<el-input v-model="adminCertForm.legal_person_id" placeholder="请输入法定代表人身份证号码" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="法人手机号" prop="legal_person_phone">
|
||||||
|
<el-input v-model="adminCertForm.legal_person_phone" placeholder="请输入法人手机号" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="企业地址" prop="enterprise_address">
|
||||||
|
<el-input v-model="adminCertForm.enterprise_address" placeholder="请输入企业地址" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="操作原因" prop="reason">
|
||||||
|
<el-input v-model="adminCertForm.reason" type="textarea" :rows="3" placeholder="请填写本次代用户完成认证的原因,便于审计" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div :class="['dialog-footer', isMobile ? 'flex-col' : '']">
|
||||||
|
<el-button :class="isMobile ? 'w-full' : ''" @click="adminCertDialogVisible = false">
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" :class="isMobile ? 'w-full' : ''" @click="handleSubmitAdminCert"
|
||||||
|
:loading="adminCertLoading">
|
||||||
|
确认完成认证
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { financeApi, userApi } from '@/api'
|
import { certificationApi, financeApi, userApi } from '@/api'
|
||||||
import FilterItem from '@/components/common/FilterItem.vue'
|
import FilterItem from '@/components/common/FilterItem.vue'
|
||||||
import FilterSection from '@/components/common/FilterSection.vue'
|
import FilterSection from '@/components/common/FilterSection.vue'
|
||||||
import ListPageLayout from '@/components/common/ListPageLayout.vue'
|
import ListPageLayout from '@/components/common/ListPageLayout.vue'
|
||||||
import { useMobileTable } from '@/composables/useMobileTable'
|
import { useMobileTable } from '@/composables/useMobileTable'
|
||||||
import { ArrowDown, Document, Money, Tickets, Wallet, Warning } from '@element-plus/icons-vue'
|
import { ArrowDown, Document, Money, ShoppingCart, Tickets, Wallet, Warning } from '@element-plus/icons-vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
@@ -665,6 +731,30 @@ const selectedUser = ref(null)
|
|||||||
const rechargeLoading = ref(false)
|
const rechargeLoading = ref(false)
|
||||||
const rechargeFormRef = ref(null)
|
const rechargeFormRef = ref(null)
|
||||||
|
|
||||||
|
// 管理员代用户完成认证
|
||||||
|
const adminCertDialogVisible = ref(false)
|
||||||
|
const adminCertLoading = ref(false)
|
||||||
|
const adminCertFormRef = ref(null)
|
||||||
|
const adminCertForm = reactive({
|
||||||
|
company_name: '',
|
||||||
|
unified_social_code: '',
|
||||||
|
legal_person_name: '',
|
||||||
|
legal_person_id: '',
|
||||||
|
legal_person_phone: '',
|
||||||
|
enterprise_address: '',
|
||||||
|
reason: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const adminCertRules = {
|
||||||
|
company_name: [{ required: true, message: '请输入企业名称', trigger: 'blur' }],
|
||||||
|
unified_social_code: [{ required: true, message: '请输入统一社会信用代码', trigger: 'blur' }],
|
||||||
|
legal_person_name: [{ required: true, message: '请输入法定代表人姓名', trigger: 'blur' }],
|
||||||
|
legal_person_id: [{ required: true, message: '请输入法定代表人身份证号码', trigger: 'blur' }],
|
||||||
|
legal_person_phone: [{ required: true, message: '请输入法人手机号', trigger: 'blur' }],
|
||||||
|
enterprise_address: [{ required: true, message: '请输入企业地址', trigger: 'blur' }],
|
||||||
|
reason: [{ required: true, message: '请填写本次操作原因', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
// 统计数据
|
// 统计数据
|
||||||
const stats = ref({
|
const stats = ref({
|
||||||
total_users: 0,
|
total_users: 0,
|
||||||
@@ -854,6 +944,61 @@ const handleViewUser = (user) => {
|
|||||||
userDialogVisible.value = true
|
userDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 打开管理员代用户完成认证弹窗
|
||||||
|
const openAdminCompleteCertDialog = () => {
|
||||||
|
if (!selectedUser.value) return
|
||||||
|
// 如果用户已有部分企业信息,做回显
|
||||||
|
if (selectedUser.value.enterprise_info) {
|
||||||
|
const info = selectedUser.value.enterprise_info
|
||||||
|
adminCertForm.company_name = info.company_name || ''
|
||||||
|
adminCertForm.unified_social_code = info.unified_social_code || ''
|
||||||
|
adminCertForm.legal_person_name = info.legal_person_name || ''
|
||||||
|
adminCertForm.legal_person_id = info.legal_person_id || ''
|
||||||
|
adminCertForm.legal_person_phone = info.legal_person_phone || ''
|
||||||
|
adminCertForm.enterprise_address = info.enterprise_address || ''
|
||||||
|
} else {
|
||||||
|
adminCertForm.company_name = ''
|
||||||
|
adminCertForm.unified_social_code = ''
|
||||||
|
adminCertForm.legal_person_name = ''
|
||||||
|
adminCertForm.legal_person_id = ''
|
||||||
|
adminCertForm.legal_person_phone = ''
|
||||||
|
adminCertForm.enterprise_address = ''
|
||||||
|
}
|
||||||
|
adminCertForm.reason = ''
|
||||||
|
adminCertDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交管理员代用户完成认证
|
||||||
|
const handleSubmitAdminCert = async () => {
|
||||||
|
if (!adminCertFormRef.value || !selectedUser.value) return
|
||||||
|
try {
|
||||||
|
await adminCertFormRef.value.validate()
|
||||||
|
adminCertLoading.value = true
|
||||||
|
const payload = {
|
||||||
|
user_id: selectedUser.value.id,
|
||||||
|
company_name: adminCertForm.company_name,
|
||||||
|
unified_social_code: adminCertForm.unified_social_code,
|
||||||
|
legal_person_name: adminCertForm.legal_person_name,
|
||||||
|
legal_person_id: adminCertForm.legal_person_id,
|
||||||
|
legal_person_phone: adminCertForm.legal_person_phone,
|
||||||
|
enterprise_address: adminCertForm.enterprise_address,
|
||||||
|
reason: adminCertForm.reason
|
||||||
|
}
|
||||||
|
await certificationApi.adminCompleteWithoutContract(payload)
|
||||||
|
ElMessage.success('已代用户完成企业认证')
|
||||||
|
adminCertDialogVisible.value = false
|
||||||
|
userDialogVisible.value = false
|
||||||
|
// 重新加载用户列表和统计
|
||||||
|
await loadUsers()
|
||||||
|
await loadStats()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('代用户完成认证失败:', error)
|
||||||
|
ElMessage.error(error?.response?.data?.message || '代用户完成认证失败')
|
||||||
|
} finally {
|
||||||
|
adminCertLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 查看合同
|
// 查看合同
|
||||||
const handleViewContract = (contract) => {
|
const handleViewContract = (contract) => {
|
||||||
if (contract.contract_file_url) {
|
if (contract.contract_file_url) {
|
||||||
@@ -950,6 +1095,12 @@ const handleMoreAction = (command) => {
|
|||||||
query: { user_id: user.id }
|
query: { user_id: user.id }
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
case 'purchase_records':
|
||||||
|
router.push({
|
||||||
|
name: 'AdminPurchaseRecords',
|
||||||
|
query: { user_id: user.id }
|
||||||
|
})
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
ElMessage.warning('未知操作')
|
ElMessage.warning('未知操作')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,17 +181,17 @@
|
|||||||
<span v-if="field.required" class="text-red-500 ml-1">*</span>
|
<span v-if="field.required" class="text-red-500 ml-1">*</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<!-- 图片上传字段(photo_data) -->
|
<!-- Base64字段上传(根据后端校验规则自动识别) -->
|
||||||
<div v-if="field.name === 'photo_data' && field.type === 'textarea'" class="space-y-2">
|
<div v-if="isBase64UploadField(field)" class="space-y-2">
|
||||||
<div class="flex gap-2 mb-2">
|
<div class="flex gap-2 mb-2">
|
||||||
<el-upload
|
<el-upload
|
||||||
:auto-upload="false"
|
:auto-upload="false"
|
||||||
:show-file-list="false"
|
:show-file-list="false"
|
||||||
accept="image/jpeg,image/jpg,image/png,image/bmp"
|
:accept="getUploadAcceptByField(field)"
|
||||||
:on-change="(file) => handleImageUpload(file, field.name)"
|
:on-change="(file) => handleImageUpload(file, field.name, field)"
|
||||||
class="flex-1">
|
class="flex-1">
|
||||||
<el-button type="primary" size="small">
|
<el-button type="primary" size="small">
|
||||||
<i class="el-icon-upload"></i> 上传图片(JPG/BMP/PNG)
|
<i class="el-icon-upload"></i> {{ getUploadButtonTextByField(field) }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
<el-button v-if="formData[field.name]" type="danger" size="small" @click="clearImageData(field.name)">
|
<el-button v-if="formData[field.name]" type="danger" size="small" @click="clearImageData(field.name)">
|
||||||
@@ -934,21 +934,48 @@ const loadFormConfig = async (apiCode) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理图片上传并转换为base64
|
const getFieldValidationText = (field) => {
|
||||||
const handleImageUpload = (file, fieldName) => {
|
return typeof field?.validation === 'string' ? field.validation : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBase64ImageOnlyField = (field) => {
|
||||||
|
return getFieldValidationText(field).includes('Base64图片')
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBase64UploadField = (field) => {
|
||||||
|
if (field?.type !== 'textarea') return false
|
||||||
|
const validationText = getFieldValidationText(field)
|
||||||
|
return validationText.includes('Base64图片') || validationText.includes('Base64编码') || validationText.toLowerCase().includes('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUploadAcceptByField = (field) => {
|
||||||
|
if (isBase64ImageOnlyField(field)) {
|
||||||
|
return 'image/jpeg,image/jpg,image/png,image/bmp'
|
||||||
|
}
|
||||||
|
return 'image/jpeg,image/jpg,image/png,image/bmp,application/pdf,.pdf'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUploadButtonTextByField = (field) => {
|
||||||
|
return isBase64ImageOnlyField(field) ? '上传图片(JPG/BMP/PNG)' : '上传文件(JPG/BMP/PNG/PDF)'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理文件上传并转换为base64(支持按字段规则限制类型)
|
||||||
|
const handleImageUpload = (file, fieldName, field) => {
|
||||||
const fileObj = file.raw || file
|
const fileObj = file.raw || file
|
||||||
|
|
||||||
// 验证文件类型
|
// 验证文件类型
|
||||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/bmp']
|
const allowedTypes = isBase64ImageOnlyField(field)
|
||||||
|
? ['image/jpeg', 'image/jpg', 'image/png', 'image/bmp']
|
||||||
|
: ['image/jpeg', 'image/jpg', 'image/png', 'image/bmp', 'application/pdf']
|
||||||
if (!allowedTypes.includes(fileObj.type)) {
|
if (!allowedTypes.includes(fileObj.type)) {
|
||||||
ElMessage.error('只支持 JPG、BMP、PNG 格式的图片')
|
ElMessage.error(isBase64ImageOnlyField(field) ? '只支持 JPG、BMP、PNG 格式的图片' : '只支持 JPG、BMP、PNG、PDF 格式的文件')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证文件大小(限制为5MB)
|
// 验证文件大小(限制为5MB)
|
||||||
const maxSize = 5 * 1024 * 1024 // 5MB
|
const maxSize = 5 * 1024 * 1024 // 5MB
|
||||||
if (fileObj.size > maxSize) {
|
if (fileObj.size > maxSize) {
|
||||||
ElMessage.error('图片大小不能超过 5MB')
|
ElMessage.error('文件大小不能超过 5MB')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -959,10 +986,10 @@ const handleImageUpload = (file, fieldName) => {
|
|||||||
// 移除 data:image/xxx;base64, 前缀,只保留纯base64数据
|
// 移除 data:image/xxx;base64, 前缀,只保留纯base64数据
|
||||||
const base64Data = base64String.includes(',') ? base64String.split(',')[1] : base64String
|
const base64Data = base64String.includes(',') ? base64String.split(',')[1] : base64String
|
||||||
formData.value[fieldName] = base64Data
|
formData.value[fieldName] = base64Data
|
||||||
ElMessage.success('图片上传成功,已转换为base64')
|
ElMessage.success('文件上传成功,已转换为base64')
|
||||||
}
|
}
|
||||||
reader.onerror = () => {
|
reader.onerror = () => {
|
||||||
ElMessage.error('图片读取失败,请重试')
|
ElMessage.error('文件读取失败,请重试')
|
||||||
}
|
}
|
||||||
reader.readAsDataURL(fileObj)
|
reader.readAsDataURL(fileObj)
|
||||||
|
|
||||||
|
|||||||
@@ -115,10 +115,12 @@
|
|||||||
|
|
||||||
<script setup name="UserLogin">
|
<script setup name="UserLogin">
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { useAliyunCaptcha } from '@/composables/useAliyunCaptcha'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const { runWithCaptcha } = useAliyunCaptcha()
|
||||||
|
|
||||||
// 登录方式
|
// 登录方式
|
||||||
const loginMethod = ref('sms')
|
const loginMethod = ref('sms')
|
||||||
@@ -157,11 +159,19 @@ const sendCode = async () => {
|
|||||||
|
|
||||||
sendingCode.value = true
|
sendingCode.value = true
|
||||||
try {
|
try {
|
||||||
const result = await userStore.sendCode(form.value.phone, 'login')
|
await runWithCaptcha(
|
||||||
if (result.success) {
|
async (captchaVerifyParam) => {
|
||||||
ElMessage.success('验证码发送成功')
|
return await userStore.sendCode(form.value.phone, 'login', captchaVerifyParam)
|
||||||
startCountdown()
|
},
|
||||||
}
|
(res) => {
|
||||||
|
if (res.success) {
|
||||||
|
ElMessage.success('验证码发送成功')
|
||||||
|
startCountdown()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res?.error?.message || '验证码发送失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('验证码发送失败:', error)
|
console.error('验证码发送失败:', error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -138,10 +138,12 @@
|
|||||||
|
|
||||||
<script setup name="UserRegister">
|
<script setup name="UserRegister">
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { useAliyunCaptcha } from '@/composables/useAliyunCaptcha'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const { runWithCaptcha } = useAliyunCaptcha()
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const form = ref({
|
const form = ref({
|
||||||
@@ -175,17 +177,25 @@ const canSubmit = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 发送验证码
|
// 发送验证码(先通过滑块再请求后端发码)
|
||||||
const sendCode = async () => {
|
const sendCode = async () => {
|
||||||
if (!canSendCode.value) return
|
if (!canSendCode.value) return
|
||||||
|
|
||||||
sendingCode.value = true
|
sendingCode.value = true
|
||||||
try {
|
try {
|
||||||
const result = await userStore.sendCode(form.value.phone, 'register')
|
await runWithCaptcha(
|
||||||
if (result.success) {
|
async (captchaVerifyParam) => {
|
||||||
ElMessage.success('验证码发送成功')
|
return await userStore.sendCode(form.value.phone, 'register', captchaVerifyParam)
|
||||||
startCountdown()
|
},
|
||||||
}
|
(res) => {
|
||||||
|
if (res.success) {
|
||||||
|
ElMessage.success('验证码发送成功')
|
||||||
|
startCountdown()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res?.error?.message || '验证码发送失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('验证码发送失败:', error)
|
console.error('验证码发送失败:', error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -126,10 +126,12 @@
|
|||||||
|
|
||||||
<script setup name="UserResetPassword">
|
<script setup name="UserResetPassword">
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { useAliyunCaptcha } from '@/composables/useAliyunCaptcha'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const { runWithCaptcha } = useAliyunCaptcha()
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const form = ref({
|
const form = ref({
|
||||||
@@ -157,17 +159,25 @@ const canSubmit = computed(() => {
|
|||||||
form.value.confirmNewPassword && form.value.confirmNewPassword === form.value.newPassword
|
form.value.confirmNewPassword && form.value.confirmNewPassword === form.value.newPassword
|
||||||
})
|
})
|
||||||
|
|
||||||
// 发送验证码
|
// 发送验证码(先通过滑块再请求后端发码)
|
||||||
const sendCode = async () => {
|
const sendCode = async () => {
|
||||||
if (!canSendCode.value) return
|
if (!canSendCode.value) return
|
||||||
|
|
||||||
sendingCode.value = true
|
sendingCode.value = true
|
||||||
try {
|
try {
|
||||||
const result = await userStore.sendCode(form.value.phone, 'reset_password')
|
await runWithCaptcha(
|
||||||
if (result.success) {
|
async (captchaVerifyParam) => {
|
||||||
ElMessage.success('验证码发送成功')
|
return await userStore.sendCode(form.value.phone, 'reset_password', captchaVerifyParam)
|
||||||
startCountdown()
|
},
|
||||||
}
|
(res) => {
|
||||||
|
if (res.success) {
|
||||||
|
ElMessage.success('验证码发送成功')
|
||||||
|
startCountdown()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res?.error?.message || '验证码发送失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('验证码发送失败:', error)
|
console.error('验证码发送失败:', error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<div class="success-text">
|
<div class="success-text">
|
||||||
<h2 class="success-title">恭喜!企业入驻已完成</h2>
|
<h2 class="success-title">恭喜!企业入驻已完成</h2>
|
||||||
<p class="success-desc">您的企业已完成入驻,现在可以使用完整的API服务功能。</p>
|
<p class="success-desc">您的企业已完成入驻,现在可以使用完整的API服务功能。</p>
|
||||||
|
<p class="success-desc">下一步,您只需要订阅贵司需要的api接口就可以实现在线调试和使用</p>
|
||||||
<div class="completion-info">
|
<div class="completion-info">
|
||||||
<h3 class="info-title">入驻信息</h3>
|
<h3 class="info-title">入驻信息</h3>
|
||||||
<div class="info-grid">
|
<div class="info-grid">
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
label-width="10em"
|
label-width="10em"
|
||||||
class="enterprise-form-content"
|
class="enterprise-form-content"
|
||||||
>
|
>
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<h3 class="section-title">基本信息</h3>
|
<h3 class="section-title">基本信息</h3>
|
||||||
|
|
||||||
<!-- 企业名称和OCR识别区域 -->
|
<!-- 企业名称和OCR识别区域 -->
|
||||||
@@ -42,21 +42,7 @@
|
|||||||
<div class="ocr-compact">
|
<div class="ocr-compact">
|
||||||
<div class="ocr-header-compact">
|
<div class="ocr-header-compact">
|
||||||
<el-icon class="text-green-600"><DocumentIcon /></el-icon>
|
<el-icon class="text-green-600"><DocumentIcon /></el-icon>
|
||||||
<span class="ocr-title-compact">OCR识别</span>
|
<span class="ocr-title-compact">可进行OCR识别,请在下方上传营业执照</span>
|
||||||
<el-upload
|
|
||||||
ref="uploadRef"
|
|
||||||
:auto-upload="false"
|
|
||||||
:show-file-list="false"
|
|
||||||
:on-change="handleFileChange"
|
|
||||||
:before-upload="beforeUpload"
|
|
||||||
accept="image/jpeg,image/jpg,image/png,image/webp"
|
|
||||||
class="ocr-uploader-compact"
|
|
||||||
>
|
|
||||||
<el-button type="success" size="small" plain>
|
|
||||||
<el-icon><ArrowUpTrayIcon /></el-icon>
|
|
||||||
上传营业执照
|
|
||||||
</el-button>
|
|
||||||
</el-upload>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="ocrLoading" class="ocr-status-compact">
|
<div v-if="ocrLoading" class="ocr-status-compact">
|
||||||
<el-icon class="is-loading"><Loading /></el-icon>
|
<el-icon class="is-loading"><Loading /></el-icon>
|
||||||
@@ -84,6 +70,7 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
|
|
||||||
<el-row :gutter="16" class="mb-4">
|
<el-row :gutter="16" class="mb-4">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="法人姓名" prop="legalPersonName">
|
<el-form-item label="法人姓名" prop="legalPersonName">
|
||||||
@@ -96,21 +83,9 @@
|
|||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
|
||||||
<el-form-item label="法人身份证号" prop="legalPersonID">
|
|
||||||
<el-input
|
|
||||||
v-model="form.legalPersonID"
|
|
||||||
placeholder="请输入法人身份证号"
|
|
||||||
clearable
|
|
||||||
size="default"
|
|
||||||
class="form-input"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
|
|
||||||
<el-row :gutter="16" class="mb-4">
|
|
||||||
<el-col :span="24">
|
<el-col :span="12">
|
||||||
<el-form-item label="企业地址" prop="enterpriseAddress">
|
<el-form-item label="企业地址" prop="enterpriseAddress">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="form.enterpriseAddress"
|
v-model="form.enterpriseAddress"
|
||||||
@@ -122,8 +97,70 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
<!-- 营业执照图片上传(保留) -->
|
||||||
|
<el-row :gutter="16" class="mb-4">
|
||||||
|
<el-col :span="24">
|
||||||
|
<el-form-item label="营业执照图片" prop="businessLicenseImageURL">
|
||||||
|
<el-upload
|
||||||
|
class="upload-area single-upload-area"
|
||||||
|
action="#"
|
||||||
|
list-type="picture-card"
|
||||||
|
:auto-upload="false"
|
||||||
|
:file-list="businessLicenseFileList"
|
||||||
|
:limit="1"
|
||||||
|
:on-change="handleBusinessLicenseChange"
|
||||||
|
:on-remove="handleBusinessLicenseRemove"
|
||||||
|
:before-upload="beforeUpload"
|
||||||
|
>
|
||||||
|
<el-icon class="upload-icon"><ArrowUpTrayIcon /></el-icon>
|
||||||
|
<div class="el-upload__text">上传清晰可辨的营业执照图片</div>
|
||||||
|
</el-upload>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<!-- 暂时隐藏:办公场地图片上传 -->
|
||||||
|
<el-row :gutter="16" class="mb-4">
|
||||||
|
<el-col :span="24">
|
||||||
|
<el-form-item label="办公场地照片" prop="officePlaceImageURLs">
|
||||||
|
<div class="text-xs mb-1 text-blue-500">
|
||||||
|
请在非 IE 浏览器下上传大小不超过 1M 的图片,最多 10 张,需体现门楣 LOGO、办公设备与工作人员。
|
||||||
|
</div>
|
||||||
|
<el-upload
|
||||||
|
ref="officePlaceUploadRef"
|
||||||
|
class="upload-area"
|
||||||
|
action="#"
|
||||||
|
list-type="picture-card"
|
||||||
|
:auto-upload="false"
|
||||||
|
v-model:file-list="officePlaceFileList"
|
||||||
|
accept="image/jpeg,image/jpg,image/png,image/webp"
|
||||||
|
multiple
|
||||||
|
:limit="10"
|
||||||
|
:on-change="handleOfficePlaceChange"
|
||||||
|
:on-remove="handleOfficePlaceRemove"
|
||||||
|
:before-upload="beforeUpload"
|
||||||
|
>
|
||||||
|
<div class="upload-trigger-inner">
|
||||||
|
<el-icon class="upload-icon"><ArrowUpTrayIcon /></el-icon>
|
||||||
|
<div class="el-upload__text">上传办公场地环境照片</div>
|
||||||
|
</div>
|
||||||
|
</el-upload>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
|
||||||
<el-row :gutter="16" class="mb-4">
|
<el-row :gutter="16" class="mb-4">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="法人身份证号" prop="legalPersonID">
|
||||||
|
<el-input
|
||||||
|
v-model="form.legalPersonID"
|
||||||
|
placeholder="请输入法人身份证号"
|
||||||
|
clearable
|
||||||
|
size="default"
|
||||||
|
class="form-input"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="法人手机号" prop="legalPersonPhone">
|
<el-form-item label="法人手机号" prop="legalPersonPhone">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -161,6 +198,137 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- 暂时隐藏:授权代表信息(姓名、身份证号、手机号、授权代表身份证) -->
|
||||||
|
<h3 class="section-title">授权代表信息</h3>
|
||||||
|
<p class="section-desc">授权代表信息用于证明该人员已获得企业授权,请确保姓名、身份证号、手机号及身份证正反面照片真实有效。</p>
|
||||||
|
|
||||||
|
<el-row :gutter="16" class="mb-4">
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="授权代表姓名" prop="authorizedRepName">
|
||||||
|
<el-input
|
||||||
|
v-model="form.authorizedRepName"
|
||||||
|
placeholder="请输入授权代表姓名"
|
||||||
|
clearable
|
||||||
|
size="default"
|
||||||
|
class="form-input"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="授权代表身份证号" prop="authorizedRepID">
|
||||||
|
<el-input
|
||||||
|
v-model="form.authorizedRepID"
|
||||||
|
placeholder="请输入授权代表身份证号"
|
||||||
|
clearable
|
||||||
|
size="default"
|
||||||
|
class="form-input"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-form-item label="授权代表手机号" prop="authorizedRepPhone">
|
||||||
|
<el-input
|
||||||
|
v-model="form.authorizedRepPhone"
|
||||||
|
placeholder="请输入授权代表手机号"
|
||||||
|
clearable
|
||||||
|
size="default"
|
||||||
|
maxlength="11"
|
||||||
|
class="form-input"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="16" class="mb-4">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="身份证人像面" prop="authorizedRepIDImageURLs">
|
||||||
|
<el-upload
|
||||||
|
class="upload-area single-upload-area"
|
||||||
|
action="#"
|
||||||
|
list-type="picture-card"
|
||||||
|
:auto-upload="false"
|
||||||
|
:file-list="authorizedRepIDFrontFileList"
|
||||||
|
:limit="1"
|
||||||
|
:on-change="handleAuthorizedRepIDFrontChange"
|
||||||
|
:on-remove="handleAuthorizedRepIDFrontRemove"
|
||||||
|
:before-upload="beforeUpload"
|
||||||
|
>
|
||||||
|
<el-icon class="upload-icon"><ArrowUpTrayIcon /></el-icon>
|
||||||
|
<div class="el-upload__text">上传授权代表身份证人像面</div>
|
||||||
|
</el-upload>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="身份证国徽面" prop="authorizedRepIDImageURLs">
|
||||||
|
<el-upload
|
||||||
|
class="upload-area single-upload-area"
|
||||||
|
action="#"
|
||||||
|
list-type="picture-card"
|
||||||
|
:auto-upload="false"
|
||||||
|
:file-list="authorizedRepIDBackFileList"
|
||||||
|
:limit="1"
|
||||||
|
:on-change="handleAuthorizedRepIDBackChange"
|
||||||
|
:on-remove="handleAuthorizedRepIDBackRemove"
|
||||||
|
:before-upload="beforeUpload"
|
||||||
|
>
|
||||||
|
<el-icon class="upload-icon"><ArrowUpTrayIcon /></el-icon>
|
||||||
|
<div class="el-upload__text">上传授权代表身份证国徽面</div>
|
||||||
|
</el-upload>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 暂时隐藏:应用场景说明、应用场景附件 -->
|
||||||
|
<h3 class="section-title">应用场景填写</h3>
|
||||||
|
<p class="section-desc">请描述您调用接口的具体业务场景</p>
|
||||||
|
|
||||||
|
<el-row :gutter="16" class="mb-4">
|
||||||
|
<el-col :span="24">
|
||||||
|
<el-form-item label="应用场景" prop="apiUsage">
|
||||||
|
<el-input
|
||||||
|
v-model="form.apiUsage"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="请描述您调用接口的具体业务场景和用途"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
class="form-input"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="16" class="mb-4">
|
||||||
|
<el-col :span="24">
|
||||||
|
|
||||||
|
<el-form-item label="应用场景附件" prop="scenarioAttachmentURLs">
|
||||||
|
<div class="text-xs mb-1 text-blue-500">
|
||||||
|
请在非 IE 浏览器下上传大小不超过 1M 的图片,最多 10 张后台应用截图。
|
||||||
|
</div>
|
||||||
|
<el-upload
|
||||||
|
ref="scenarioUploadRef"
|
||||||
|
class="upload-area"
|
||||||
|
action="#"
|
||||||
|
list-type="picture-card"
|
||||||
|
:auto-upload="false"
|
||||||
|
v-model:file-list="scenarioFileList"
|
||||||
|
accept="image/jpeg,image/jpg,image/png,image/webp"
|
||||||
|
multiple
|
||||||
|
:limit="10"
|
||||||
|
:on-change="handleScenarioChange"
|
||||||
|
:on-remove="handleScenarioRemove"
|
||||||
|
:before-upload="beforeUpload"
|
||||||
|
>
|
||||||
|
<div class="upload-trigger-inner">
|
||||||
|
<el-icon class="upload-icon"><ArrowUpTrayIcon /></el-icon>
|
||||||
|
<div class="el-upload__text">上传业务场景相关截图或证明材料</div>
|
||||||
|
</div>
|
||||||
|
</el-upload>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
@@ -190,7 +358,8 @@ import {
|
|||||||
CheckIcon,
|
CheckIcon,
|
||||||
DocumentIcon
|
DocumentIcon
|
||||||
} from '@heroicons/vue/24/outline'
|
} from '@heroicons/vue/24/outline'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { useAliyunCaptcha } from '@/composables/useAliyunCaptcha'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
formData: {
|
formData: {
|
||||||
@@ -202,7 +371,17 @@ const props = defineProps({
|
|||||||
legalPersonID: '',
|
legalPersonID: '',
|
||||||
legalPersonPhone: '',
|
legalPersonPhone: '',
|
||||||
enterpriseAddress: '',
|
enterpriseAddress: '',
|
||||||
legalPersonCode: ''
|
legalPersonCode: '',
|
||||||
|
// 扩展:营业执照 & 办公场地 & 场景
|
||||||
|
businessLicenseImageURL: '',
|
||||||
|
officePlaceImageURLs: [],
|
||||||
|
apiUsage: '',
|
||||||
|
scenarioAttachmentURLs: [],
|
||||||
|
// 授权代表信息
|
||||||
|
authorizedRepName: '',
|
||||||
|
authorizedRepID: '',
|
||||||
|
authorizedRepPhone: '',
|
||||||
|
authorizedRepIDImageURLs: []
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -210,6 +389,7 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['submit'])
|
const emit = defineEmits(['submit'])
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const { runWithCaptcha } = useAliyunCaptcha()
|
||||||
|
|
||||||
// 表单引用
|
// 表单引用
|
||||||
const enterpriseFormRef = ref()
|
const enterpriseFormRef = ref()
|
||||||
@@ -222,7 +402,17 @@ const form = ref({
|
|||||||
legalPersonID: '',
|
legalPersonID: '',
|
||||||
legalPersonPhone: '',
|
legalPersonPhone: '',
|
||||||
enterpriseAddress: '',
|
enterpriseAddress: '',
|
||||||
legalPersonCode: ''
|
legalPersonCode: '',
|
||||||
|
// 扩展:营业执照 & 办公场地 & 场景
|
||||||
|
businessLicenseImageURL: '',
|
||||||
|
officePlaceImageURLs: [],
|
||||||
|
apiUsage: '',
|
||||||
|
scenarioAttachmentURLs: [],
|
||||||
|
// 授权代表信息
|
||||||
|
authorizedRepName: '',
|
||||||
|
authorizedRepID: '',
|
||||||
|
authorizedRepPhone: '',
|
||||||
|
authorizedRepIDImageURLs: []
|
||||||
})
|
})
|
||||||
|
|
||||||
// 验证码相关状态
|
// 验证码相关状态
|
||||||
@@ -237,6 +427,15 @@ const submitting = ref(false)
|
|||||||
const ocrLoading = ref(false)
|
const ocrLoading = ref(false)
|
||||||
const ocrResult = ref(false)
|
const ocrResult = ref(false)
|
||||||
const uploadRef = ref()
|
const uploadRef = ref()
|
||||||
|
const officePlaceUploadRef = ref()
|
||||||
|
const scenarioUploadRef = ref()
|
||||||
|
|
||||||
|
// 上传文件列表(前端展示用)
|
||||||
|
const officePlaceFileList = ref([])
|
||||||
|
const scenarioFileList = ref([])
|
||||||
|
const businessLicenseFileList = ref([])
|
||||||
|
const authorizedRepIDFrontFileList = ref([])
|
||||||
|
const authorizedRepIDBackFileList = ref([])
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const canSendCode = computed(() => {
|
const canSendCode = computed(() => {
|
||||||
@@ -294,6 +493,24 @@ const validatePhone = (rule, value, callback) => {
|
|||||||
callback()
|
callback()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 数组字段必填验证(至少上传/选择一项)
|
||||||
|
const validateRequiredArray = (message) => (rule, value, callback) => {
|
||||||
|
if (!Array.isArray(value) || value.length === 0) {
|
||||||
|
callback(new Error(message))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 授权代表身份证要求:正反面都必须上传
|
||||||
|
const validateAuthorizedRepIDImages = (rule, value, callback) => {
|
||||||
|
if (!Array.isArray(value) || value.length < 2) {
|
||||||
|
callback(new Error('请上传授权代表身份证正反面图片'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
|
||||||
// 表单验证规则
|
// 表单验证规则
|
||||||
const enterpriseRules = {
|
const enterpriseRules = {
|
||||||
companyName: [
|
companyName: [
|
||||||
@@ -324,6 +541,35 @@ const enterpriseRules = {
|
|||||||
legalPersonCode: [
|
legalPersonCode: [
|
||||||
{ required: true, message: '请输入验证码', trigger: 'blur' },
|
{ required: true, message: '请输入验证码', trigger: 'blur' },
|
||||||
{ len: 6, message: '验证码应为6位数字', trigger: 'blur' }
|
{ len: 6, message: '验证码应为6位数字', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
businessLicenseImageURL: [
|
||||||
|
{ required: true, message: '请上传营业执照图片', trigger: 'change' }
|
||||||
|
],
|
||||||
|
officePlaceImageURLs: [
|
||||||
|
{ validator: validateRequiredArray('请上传办公场地照片'), trigger: 'change' }
|
||||||
|
],
|
||||||
|
// 暂时隐藏的表单项,校验已关闭,恢复显示时请还原
|
||||||
|
apiUsage: [
|
||||||
|
{ required: true, message: '请填写接口用途', trigger: 'blur' },
|
||||||
|
{ min: 5, max: 500, message: '接口用途长度应在5-500个字符之间', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
scenarioAttachmentURLs: [
|
||||||
|
{ validator: validateRequiredArray('请上传应用场景附件'), trigger: 'change' }
|
||||||
|
],
|
||||||
|
authorizedRepName: [
|
||||||
|
{ required: true, message: '请输入授权代表姓名', trigger: 'blur' },
|
||||||
|
{ min: 2, max: 20, message: '授权代表姓名长度应在2-20个字符之间', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
authorizedRepID: [
|
||||||
|
{ required: true, message: '请输入授权代表身份证号', trigger: 'blur' },
|
||||||
|
{ validator: validateIDCard, trigger: 'blur' }
|
||||||
|
],
|
||||||
|
authorizedRepPhone: [
|
||||||
|
{ required: true, message: '请输入授权代表手机号', trigger: 'blur' },
|
||||||
|
{ validator: validatePhone, trigger: 'blur' }
|
||||||
|
],
|
||||||
|
authorizedRepIDImageURLs: [
|
||||||
|
{ validator: validateAuthorizedRepIDImages, trigger: 'change' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,14 +591,21 @@ const sendCode = async () => {
|
|||||||
|
|
||||||
sendingCode.value = true
|
sendingCode.value = true
|
||||||
try {
|
try {
|
||||||
const result = await userStore.sendCode(form.value.legalPersonPhone, 'certification')
|
await runWithCaptcha(
|
||||||
if (result.success) {
|
async (captchaVerifyParam) => {
|
||||||
ElMessage.success('验证码发送成功')
|
return await userStore.sendCode(form.value.legalPersonPhone, 'certification', captchaVerifyParam)
|
||||||
startCountdown()
|
},
|
||||||
}
|
(res) => {
|
||||||
|
if (res.success) {
|
||||||
|
ElMessage.success('验证码发送成功')
|
||||||
|
startCountdown()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res?.error?.message || '验证码发送失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('验证码发送失败:', error)
|
console.error('验证码发送失败:', error)
|
||||||
ElMessage.error('验证码发送失败,请重试')
|
|
||||||
} finally {
|
} finally {
|
||||||
sendingCode.value = false
|
sendingCode.value = false
|
||||||
}
|
}
|
||||||
@@ -373,20 +626,21 @@ const startCountdown = () => {
|
|||||||
const beforeUpload = (file) => {
|
const beforeUpload = (file) => {
|
||||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
|
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
|
||||||
const isValidType = allowedTypes.includes(file.type)
|
const isValidType = allowedTypes.includes(file.type)
|
||||||
const isValidSize = file.size / 1024 / 1024 < 5
|
const maxSizeMB = 1
|
||||||
|
const isValidSize = file.size / 1024 / 1024 < maxSizeMB
|
||||||
|
|
||||||
if (!isValidType) {
|
if (!isValidType) {
|
||||||
ElMessage.error('只支持 JPG、PNG、WEBP 格式的图片')
|
ElMessage.error('只支持 JPG、PNG、WEBP 格式的图片')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!isValidSize) {
|
if (!isValidSize) {
|
||||||
ElMessage.error('图片大小不能超过 5MB')
|
ElMessage.error(`图片大小不能超过 ${maxSizeMB}MB`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理文件变化
|
// 处理文件变化:触发 OCR,并保存营业执照原图 URL(若有上传地址)
|
||||||
const handleFileChange = async (file) => {
|
const handleFileChange = async (file) => {
|
||||||
if (!beforeUpload(file.raw)) {
|
if (!beforeUpload(file.raw)) {
|
||||||
return
|
return
|
||||||
@@ -410,6 +664,11 @@ const handleFileChange = async (file) => {
|
|||||||
form.value.legalPersonID = ocrData.legal_person_id || ''
|
form.value.legalPersonID = ocrData.legal_person_id || ''
|
||||||
form.value.enterpriseAddress = ocrData.address || ''
|
form.value.enterpriseAddress = ocrData.address || ''
|
||||||
|
|
||||||
|
// 如果后端返回了已保存的营业执照图片URL,可以直接写入
|
||||||
|
if (ocrData.license_image_url) {
|
||||||
|
form.value.businessLicenseImageURL = ocrData.license_image_url
|
||||||
|
}
|
||||||
|
|
||||||
ocrResult.value = true
|
ocrResult.value = true
|
||||||
ElMessage.success('营业执照识别成功,已自动填充表单')
|
ElMessage.success('营业执照识别成功,已自动填充表单')
|
||||||
} else {
|
} else {
|
||||||
@@ -423,20 +682,241 @@ const handleFileChange = async (file) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 上传单张图片到七牛云,返回可访问 URL
|
||||||
|
const uploadFileToServer = async (file) => {
|
||||||
|
const res = await certificationApi.uploadFile(file)
|
||||||
|
if (!res?.success || !res?.data?.url) {
|
||||||
|
throw new Error(res?.error?.message || '图片上传失败')
|
||||||
|
}
|
||||||
|
return res.data.url
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择后立即上传:服务器 URL 存到 response.url,保留 file.url 为 blob 以便预览(避免服务器证书等问题导致预览失败)
|
||||||
|
const uploadFileOnceSelected = async (file) => {
|
||||||
|
if (!file?.raw) return null
|
||||||
|
if (file.response?.url) return file.response.url // 已上传过,不重复上传
|
||||||
|
file.status = 'uploading'
|
||||||
|
try {
|
||||||
|
const url = await uploadFileToServer(file.raw)
|
||||||
|
file.status = 'success'
|
||||||
|
if (file.response === undefined) file.response = {}
|
||||||
|
file.response.url = url
|
||||||
|
// 不覆盖 file.url,保留 blob 预览地址,避免服务器证书无效时预览失败
|
||||||
|
return url
|
||||||
|
} catch (err) {
|
||||||
|
file.status = 'fail'
|
||||||
|
ElMessage.error(err?.message || '图片上传失败')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交前仅从 fileList 同步 URL 到表单,并检查是否全部已上传(选择即上传,提交时不再批量上传)
|
||||||
|
// 注:营业执照、办公场地、应用场景、授权代表身份证等表单项已暂时隐藏,仅同步 URL,不再强制校验
|
||||||
|
const syncFormUrlsAndCheckReady = () => {
|
||||||
|
form.value.businessLicenseImageURL = extractUrls(businessLicenseFileList.value)[0] || ''
|
||||||
|
form.value.officePlaceImageURLs = extractUrls(officePlaceFileList.value)
|
||||||
|
form.value.scenarioAttachmentURLs = extractUrls(scenarioFileList.value)
|
||||||
|
updateAuthorizedRepIDImageURLs()
|
||||||
|
|
||||||
|
const hasUploading = (list) => list.some((f) => f.status === 'uploading')
|
||||||
|
const hasUnfinished = (list) => list.some((f) => f.raw && !f.response?.url)
|
||||||
|
if (hasUploading(businessLicenseFileList.value) || hasUnfinished(businessLicenseFileList.value)) return false
|
||||||
|
// 以下上传项已暂时隐藏,不再参与“未上传完成”的拦截
|
||||||
|
if (hasUploading(officePlaceFileList.value) || hasUnfinished(officePlaceFileList.value)) return false
|
||||||
|
if (hasUploading(scenarioFileList.value) || hasUnfinished(scenarioFileList.value)) return false
|
||||||
|
if (hasUploading(authorizedRepIDFrontFileList.value) || hasUnfinished(authorizedRepIDFrontFileList.value)) return false
|
||||||
|
if (hasUploading(authorizedRepIDBackFileList.value) || hasUnfinished(authorizedRepIDBackFileList.value)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 el-upload 的 fileList 中提取 URL 数组,优先用服务器 URL(response.url),提交用
|
||||||
|
const extractUrls = (fileList) => {
|
||||||
|
return fileList
|
||||||
|
.map(f => f.response?.url || f.url || f.name)
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 营业执照图片变更:先 OCR 识别,再选择即上传
|
||||||
|
const handleBusinessLicenseChange = async (file, fileList) => {
|
||||||
|
businessLicenseFileList.value = fileList
|
||||||
|
const urls = extractUrls(fileList)
|
||||||
|
form.value.businessLicenseImageURL = urls[0] || ''
|
||||||
|
|
||||||
|
if (file && file.raw) {
|
||||||
|
await handleFileChange(file)
|
||||||
|
// OCR 若未返回服务器 URL,则选择后立即上传(未上传过才上传)
|
||||||
|
if (!file.response?.url) {
|
||||||
|
const url = await uploadFileOnceSelected(file)
|
||||||
|
if (url) form.value.businessLicenseImageURL = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBusinessLicenseRemove = (file, fileList) => {
|
||||||
|
businessLicenseFileList.value = fileList
|
||||||
|
const urls = extractUrls(fileList)
|
||||||
|
form.value.businessLicenseImageURL = urls[0] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动清除营业执照图片(预览区域中的“删除”按钮)
|
||||||
|
const clearBusinessLicense = () => {
|
||||||
|
businessLicenseFileList.value = []
|
||||||
|
form.value.businessLicenseImageURL = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 授权代表身份证人像面图片变更:选择即上传
|
||||||
|
const handleAuthorizedRepIDFrontChange = async (file, fileList) => {
|
||||||
|
authorizedRepIDFrontFileList.value = fileList
|
||||||
|
if (file?.raw && !file.response?.url) {
|
||||||
|
const url = await uploadFileOnceSelected(file)
|
||||||
|
if (url) {
|
||||||
|
authorizedRepIDFrontFileList.value = authorizedRepIDFrontFileList.value.map((f) =>
|
||||||
|
f.uid === file.uid ? { ...f, status: 'success', response: { url }, url: f.url } : f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateAuthorizedRepIDImageURLs()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAuthorizedRepIDFrontRemove = (file, fileList) => {
|
||||||
|
authorizedRepIDFrontFileList.value = fileList
|
||||||
|
updateAuthorizedRepIDImageURLs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 授权代表身份证国徽面图片变更:选择即上传
|
||||||
|
const handleAuthorizedRepIDBackChange = async (file, fileList) => {
|
||||||
|
authorizedRepIDBackFileList.value = fileList
|
||||||
|
if (file?.raw && !file.response?.url) {
|
||||||
|
const url = await uploadFileOnceSelected(file)
|
||||||
|
if (url) {
|
||||||
|
authorizedRepIDBackFileList.value = authorizedRepIDBackFileList.value.map((f) =>
|
||||||
|
f.uid === file.uid ? { ...f, status: 'success', response: { url }, url: f.url } : f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateAuthorizedRepIDImageURLs()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAuthorizedRepIDBackRemove = (file, fileList) => {
|
||||||
|
authorizedRepIDBackFileList.value = fileList
|
||||||
|
updateAuthorizedRepIDImageURLs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动清除授权代表身份证人像面
|
||||||
|
const clearAuthorizedRepFront = () => {
|
||||||
|
authorizedRepIDFrontFileList.value = []
|
||||||
|
updateAuthorizedRepIDImageURLs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动清除授权代表身份证国徽面
|
||||||
|
const clearAuthorizedRepBack = () => {
|
||||||
|
authorizedRepIDBackFileList.value = []
|
||||||
|
updateAuthorizedRepIDImageURLs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 汇总授权代表身份证正反面图片URL到一个数组字段
|
||||||
|
const updateAuthorizedRepIDImageURLs = () => {
|
||||||
|
const frontUrl = extractUrls(authorizedRepIDFrontFileList.value)[0] || ''
|
||||||
|
const backUrl = extractUrls(authorizedRepIDBackFileList.value)[0] || ''
|
||||||
|
const urls = []
|
||||||
|
if (frontUrl) urls.push(frontUrl)
|
||||||
|
if (backUrl) urls.push(backUrl)
|
||||||
|
form.value.authorizedRepIDImageURLs = urls
|
||||||
|
enterpriseFormRef.value?.validateField('authorizedRepIDImageURLs')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 办公场地图片变更:选择即上传
|
||||||
|
const handleOfficePlaceChange = async (file, fileList) => {
|
||||||
|
officePlaceFileList.value = fileList
|
||||||
|
if (file?.raw && !file.response?.url) {
|
||||||
|
await uploadFileOnceSelected(file)
|
||||||
|
}
|
||||||
|
form.value.officePlaceImageURLs = extractUrls(fileList)
|
||||||
|
enterpriseFormRef.value?.validateField('officePlaceImageURLs')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOfficePlaceRemove = (file, fileList) => {
|
||||||
|
officePlaceFileList.value = fileList
|
||||||
|
form.value.officePlaceImageURLs = extractUrls(fileList)
|
||||||
|
enterpriseFormRef.value?.validateField('officePlaceImageURLs')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用场景附件图片变更:选择即上传
|
||||||
|
const handleScenarioChange = async (file, fileList) => {
|
||||||
|
scenarioFileList.value = fileList
|
||||||
|
if (file?.raw && !file.response?.url) {
|
||||||
|
await uploadFileOnceSelected(file)
|
||||||
|
}
|
||||||
|
form.value.scenarioAttachmentURLs = extractUrls(fileList)
|
||||||
|
enterpriseFormRef.value?.validateField('scenarioAttachmentURLs')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScenarioRemove = (file, fileList) => {
|
||||||
|
scenarioFileList.value = fileList
|
||||||
|
form.value.scenarioAttachmentURLs = extractUrls(fileList)
|
||||||
|
enterpriseFormRef.value?.validateField('scenarioAttachmentURLs')
|
||||||
|
}
|
||||||
|
|
||||||
// 提交表单
|
// 提交表单
|
||||||
const submitForm = async () => {
|
const submitForm = async () => {
|
||||||
|
if (submitting.value) return
|
||||||
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
await enterpriseFormRef.value.validate()
|
await enterpriseFormRef.value.validate()
|
||||||
|
|
||||||
submitting.value = true
|
// 显示确认对话框
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'提交的信息必须为法人真实信息(包括手机号),如信息有误请联系客服。',
|
||||||
|
'提交确认',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确认提交',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
distinguishCancelAndClose: true,
|
||||||
|
customClass: 'submit-confirm-dialog'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Mock API 调用
|
// 选择即上传:提交时不再上传,仅同步 URL 并校验是否均已上传完成
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
if (!syncFormUrlsAndCheckReady()) {
|
||||||
|
ElMessage.warning('请等待所有图片上传完成后再提交')
|
||||||
|
submitting.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
emit('submit', form.value)
|
// 调用后端提交接口
|
||||||
|
const payload = {
|
||||||
|
company_name: form.value.companyName,
|
||||||
|
unified_social_code: form.value.unifiedSocialCode,
|
||||||
|
legal_person_name: form.value.legalPersonName,
|
||||||
|
legal_person_id: form.value.legalPersonID,
|
||||||
|
legal_person_phone: form.value.legalPersonPhone,
|
||||||
|
enterprise_address: form.value.enterpriseAddress,
|
||||||
|
verification_code: form.value.legalPersonCode,
|
||||||
|
// 扩展字段
|
||||||
|
business_license_image_url: form.value.businessLicenseImageURL,
|
||||||
|
office_place_image_urls: form.value.officePlaceImageURLs,
|
||||||
|
api_usage: form.value.apiUsage,
|
||||||
|
scenario_attachment_urls: form.value.scenarioAttachmentURLs,
|
||||||
|
// 授权代表信息
|
||||||
|
authorized_rep_name: form.value.authorizedRepName,
|
||||||
|
authorized_rep_id: form.value.authorizedRepID,
|
||||||
|
authorized_rep_phone: form.value.authorizedRepPhone,
|
||||||
|
authorized_rep_id_image_urls: form.value.authorizedRepIDImageURLs
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await certificationApi.submitEnterpriseInfo(payload)
|
||||||
|
if (!res.success) {
|
||||||
|
throw new Error(res?.error?.message || '提交企业信息失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('submit', { formData: form.value, response: res })
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('表单验证失败:', error)
|
// 用户点击取消或关闭对话框,不处理
|
||||||
|
if (error !== 'cancel' && error !== 'close') {
|
||||||
|
console.error('表单验证失败:', error)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
@@ -584,6 +1064,13 @@ onUnmounted(() => {
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #64748b;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
/* 表单输入框 */
|
/* 表单输入框 */
|
||||||
.form-input :deep(.el-input__wrapper) {
|
.form-input :deep(.el-input__wrapper) {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -608,6 +1095,33 @@ onUnmounted(() => {
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 上传区域基础样式 */
|
||||||
|
.upload-area {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 保证 picture-card 触发区域整块可点击、可拖拽 */
|
||||||
|
.upload-trigger-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 148px;
|
||||||
|
cursor: pointer;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 当已有一张图片时,隐藏单图上传的“+ 选择文件”入口 */
|
||||||
|
.single-upload-area :deep(.el-upload-list__item + .el-upload--picture-card) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* 验证码按钮 */
|
/* 验证码按钮 */
|
||||||
.code-btn {
|
.code-btn {
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
@@ -723,3 +1237,51 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 提交确认对话框样式 */
|
||||||
|
.submit-confirm-dialog {
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-confirm-dialog .el-message-box__message {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #606266;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-confirm-dialog .el-message-box__title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-confirm-dialog .el-message-box__btns {
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-confirm-dialog .el-button--warning {
|
||||||
|
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||||
|
border: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-confirm-dialog .el-button--warning:hover {
|
||||||
|
background: linear-gradient(135deg, #d97706 0%, #b45309 100%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(217, 119, 6, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-confirm-dialog .el-button--default {
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
color: #606266;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-confirm-dialog .el-button--default:hover {
|
||||||
|
color: #409eff;
|
||||||
|
border-color: #c6e2ff;
|
||||||
|
background-color: #ecf5ff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
115
src/pages/certification/components/ManualReviewPending.vue
Normal file
115
src/pages/certification/components/ManualReviewPending.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<el-card class="step-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="header-icon">
|
||||||
|
<el-icon class="text-amber-600">
|
||||||
|
<ClockIcon />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="header-content">
|
||||||
|
<h2 class="header-title">人工审核</h2>
|
||||||
|
<p class="header-subtitle">您的企业信息已提交,请等待管理员审核</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="manual-review-content">
|
||||||
|
<div class="review-status-box">
|
||||||
|
<el-icon class="status-icon"><ClockIcon /></el-icon>
|
||||||
|
<p class="status-text">我们正在审核您提交的企业信息,请耐心等待。</p>
|
||||||
|
<p v-if="submitTime" class="submit-time">提交时间:{{ submitTime }}</p>
|
||||||
|
<p v-if="companyName" class="company-name">企业名称:{{ companyName }}</p>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" class="refresh-btn" :loading="refreshing" @click="handleRefresh">
|
||||||
|
刷新状态
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ClockIcon } from '@heroicons/vue/24/outline'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
certificationData: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
submitTime: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
companyName: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['refresh'])
|
||||||
|
|
||||||
|
const refreshing = ref(false)
|
||||||
|
const pollTimer = ref(null)
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
refreshing.value = true
|
||||||
|
try {
|
||||||
|
emit('refresh')
|
||||||
|
} finally {
|
||||||
|
refreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 人工审核阶段:自动轮询状态,审核通过后会在父组件中自动切换步骤
|
||||||
|
pollTimer.value = window.setInterval(() => {
|
||||||
|
emit('refresh')
|
||||||
|
}, 5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (pollTimer.value) {
|
||||||
|
window.clearInterval(pollTimer.value)
|
||||||
|
pollTimer.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.manual-review-content {
|
||||||
|
padding: 24px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-status-box {
|
||||||
|
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
color: #d97706;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #92400e;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-time,
|
||||||
|
.company-name {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #b45309;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -68,6 +68,14 @@
|
|||||||
@submit="handleEnterpriseSubmit"
|
@submit="handleEnterpriseSubmit"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ManualReviewPending
|
||||||
|
v-if="currentStep === 'manual_review'"
|
||||||
|
:certification-data="certificationData"
|
||||||
|
:submit-time="manualReviewSubmitTime"
|
||||||
|
:company-name="enterpriseForm.companyName"
|
||||||
|
@refresh="getCertificationDetails"
|
||||||
|
/>
|
||||||
|
|
||||||
<EnterpriseVerify
|
<EnterpriseVerify
|
||||||
v-if="currentStep === 'enterprise_verify'"
|
v-if="currentStep === 'enterprise_verify'"
|
||||||
:enterprise-data="enterpriseForm"
|
:enterprise-data="enterpriseForm"
|
||||||
@@ -120,6 +128,7 @@ import { useUserStore } from '@/stores/user'
|
|||||||
import {
|
import {
|
||||||
BuildingOfficeIcon,
|
BuildingOfficeIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
|
ClockIcon,
|
||||||
CodeBracketIcon,
|
CodeBracketIcon,
|
||||||
DocumentTextIcon,
|
DocumentTextIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
@@ -133,9 +142,9 @@ import ContractRejected from './components/ContractRejected.vue'
|
|||||||
import ContractSign from './components/ContractSign.vue'
|
import ContractSign from './components/ContractSign.vue'
|
||||||
import EnterpriseInfo from './components/EnterpriseInfo.vue'
|
import EnterpriseInfo from './components/EnterpriseInfo.vue'
|
||||||
import EnterpriseVerify from './components/EnterpriseVerify.vue'
|
import EnterpriseVerify from './components/EnterpriseVerify.vue'
|
||||||
|
import ManualReviewPending from './components/ManualReviewPending.vue'
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
// 认证步骤配置
|
|
||||||
const certificationSteps = [
|
const certificationSteps = [
|
||||||
{
|
{
|
||||||
key: 'enterprise_info',
|
key: 'enterprise_info',
|
||||||
@@ -143,6 +152,12 @@ const certificationSteps = [
|
|||||||
description: '填写企业基本信息和法人信息',
|
description: '填写企业基本信息和法人信息',
|
||||||
icon: BuildingOfficeIcon,
|
icon: BuildingOfficeIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'manual_review',
|
||||||
|
title: '人工审核',
|
||||||
|
description: '等待管理员审核企业信息',
|
||||||
|
icon: ClockIcon,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'enterprise_verify',
|
key: 'enterprise_verify',
|
||||||
title: '企业认证',
|
title: '企业认证',
|
||||||
@@ -176,6 +191,18 @@ const currentStepIndex = computed(() => {
|
|||||||
// 步骤特定元数据
|
// 步骤特定元数据
|
||||||
const stepMeta = ref({}) // 用于存储当前步骤的metadata
|
const stepMeta = ref({}) // 用于存储当前步骤的metadata
|
||||||
|
|
||||||
|
// 人工审核步骤的提交时间展示
|
||||||
|
const manualReviewSubmitTime = computed(() => {
|
||||||
|
const at = certificationData.value?.metadata?.enterprise_info?.submit_at ?? certificationData.value?.info_submitted_at
|
||||||
|
if (!at) return ''
|
||||||
|
try {
|
||||||
|
const d = new Date(at)
|
||||||
|
return Number.isNaN(d.getTime()) ? '' : d.toLocaleString('zh-CN')
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const enterpriseForm = ref({
|
const enterpriseForm = ref({
|
||||||
companyName: '',
|
companyName: '',
|
||||||
@@ -188,35 +215,66 @@ const enterpriseForm = ref({
|
|||||||
enterpriseEmail: '',
|
enterpriseEmail: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
// 开发模式控制
|
//
|
||||||
const isDevelopment = ref(false)
|
const isDevelopment = ref(false)
|
||||||
const devCurrentStep = ref('enterprise_info')
|
const devCurrentStep = ref('enterprise_info')
|
||||||
|
|
||||||
// 合同签署加载状态
|
// 合同签署加载状态
|
||||||
const contractSignLoading = ref(false)
|
const contractSignLoading = ref(false)
|
||||||
|
|
||||||
// 事件处理
|
// 只补 enterprise_verify 所需 auth_url,不改动原有页面展示数据结构
|
||||||
const handleEnterpriseSubmit = async (formData) => {
|
// 轮询策略:最多 5 秒,每秒 1 次(最多 5 次)
|
||||||
|
const pollAuthUrlOnly = async (maxTries = 5) => {
|
||||||
|
for (let i = 0; i < maxTries; i++) {
|
||||||
|
const res = await certificationApi.getCertificationDetails()
|
||||||
|
const status = res?.data?.status
|
||||||
|
const authUrl = res?.data?.metadata?.auth_url
|
||||||
|
|
||||||
|
// 仅同步状态,避免覆盖已有展示字段
|
||||||
|
if (status && certificationData.value) {
|
||||||
|
certificationData.value.status = status
|
||||||
|
await setCurrentStepByStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authUrl) {
|
||||||
|
// 保留原 metadata,仅更新链接字段
|
||||||
|
stepMeta.value = {
|
||||||
|
...(stepMeta.value || {}),
|
||||||
|
auth_url: authUrl,
|
||||||
|
}
|
||||||
|
return authUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件处理:优先用提交接口返回的认证数据更新步骤,确保进入「人工审核」页,避免依赖二次请求
|
||||||
|
const handleEnterpriseSubmit = async (payload) => {
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
// 字段映射
|
const nextAction = payload?.response?.data?.metadata?.next_action
|
||||||
const payload = {
|
if (nextAction) {
|
||||||
company_name: formData.companyName,
|
ElMessage.success(nextAction)
|
||||||
unified_social_code: formData.unifiedSocialCode,
|
} else {
|
||||||
legal_person_name: formData.legalPersonName,
|
ElMessage.success('企业信息提交成功,请等待管理员审核')
|
||||||
legal_person_id: formData.legalPersonID,
|
}
|
||||||
legal_person_phone: formData.legalPersonPhone,
|
if (payload?.response?.data?.status) {
|
||||||
enterprise_address: formData.enterpriseAddress,
|
certificationData.value = payload.response.data
|
||||||
enterprise_email: formData.enterpriseEmail,
|
stepMeta.value = payload.response.data?.metadata || {}
|
||||||
verification_code: formData.legalPersonCode,
|
await setCurrentStepByStatus()
|
||||||
|
if (currentStep.value === 'enterprise_verify' && !stepMeta.value?.auth_url) {
|
||||||
|
await pollAuthUrlOnly()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await getCertificationDetails()
|
||||||
|
if (currentStep.value === 'enterprise_verify' && !stepMeta.value?.auth_url) {
|
||||||
|
await pollAuthUrlOnly()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await certificationApi.submitEnterpriseInfo(payload)
|
|
||||||
ElMessage.success('企业信息提交成功')
|
|
||||||
// 提交成功后刷新认证详情
|
|
||||||
await getCertificationDetails()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error?.message || '提交失败,请检查表单信息')
|
ElMessage.error(error?.message || '获取认证状态失败,请刷新页面')
|
||||||
// 提交失败时不刷新认证详情,保持用户填写的信息
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -355,6 +413,9 @@ const setCurrentStepByStatus = async () => {
|
|||||||
case 'pending':
|
case 'pending':
|
||||||
currentStep.value = 'enterprise_info'
|
currentStep.value = 'enterprise_info'
|
||||||
break
|
break
|
||||||
|
case 'info_pending_review':
|
||||||
|
currentStep.value = 'manual_review'
|
||||||
|
break
|
||||||
case 'info_submitted':
|
case 'info_submitted':
|
||||||
currentStep.value = 'enterprise_verify'
|
currentStep.value = 'enterprise_verify'
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -26,8 +26,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 充值优惠提示 -->
|
<!-- 充值优惠提示(仅在开启赠送时显示) -->
|
||||||
<div class="recharge-benefit-alert">
|
<div class="recharge-benefit-alert" v-if="rechargeConfig.recharge_bonus_enabled">
|
||||||
<el-alert
|
<el-alert
|
||||||
title="充值优惠"
|
title="充值优惠"
|
||||||
description="充值满1000元即可享受商务洽谈优惠,获得专属服务支持"
|
description="充值满1000元即可享受商务洽谈优惠,获得专属服务支持"
|
||||||
@@ -51,6 +51,23 @@
|
|||||||
</el-alert>
|
</el-alert>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- API商店充值提示:只要后端配置了就直接展示在页面中 -->
|
||||||
|
<div v-if="rechargeConfig.api_store_recharge_tip" class="recharge-benefit-alert api-store-recharge-tip">
|
||||||
|
<div class="benefit-content">
|
||||||
|
<span class="api-store-recharge-tip-text">
|
||||||
|
{{ rechargeConfig.api_store_recharge_tip }}
|
||||||
|
</span>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="showBusinessConsultation = true"
|
||||||
|
class="consultation-btn"
|
||||||
|
>
|
||||||
|
商务洽谈
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 余额状态提示 -->
|
<!-- 余额状态提示 -->
|
||||||
<div v-if="walletInfo.is_arrears" class="balance-alert arrears-alert">
|
<div v-if="walletInfo.is_arrears" class="balance-alert arrears-alert">
|
||||||
<el-alert
|
<el-alert
|
||||||
@@ -163,21 +180,21 @@
|
|||||||
<h4 class="preset-title">选择充值金额</h4>
|
<h4 class="preset-title">选择充值金额</h4>
|
||||||
<div class="preset-amounts-grid">
|
<div class="preset-amounts-grid">
|
||||||
<div
|
<div
|
||||||
v-for="bonus in rechargeConfig.alipay_recharge_bonus"
|
v-for="item in presetAmountOptions"
|
||||||
:key="bonus.recharge_amount"
|
:key="item.recharge_amount"
|
||||||
class="preset-amount-card"
|
class="preset-amount-card"
|
||||||
:class="{ active: selectedPresetAmount === bonus.recharge_amount }"
|
:class="{ active: selectedPresetAmount === item.recharge_amount }"
|
||||||
@click="selectPresetAmount(bonus.recharge_amount)"
|
@click="selectPresetAmount(item.recharge_amount)"
|
||||||
>
|
>
|
||||||
<div class="preset-amount-main">
|
<div class="preset-amount-main">
|
||||||
<div class="preset-amount-value">¥{{ formatPrice(bonus.recharge_amount) }}</div>
|
<div class="preset-amount-value">¥{{ formatPrice(item.recharge_amount) }}</div>
|
||||||
<div class="preset-bonus-info">
|
<div v-if="rechargeConfig.recharge_bonus_enabled && item.bonus_amount > 0" class="preset-bonus-info">
|
||||||
<span class="bonus-label">赠送</span>
|
<span class="bonus-label">赠送</span>
|
||||||
<span class="bonus-amount">¥{{ formatPrice(bonus.bonus_amount) }}</span>
|
<span class="bonus-amount">¥{{ formatPrice(item.bonus_amount) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="preset-amount-total">
|
<div class="preset-amount-total">
|
||||||
实到账:¥{{ formatPrice(bonus.recharge_amount + bonus.bonus_amount) }}
|
实到账:¥{{ formatPrice(item.recharge_amount + (rechargeConfig.recharge_bonus_enabled ? item.bonus_amount : 0)) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -189,7 +206,7 @@
|
|||||||
>
|
>
|
||||||
<div class="preset-amount-main">
|
<div class="preset-amount-main">
|
||||||
<div class="preset-amount-value">自定义金额</div>
|
<div class="preset-amount-value">自定义金额</div>
|
||||||
<div class="preset-bonus-info">
|
<div v-if="rechargeConfig.recharge_bonus_enabled" class="preset-bonus-info">
|
||||||
<span class="bonus-label">赠送</span>
|
<span class="bonus-label">赠送</span>
|
||||||
<span class="bonus-amount">{{ getCustomBonusText() }}</span>
|
<span class="bonus-amount">{{ getCustomBonusText() }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,8 +236,8 @@
|
|||||||
</el-input>
|
</el-input>
|
||||||
<div class="form-tip">最低充值金额:¥{{ rechargeConfig.min_amount }},最多支持两位小数</div>
|
<div class="form-tip">最低充值金额:¥{{ rechargeConfig.min_amount }},最多支持两位小数</div>
|
||||||
|
|
||||||
<!-- 显示赠送信息 -->
|
<!-- 赠送开启时显示赠送信息 -->
|
||||||
<div v-if="wechatForm.amount && getCurrentBonusAmount() > 0" class="bonus-info">
|
<div v-if="rechargeConfig.recharge_bonus_enabled && wechatForm.amount && getCurrentBonusAmount() > 0" class="bonus-info">
|
||||||
<el-alert
|
<el-alert
|
||||||
:title="`充值 ¥${wechatForm.amount} 可享受赠送 ¥${formatPrice(getCurrentBonusAmount())}`"
|
:title="`充值 ¥${wechatForm.amount} 可享受赠送 ¥${formatPrice(getCurrentBonusAmount())}`"
|
||||||
type="success"
|
type="success"
|
||||||
@@ -261,21 +278,21 @@
|
|||||||
<h4 class="preset-title">选择充值金额</h4>
|
<h4 class="preset-title">选择充值金额</h4>
|
||||||
<div class="preset-amounts-grid">
|
<div class="preset-amounts-grid">
|
||||||
<div
|
<div
|
||||||
v-for="bonus in rechargeConfig.alipay_recharge_bonus"
|
v-for="item in presetAmountOptions"
|
||||||
:key="bonus.recharge_amount"
|
:key="item.recharge_amount"
|
||||||
class="preset-amount-card"
|
class="preset-amount-card"
|
||||||
:class="{ active: selectedPresetAmount === bonus.recharge_amount }"
|
:class="{ active: selectedPresetAmount === item.recharge_amount }"
|
||||||
@click="selectPresetAmount(bonus.recharge_amount)"
|
@click="selectPresetAmount(item.recharge_amount)"
|
||||||
>
|
>
|
||||||
<div class="preset-amount-main">
|
<div class="preset-amount-main">
|
||||||
<div class="preset-amount-value">¥{{ formatPrice(bonus.recharge_amount) }}</div>
|
<div class="preset-amount-value">¥{{ formatPrice(item.recharge_amount) }}</div>
|
||||||
<div class="preset-bonus-info">
|
<div v-if="rechargeConfig.recharge_bonus_enabled && item.bonus_amount > 0" class="preset-bonus-info">
|
||||||
<span class="bonus-label">赠送</span>
|
<span class="bonus-label">赠送</span>
|
||||||
<span class="bonus-amount">¥{{ formatPrice(bonus.bonus_amount) }}</span>
|
<span class="bonus-amount">¥{{ formatPrice(item.bonus_amount) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="preset-amount-total">
|
<div class="preset-amount-total">
|
||||||
实到账:¥{{ formatPrice(bonus.recharge_amount + bonus.bonus_amount) }}
|
实到账:¥{{ formatPrice(item.recharge_amount + (rechargeConfig.recharge_bonus_enabled ? item.bonus_amount : 0)) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -287,7 +304,7 @@
|
|||||||
>
|
>
|
||||||
<div class="preset-amount-main">
|
<div class="preset-amount-main">
|
||||||
<div class="preset-amount-value">自定义金额</div>
|
<div class="preset-amount-value">自定义金额</div>
|
||||||
<div class="preset-bonus-info">
|
<div v-if="rechargeConfig.recharge_bonus_enabled" class="preset-bonus-info">
|
||||||
<span class="bonus-label">赠送</span>
|
<span class="bonus-label">赠送</span>
|
||||||
<span class="bonus-amount">{{ getCustomBonusText() }}</span>
|
<span class="bonus-amount">{{ getCustomBonusText() }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -317,8 +334,8 @@
|
|||||||
</el-input>
|
</el-input>
|
||||||
<div class="form-tip">最低充值金额:¥{{ rechargeConfig.min_amount }},最多支持两位小数</div>
|
<div class="form-tip">最低充值金额:¥{{ rechargeConfig.min_amount }},最多支持两位小数</div>
|
||||||
|
|
||||||
<!-- 显示赠送信息 -->
|
<!-- 赠送开启时显示赠送信息 -->
|
||||||
<div v-if="alipayForm.amount && getCurrentBonusAmount() > 0" class="bonus-info">
|
<div v-if="rechargeConfig.recharge_bonus_enabled && alipayForm.amount && getCurrentBonusAmount() > 0" class="bonus-info">
|
||||||
<el-alert
|
<el-alert
|
||||||
:title="`充值 ¥${alipayForm.amount} 可享受赠送 ¥${formatPrice(getCurrentBonusAmount())}`"
|
:title="`充值 ¥${alipayForm.amount} 可享受赠送 ¥${formatPrice(getCurrentBonusAmount())}`"
|
||||||
type="success"
|
type="success"
|
||||||
@@ -427,6 +444,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
import { financeApi } from '@/api'
|
import { financeApi } from '@/api'
|
||||||
import BusinessConsultationDialog from '@/components/common/BusinessConsultationDialog.vue'
|
import BusinessConsultationDialog from '@/components/common/BusinessConsultationDialog.vue'
|
||||||
import { useCertification } from '@/composables/useCertification'
|
import { useCertification } from '@/composables/useCertification'
|
||||||
@@ -468,13 +486,29 @@ const walletInfo = ref({
|
|||||||
is_low_balance: false,
|
is_low_balance: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 充值配置
|
// 充值配置(含赠送开关与 API 商店充值提示)
|
||||||
const rechargeConfig = ref({
|
const rechargeConfig = ref({
|
||||||
min_amount: '1.00',
|
min_amount: '1.00',
|
||||||
max_amount: '100000.00',
|
max_amount: '100000.00',
|
||||||
|
recharge_bonus_enabled: false,
|
||||||
|
api_store_recharge_tip: '',
|
||||||
alipay_recharge_bonus: []
|
alipay_recharge_bonus: []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 关闭赠送时的预设金额(仅展示金额,无赠送)
|
||||||
|
const PRESET_AMOUNTS_NO_BONUS = [1000, 5000, 10000]
|
||||||
|
|
||||||
|
// 预设金额选项:开启赠送用配置规则,关闭赠送用固定金额列表
|
||||||
|
const presetAmountOptions = computed(() => {
|
||||||
|
if (rechargeConfig.value.recharge_bonus_enabled && rechargeConfig.value.alipay_recharge_bonus?.length) {
|
||||||
|
return rechargeConfig.value.alipay_recharge_bonus.map((b) => ({
|
||||||
|
recharge_amount: b.recharge_amount,
|
||||||
|
bonus_amount: b.bonus_amount ?? 0
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return PRESET_AMOUNTS_NO_BONUS.map((amount) => ({ recharge_amount: amount, bonus_amount: 0 }))
|
||||||
|
})
|
||||||
|
|
||||||
// 对公转账信息
|
// 对公转账信息
|
||||||
const transferInfo = ref({
|
const transferInfo = ref({
|
||||||
bankName: '中国银行股份有限公司海口美苑路支行',
|
bankName: '中国银行股份有限公司海口美苑路支行',
|
||||||
@@ -647,17 +681,21 @@ const loadRechargeConfig = async () => {
|
|||||||
// 直接调用API,不需要认证保护
|
// 直接调用API,不需要认证保护
|
||||||
const response = await financeApi.getRechargeConfig()
|
const response = await financeApi.getRechargeConfig()
|
||||||
if (response && response.data) {
|
if (response && response.data) {
|
||||||
rechargeConfig.value = response.data || {
|
rechargeConfig.value = {
|
||||||
min_amount: '50.00',
|
...response.data,
|
||||||
max_amount: '100000.00',
|
min_amount: response.data?.min_amount ?? '50.00',
|
||||||
alipay_recharge_bonus: []
|
max_amount: response.data?.max_amount ?? '100000.00',
|
||||||
|
recharge_bonus_enabled: response.data?.recharge_bonus_enabled ?? false,
|
||||||
|
api_store_recharge_tip: response.data?.api_store_recharge_tip ?? '',
|
||||||
|
alipay_recharge_bonus: response.data?.alipay_recharge_bonus ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置默认选中的预设金额(选择第一个赠送规则)
|
// 设置默认选中的预设金额:有赠送规则选第一条,否则选第一个预设金额(如 1000)
|
||||||
if (rechargeConfig.value.alipay_recharge_bonus && rechargeConfig.value.alipay_recharge_bonus.length > 0) {
|
const options = presetAmountOptions.value
|
||||||
const firstBonus = rechargeConfig.value.alipay_recharge_bonus[0]
|
if (options && options.length > 0) {
|
||||||
selectedPresetAmount.value = firstBonus.recharge_amount
|
const first = options[0]
|
||||||
const amountStr = firstBonus.recharge_amount.toString()
|
selectedPresetAmount.value = first.recharge_amount
|
||||||
|
const amountStr = first.recharge_amount.toString()
|
||||||
alipayForm.amount = amountStr
|
alipayForm.amount = amountStr
|
||||||
wechatForm.amount = amountStr
|
wechatForm.amount = amountStr
|
||||||
}
|
}
|
||||||
@@ -734,16 +772,15 @@ const selectCustomAmount = () => {
|
|||||||
wechatForm.amount = '' // 清空微信金额输入框
|
wechatForm.amount = '' // 清空微信金额输入框
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据充值金额获取赠送金额
|
// 根据充值金额获取赠送金额(关闭赠送时恒为 0)
|
||||||
const getBonusAmount = (rechargeAmount) => {
|
const getBonusAmount = (rechargeAmount) => {
|
||||||
if (!rechargeAmount || !rechargeConfig.value.alipay_recharge_bonus) {
|
if (!rechargeConfig.value.recharge_bonus_enabled || !rechargeAmount || !rechargeConfig.value.alipay_recharge_bonus?.length) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const amount = parseFloat(rechargeAmount)
|
const amount = parseFloat(rechargeAmount)
|
||||||
const bonusRules = rechargeConfig.value.alipay_recharge_bonus
|
const bonusRules = rechargeConfig.value.alipay_recharge_bonus
|
||||||
|
|
||||||
// 按充值金额从高到低排序,找到第一个匹配的赠送规则
|
|
||||||
for (let i = bonusRules.length - 1; i >= 0; i--) {
|
for (let i = bonusRules.length - 1; i >= 0; i--) {
|
||||||
const rule = bonusRules[i]
|
const rule = bonusRules[i]
|
||||||
if (amount >= rule.recharge_amount) {
|
if (amount >= rule.recharge_amount) {
|
||||||
@@ -754,15 +791,14 @@ const getBonusAmount = (rechargeAmount) => {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前预设金额的赠送金额
|
// 获取当前预设金额的赠送金额(关闭赠送时恒为 0)
|
||||||
const getCurrentBonusAmount = () => {
|
const getCurrentBonusAmount = () => {
|
||||||
|
if (!rechargeConfig.value.recharge_bonus_enabled) return 0
|
||||||
if (selectedPresetAmount.value === 'custom') {
|
if (selectedPresetAmount.value === 'custom') {
|
||||||
// 根据当前选择的充值方式获取金额
|
|
||||||
const currentAmount = selectedMethod.value === 'wechat' ? wechatForm.amount : alipayForm.amount
|
const currentAmount = selectedMethod.value === 'wechat' ? wechatForm.amount : alipayForm.amount
|
||||||
return getBonusAmount(currentAmount)
|
return getBonusAmount(currentAmount)
|
||||||
}
|
}
|
||||||
|
const bonus = rechargeConfig.value.alipay_recharge_bonus?.find(
|
||||||
const bonus = rechargeConfig.value.alipay_recharge_bonus.find(
|
|
||||||
(item) => item.recharge_amount === selectedPresetAmount.value
|
(item) => item.recharge_amount === selectedPresetAmount.value
|
||||||
)
|
)
|
||||||
return bonus ? parseFloat(bonus.bonus_amount) : 0
|
return bonus ? parseFloat(bonus.bonus_amount) : 0
|
||||||
@@ -770,26 +806,25 @@ const getCurrentBonusAmount = () => {
|
|||||||
|
|
||||||
// 获取自定义金额的赠送文本
|
// 获取自定义金额的赠送文本
|
||||||
const getCustomBonusText = () => {
|
const getCustomBonusText = () => {
|
||||||
|
if (!rechargeConfig.value.recharge_bonus_enabled) return '暂无'
|
||||||
if (selectedPresetAmount.value === 'custom') {
|
if (selectedPresetAmount.value === 'custom') {
|
||||||
return '根据实际充值金额计算'
|
return '根据实际充值金额计算'
|
||||||
}
|
}
|
||||||
return '0.00'
|
return '0.00'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取自定义金额的总到账金额
|
// 获取自定义金额的总到账金额(关闭赠送时仅为本金)
|
||||||
const getCustomTotalAmount = () => {
|
const getCustomTotalAmount = () => {
|
||||||
if (selectedPresetAmount.value === 'custom') {
|
if (selectedPresetAmount.value === 'custom') {
|
||||||
// 根据当前选择的充值方式获取金额
|
|
||||||
const currentAmount = selectedMethod.value === 'wechat' ? wechatForm.amount : alipayForm.amount
|
const currentAmount = selectedMethod.value === 'wechat' ? wechatForm.amount : alipayForm.amount
|
||||||
const amount = parseFloat(currentAmount || 0)
|
const amount = parseFloat(currentAmount || 0)
|
||||||
const bonus = getBonusAmount(amount)
|
const bonus = getBonusAmount(amount)
|
||||||
return formatPrice(amount + bonus)
|
return formatPrice(amount + bonus)
|
||||||
}
|
}
|
||||||
|
const item = presetAmountOptions.value.find((i) => i.recharge_amount === selectedPresetAmount.value)
|
||||||
const bonus = rechargeConfig.value.alipay_recharge_bonus.find(
|
if (!item) return '0.00'
|
||||||
(item) => item.recharge_amount === selectedPresetAmount.value
|
const bonus = rechargeConfig.value.recharge_bonus_enabled ? item.bonus_amount : 0
|
||||||
)
|
return formatPrice(parseFloat(item.recharge_amount) + parseFloat(bonus))
|
||||||
return bonus ? formatPrice(parseFloat(bonus.recharge_amount) + parseFloat(bonus.bonus_amount)) : '0.00'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 支付宝充值
|
// 支付宝充值
|
||||||
|
|||||||
555
src/pages/finance/purchase-records/index.vue
Normal file
555
src/pages/finance/purchase-records/index.vue
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
<template>
|
||||||
|
<ListPageLayout
|
||||||
|
title="购买记录"
|
||||||
|
subtitle="查看您的所有购买记录"
|
||||||
|
>
|
||||||
|
<template #filters>
|
||||||
|
<FilterSection>
|
||||||
|
<FilterItem label="支付类型">
|
||||||
|
<el-select
|
||||||
|
v-model="filters.payment_type"
|
||||||
|
placeholder="选择支付类型"
|
||||||
|
clearable
|
||||||
|
@change="handleFilterChange"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<el-option label="支付宝" value="alipay" />
|
||||||
|
<el-option label="微信" value="wechat" />
|
||||||
|
<el-option label="免费" value="free" />
|
||||||
|
</el-select>
|
||||||
|
</FilterItem>
|
||||||
|
|
||||||
|
<FilterItem label="支付渠道">
|
||||||
|
<el-select
|
||||||
|
v-model="filters.pay_channel"
|
||||||
|
placeholder="选择支付渠道"
|
||||||
|
clearable
|
||||||
|
@change="handleFilterChange"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<el-option label="支付宝" value="alipay" />
|
||||||
|
<el-option label="微信" value="wechat" />
|
||||||
|
</el-select>
|
||||||
|
</FilterItem>
|
||||||
|
|
||||||
|
<FilterItem label="订单状态">
|
||||||
|
<el-select
|
||||||
|
v-model="filters.status"
|
||||||
|
placeholder="选择订单状态"
|
||||||
|
clearable
|
||||||
|
@change="handleFilterChange"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<el-option label="已创建" value="created" />
|
||||||
|
<el-option label="已支付" value="paid" />
|
||||||
|
<el-option label="支付失败" value="failed" />
|
||||||
|
<el-option label="已取消" value="cancelled" />
|
||||||
|
<el-option label="已退款" value="refunded" />
|
||||||
|
<el-option label="已关闭" value="closed" />
|
||||||
|
</el-select>
|
||||||
|
</FilterItem>
|
||||||
|
|
||||||
|
<FilterItem label="开始时间" class="col-span-1">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="filters.start_time"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="选择开始时间"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
@change="handleFilterChange"
|
||||||
|
class="w-full"
|
||||||
|
:size="isMobile ? 'small' : 'default'"
|
||||||
|
/>
|
||||||
|
</FilterItem>
|
||||||
|
|
||||||
|
<FilterItem label="结束时间" class="col-span-1">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="filters.end_time"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="选择结束时间"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
@change="handleFilterChange"
|
||||||
|
class="w-full"
|
||||||
|
:size="isMobile ? 'small' : 'default'"
|
||||||
|
/>
|
||||||
|
</FilterItem>
|
||||||
|
|
||||||
|
<template #stats>
|
||||||
|
共找到 {{ total }} 条购买记录
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #buttons>
|
||||||
|
<el-button @click="resetFilters">重置筛选</el-button>
|
||||||
|
<el-button type="primary" @click="loadRecords">应用筛选</el-button>
|
||||||
|
</template>
|
||||||
|
</FilterSection>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #table>
|
||||||
|
<div v-if="loading" class="flex justify-center items-center py-12">
|
||||||
|
<el-loading size="large" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="records.length === 0" class="text-center py-12">
|
||||||
|
<el-empty description="暂无购买记录" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div class="table-container">
|
||||||
|
<el-table
|
||||||
|
:data="records"
|
||||||
|
style="width: 100%"
|
||||||
|
:header-cell-style="{
|
||||||
|
background: '#f8fafc',
|
||||||
|
color: '#475569',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '14px'
|
||||||
|
}"
|
||||||
|
:cell-style="{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#1e293b'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<el-table-column prop="order_no" label="订单号">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="text-gray-500">商户订单:</span>
|
||||||
|
<span class="font-mono">{{ row.order_no }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.trade_no" class="text-sm">
|
||||||
|
<span class="text-gray-500">交易号:</span>
|
||||||
|
<span class="font-mono">{{ row.trade_no }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="product_name" label="产品名称" width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-sm font-medium">{{ row.product_name }}</div>
|
||||||
|
<div class="text-xs text-gray-500">{{ row.product_code }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="amount" label="订单金额" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="font-medium text-green-600">
|
||||||
|
¥{{ formatMoney(row.amount) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="payment_type" label="支付类型" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag
|
||||||
|
:type="getPaymentTypeTagType(row.payment_type)"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ getPaymentTypeText(row.payment_type) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="pay_channel" label="支付渠道" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag
|
||||||
|
:type="getPayChannelTagType(row.pay_channel)"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ getPayChannelText(row.pay_channel) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="status" label="订单状态" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag
|
||||||
|
:type="getStatusTagType(row.status)"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ getStatusText(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="pay_time" label="支付时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="text-sm">
|
||||||
|
<div v-if="row.pay_time" class="text-gray-900">{{ formatDate(row.pay_time) }}</div>
|
||||||
|
<div v-if="row.pay_time" class="text-gray-500">{{ formatTime(row.pay_time) }}</div>
|
||||||
|
<div v-else class="text-gray-400">-</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="text-sm">
|
||||||
|
<div class="text-gray-900">{{ formatDate(row.created_at) }}</div>
|
||||||
|
<div class="text-gray-500">{{ formatTime(row.created_at) }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #pagination>
|
||||||
|
<div class="pagination-wrapper">
|
||||||
|
<el-pagination
|
||||||
|
v-if="total > 0"
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListPageLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { financeApi } from '@/api'
|
||||||
|
import FilterItem from '@/components/common/FilterItem.vue'
|
||||||
|
import FilterSection from '@/components/common/FilterSection.vue'
|
||||||
|
import ListPageLayout from '@/components/common/ListPageLayout.vue'
|
||||||
|
import { useMobileTable } from '@/composables/useMobileTable'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
// 移动端检测
|
||||||
|
const { isMobile } = useMobileTable()
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const loading = ref(false)
|
||||||
|
const records = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
|
||||||
|
// 筛选条件
|
||||||
|
const filters = reactive({
|
||||||
|
payment_type: '',
|
||||||
|
pay_channel: '',
|
||||||
|
status: '',
|
||||||
|
start_time: '',
|
||||||
|
end_time: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 搜索防抖定时器
|
||||||
|
let searchTimer = null
|
||||||
|
|
||||||
|
// 加载购买记录
|
||||||
|
const loadRecords = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
// 构建参数,过滤掉空值
|
||||||
|
const params = {
|
||||||
|
page: currentPage.value,
|
||||||
|
page_size: pageSize.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只添加非空的筛选条件
|
||||||
|
Object.keys(filters).forEach(key => {
|
||||||
|
const value = filters[key]
|
||||||
|
if (value !== '' && value !== null && value !== undefined) {
|
||||||
|
params[key] = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await financeApi.getUserPurchaseRecords(params)
|
||||||
|
records.value = response.data?.items || []
|
||||||
|
total.value = response.data?.total || 0
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载购买记录失败:', error)
|
||||||
|
ElMessage.error('加载购买记录失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化金额
|
||||||
|
const formatMoney = (amount) => {
|
||||||
|
if (!amount) return '0.00'
|
||||||
|
const num = parseFloat(amount)
|
||||||
|
if (isNaN(num)) return '0.00'
|
||||||
|
return num.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (date) => {
|
||||||
|
if (!date) return '-'
|
||||||
|
return new Date(date).toLocaleDateString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (date) => {
|
||||||
|
if (!date) return '-'
|
||||||
|
return new Date(date).toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取支付类型标签样式
|
||||||
|
const getPaymentTypeTagType = (type) => {
|
||||||
|
const typeMap = {
|
||||||
|
alipay: 'primary',
|
||||||
|
wechat: 'success',
|
||||||
|
free: 'info'
|
||||||
|
}
|
||||||
|
return typeMap[type] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取支付类型文本
|
||||||
|
const getPaymentTypeText = (type) => {
|
||||||
|
const typeMap = {
|
||||||
|
alipay: '支付宝',
|
||||||
|
wechat: '微信',
|
||||||
|
free: '免费'
|
||||||
|
}
|
||||||
|
return typeMap[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取支付渠道标签样式
|
||||||
|
const getPayChannelTagType = (channel) => {
|
||||||
|
const channelMap = {
|
||||||
|
alipay: 'primary',
|
||||||
|
wechat: 'success'
|
||||||
|
}
|
||||||
|
return channelMap[channel] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取支付渠道文本
|
||||||
|
const getPayChannelText = (channel) => {
|
||||||
|
const channelMap = {
|
||||||
|
alipay: '支付宝',
|
||||||
|
wechat: '微信'
|
||||||
|
}
|
||||||
|
return channelMap[channel] || channel
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取状态标签样式
|
||||||
|
const getStatusTagType = (status) => {
|
||||||
|
const statusMap = {
|
||||||
|
created: 'info',
|
||||||
|
paid: 'success',
|
||||||
|
failed: 'danger',
|
||||||
|
cancelled: 'warning',
|
||||||
|
refunded: 'info',
|
||||||
|
closed: 'info'
|
||||||
|
}
|
||||||
|
return statusMap[status] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取状态文本
|
||||||
|
const getStatusText = (status) => {
|
||||||
|
const statusMap = {
|
||||||
|
created: '已创建',
|
||||||
|
paid: '已支付',
|
||||||
|
failed: '支付失败',
|
||||||
|
cancelled: '已取消',
|
||||||
|
refunded: '已退款',
|
||||||
|
closed: '已关闭'
|
||||||
|
}
|
||||||
|
return statusMap[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理筛选变化
|
||||||
|
const handleFilterChange = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
loadRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置筛选
|
||||||
|
const resetFilters = () => {
|
||||||
|
Object.keys(filters).forEach(key => {
|
||||||
|
filters[key] = ''
|
||||||
|
})
|
||||||
|
currentPage.value = 1
|
||||||
|
loadRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理分页大小变化
|
||||||
|
const handleSizeChange = (size) => {
|
||||||
|
pageSize.value = size
|
||||||
|
currentPage.value = 1
|
||||||
|
loadRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理当前页变化
|
||||||
|
const handleCurrentChange = (page) => {
|
||||||
|
currentPage.value = page
|
||||||
|
loadRecords()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面加载时获取数据
|
||||||
|
onMounted(() => {
|
||||||
|
loadRecords()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 表格样式优化 */
|
||||||
|
:deep(.el-table) {
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table th) {
|
||||||
|
background: #f8fafc !important;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table td) {
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table tr:hover > td) {
|
||||||
|
background: #f8fafc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格容器 */
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分页器包装器 */
|
||||||
|
.pagination-wrapper {
|
||||||
|
padding: 16px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.stat-item {
|
||||||
|
padding: 12px 16px;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格在移动端优化 */
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table) {
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table th),
|
||||||
|
:deep(.el-table td) {
|
||||||
|
padding: 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table .cell) {
|
||||||
|
padding: 0 4px;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分页组件在移动端优化 */
|
||||||
|
.pagination-wrapper {
|
||||||
|
padding: 12px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrapper :deep(.el-pagination) {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
min-width: fit-content;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrapper :deep(.el-pagination__sizes) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrapper :deep(.el-pagination__total) {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrapper :deep(.el-pagination__jump) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrapper :deep(.el-pager li) {
|
||||||
|
min-width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
margin: 0 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrapper :deep(.btn-prev),
|
||||||
|
.pagination-wrapper :deep(.btn-next) {
|
||||||
|
min-width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 超小屏幕进一步优化 */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.pagination-wrapper {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrapper :deep(.el-pagination__total) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrapper :deep(.el-pagination) {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrapper :deep(.el-pager li) {
|
||||||
|
min-width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
line-height: 28px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrapper :deep(.btn-prev),
|
||||||
|
.pagination-wrapper :deep(.btn-next) {
|
||||||
|
min-width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
line-height: 28px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -51,6 +51,28 @@
|
|||||||
>
|
>
|
||||||
前往在线调试
|
前往在线调试
|
||||||
</el-button>
|
</el-button>
|
||||||
|
|
||||||
|
<el-button
|
||||||
|
v-if="product?.is_package && product?.sell_ui_component && showDownloadReportButton"
|
||||||
|
:size="isMobile ? 'small' : 'default'"
|
||||||
|
type="success"
|
||||||
|
@click="handleDownloadReport"
|
||||||
|
:loading="downloadingReport"
|
||||||
|
>
|
||||||
|
<el-icon><Download /></el-icon>
|
||||||
|
{{ canDownloadReport ? '下载示例报告' : '购买示例报告' }}
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<!-- 手动检查支付状态按钮 -->
|
||||||
|
<el-button
|
||||||
|
v-if="currentReportOrderId && !canDownloadReport"
|
||||||
|
:size="isMobile ? 'small' : 'default'"
|
||||||
|
type="info"
|
||||||
|
@click="checkPaymentStatusManually"
|
||||||
|
:loading="checkingPaymentStatus"
|
||||||
|
>
|
||||||
|
检查支付状态
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -274,6 +296,82 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 支付对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="reportPaymentDialogVisible"
|
||||||
|
title="购买示例报告"
|
||||||
|
width="500px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<div v-if="reportDownloadInfo">
|
||||||
|
<el-alert
|
||||||
|
title="购买说明"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
style="margin-bottom: 20px;"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<p>您正在购买<strong>{{ reportDownloadInfo.product_name }}</strong>的示例报告</p>
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
|
<div class="component-list">
|
||||||
|
<h4>包含的组件</h4>
|
||||||
|
<div v-if="reportDownloadInfo.sub_products && reportDownloadInfo.sub_products.length > 0" class="component-tags">
|
||||||
|
<el-tag
|
||||||
|
v-for="item in reportDownloadInfo.sub_products"
|
||||||
|
:key="item.product_id"
|
||||||
|
:type="item.is_purchased ? 'success' : 'primary'"
|
||||||
|
class="component-tag"
|
||||||
|
>
|
||||||
|
{{ item.product_code }}
|
||||||
|
<span v-if="item.is_purchased" class="downloaded-indicator">✓</span>
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div v-else class="no-components">
|
||||||
|
暂无组件信息
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="price-info" v-if="currentUIComponentPrice && !canDownloadReport">
|
||||||
|
<h4>价格信息</h4>
|
||||||
|
<div class="price-summary">
|
||||||
|
<div class="price-row">
|
||||||
|
<span>价格:</span>
|
||||||
|
<span class="total-price">¥{{ formatPrice(currentUIComponentPrice) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="payment-methods">
|
||||||
|
<h4>支付方式</h4>
|
||||||
|
<div class="payment-buttons">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
@click="createReportPaymentOrder('alipay')"
|
||||||
|
>
|
||||||
|
<el-icon><svg viewBox="0 0 1024 1024" width="18" height="18"><path d="M866.688 166.4c-14.336-14.336-33.792-22.528-54.272-22.528H200.704c-20.48 0-39.936 8.192-54.272 22.528S124.928 200.192 124.928 220.672v611.328c0 20.48 7.168 39.936 22.528 54.272s33.792 22.528 54.272 22.528h611.328c20.48 0 39.936-7.168 54.272-22.528s22.528-33.792 22.528-54.272V220.672c0-20.48-8.192-39.936-22.528-54.272zM512 310.272c85.504 0 155.648 70.144 155.648 155.648S597.504 621.568 512 621.568 356.352 551.424 356.352 465.92 426.496 310.272 512 310.272z m172.032 400.384c0 20.48-7.168 39.936-22.528 54.272s-33.792 22.528-54.272 22.528H416.768c-20.48 0-39.936-7.168-54.272-22.528s-22.528-33.792-22.528-54.272v-90.112c0-20.48 7.168-39.936 22.528-54.272s33.792-22.528 54.272-22.528h190.464c20.48 0 39.936 7.168 54.272 22.528s22.528 33.792 22.528 54.272v90.112z" fill="#1677FF"/></svg></el-icon>
|
||||||
|
支付宝支付
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="success"
|
||||||
|
size="large"
|
||||||
|
@click="createReportPaymentOrder('wechat')"
|
||||||
|
>
|
||||||
|
<el-icon><svg viewBox="0 0 1024 1024" width="18" height="18"><path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm189.5 301.7c-8 14.4-21.7 26.8-41.1 37.4-19.4 10.6-41.9 15.9-67.6 15.9-20.7 0-39.5-3.6-56.3-10.9-16.8-7.3-31-17.6-42.6-31-11.6-13.4-20.6-29-26.9-46.8-6.3-17.8-9.5-36.9-9.5-57.3 0-22.4 3.9-43.2 11.8-62.3 7.9-19.1 19.1-35.7 33.6-49.8 14.5-14.1 31.5-25.2 51-33.3 19.5-8.1 40.5-12.1 63.1-12.1 22.6 0 43.6 4 63.1 12.1 19.5 8.1 36.5 19.2 51 33.3 14.5 14.1 25.7 30.7 33.6 49.8 7.9 19.1 11.8 39.9 11.8 62.3 0 20.4-3.1 39.5-9.5 57.3zm-189.5 20.2c19.1 0 35.9 5.5 50.3 16.4 14.4 10.9 21.6 25.5 21.6 43.6 0 18.2-7.2 32.7-21.6 43.6-14.4 10.9-31.2 16.4-50.3 16.4s-35.9-5.5-50.3-16.4c-14.4-10.9-21.6-25.5-21.6-43.6 0-18.2 7.2-32.7 21.6-43.6 14.4-10.9 31.2-16.4 50.3-16.4z" fill="#07C160"/></svg></el-icon>
|
||||||
|
微信支付
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="reportPaymentDialogVisible = false">取消</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -281,8 +379,9 @@
|
|||||||
import { productApi, subscriptionApi } from '@/api'
|
import { productApi, subscriptionApi } from '@/api'
|
||||||
import { useMobileTable } from '@/composables/useMobileTable'
|
import { useMobileTable } from '@/composables/useMobileTable'
|
||||||
import { DocumentCopy, Download } from '@element-plus/icons-vue'
|
import { DocumentCopy, Download } from '@element-plus/icons-vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElLoading, ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
|
import { h } from 'vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -297,8 +396,18 @@ const userSubscriptions = ref([])
|
|||||||
const subscribing = ref(false)
|
const subscribing = ref(false)
|
||||||
const cancelling = ref(false)
|
const cancelling = ref(false)
|
||||||
const downloading = ref(false)
|
const downloading = ref(false)
|
||||||
|
const downloadingReport = ref(false)
|
||||||
const activeTab = ref('content')
|
const activeTab = ref('content')
|
||||||
const currentTimestamp = ref('')
|
const currentTimestamp = ref('')
|
||||||
|
const showDownloadReportButton = ref(false)
|
||||||
|
const canDownloadReport = ref(false)
|
||||||
|
const reportDownloadInfo = ref(null)
|
||||||
|
const reportPaymentDialogVisible = ref(false)
|
||||||
|
const reportPaymentStatus = ref('pending')
|
||||||
|
const currentReportOrderId = ref('')
|
||||||
|
const checkingPaymentStatus = ref(false)
|
||||||
|
const isCheckingWechatPayment = ref(false)
|
||||||
|
let wechatOrderPollTimer = null
|
||||||
|
|
||||||
// DOM 引用
|
// DOM 引用
|
||||||
const basicInfoRef = ref(null)
|
const basicInfoRef = ref(null)
|
||||||
@@ -319,22 +428,76 @@ const currentSubscription = computed(() => {
|
|||||||
return userSubscriptions.value.find(sub => sub.product_id === product.value.id)
|
return userSubscriptions.value.find(sub => sub.product_id === product.value.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 获取当前产品的UI组件价格(优先使用订阅中的价格)
|
||||||
|
const currentUIComponentPrice = computed(() => {
|
||||||
|
if (currentSubscription.value && currentSubscription.value.ui_component_price) {
|
||||||
|
return currentSubscription.value.ui_component_price
|
||||||
|
}
|
||||||
|
return product.value?.ui_component_price || 0
|
||||||
|
})
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadUserSubscriptions()
|
loadUserSubscriptions()
|
||||||
loadProductDetail().then(() => {
|
loadProductDetail().then(() => {
|
||||||
addCollapsibleFeatures()
|
addCollapsibleFeatures()
|
||||||
|
// 检查是否可以下载示例报告
|
||||||
|
if (product.value?.is_package) {
|
||||||
|
checkReportDownloadAvailability()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
startTimestampUpdate()
|
startTimestampUpdate()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 组件卸载时清理定时器
|
// 组件卸载时清理定时器
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
// 清理时间戳更新定时器
|
||||||
if (timestampTimer.value) {
|
if (timestampTimer.value) {
|
||||||
clearInterval(timestampTimer.value)
|
clearInterval(timestampTimer.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清理支付状态检查定时器
|
||||||
|
if (window.paymentCheckIntervals) {
|
||||||
|
Object.keys(window.paymentCheckIntervals).forEach(orderId => {
|
||||||
|
clearInterval(window.paymentCheckIntervals[orderId])
|
||||||
|
})
|
||||||
|
window.paymentCheckIntervals = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理微信支付轮询定时器
|
||||||
|
stopWechatPaymentPolling()
|
||||||
|
|
||||||
|
// 关闭所有消息框
|
||||||
|
ElMessageBox.close()
|
||||||
|
|
||||||
|
// 关闭所有加载提示
|
||||||
|
const loadingInstance = ElLoading.service()
|
||||||
|
loadingInstance.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 重置支付状态检查
|
||||||
|
const resetPaymentStatusCheck = () => {
|
||||||
|
// 停止所有定时器
|
||||||
|
if (window.paymentCheckIntervals) {
|
||||||
|
Object.keys(window.paymentCheckIntervals).forEach(orderId => {
|
||||||
|
clearInterval(window.paymentCheckIntervals[orderId])
|
||||||
|
})
|
||||||
|
window.paymentCheckIntervals = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止微信支付轮询
|
||||||
|
stopWechatPaymentPolling()
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
isCheckingWechatPayment.value = false
|
||||||
|
checkingPaymentStatus.value = false
|
||||||
|
|
||||||
|
// 关闭所有加载和消息框
|
||||||
|
ElMessageBox.close()
|
||||||
|
const loadingInstance = ElLoading.service()
|
||||||
|
loadingInstance.close()
|
||||||
|
}
|
||||||
|
|
||||||
// 时间戳更新定时器
|
// 时间戳更新定时器
|
||||||
const timestampTimer = ref(null)
|
const timestampTimer = ref(null)
|
||||||
|
|
||||||
@@ -654,6 +817,29 @@ const openProductDetail = (productId) => {
|
|||||||
window.open(url, '_blank')
|
window.open(url, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 示例报告下载功能说明
|
||||||
|
*
|
||||||
|
* 功能概述:
|
||||||
|
* - 只有组合包产品才能下载示例报告
|
||||||
|
* - 根据用户是否已下载过子产品,计算价格(已下载的产品不需要再次付费)
|
||||||
|
* - 支持微信和支付宝两种支付方式
|
||||||
|
* - 支付成功后可以下载包含所有子产品UI组件和示例数据的ZIP文件
|
||||||
|
*
|
||||||
|
* 流程说明:
|
||||||
|
* 1. 检查产品是否为组合包,以及是否可以下载示例报告
|
||||||
|
* 2. 如果可以直接下载(免费或已付费),直接提供下载
|
||||||
|
* 3. 如果需要付费,显示支付对话框,包含价格明细和支付方式选择
|
||||||
|
* 4. 用户选择支付方式后,创建支付订单
|
||||||
|
* 5. 定期检查支付状态,成功后自动下载示例报告
|
||||||
|
*
|
||||||
|
* 注意事项:
|
||||||
|
* - 微信支付显示二维码,需要用户扫码支付
|
||||||
|
* - 支付宝支付会跳转到支付宝页面
|
||||||
|
* - 支付状态检查会在后台持续进行,直到成功或超时
|
||||||
|
* - 下载的ZIP文件包含UI组件和示例数据
|
||||||
|
*/
|
||||||
|
|
||||||
// 获取默认的请求方式内容
|
// 获取默认的请求方式内容
|
||||||
const getDefaultBasicInfo = () => {
|
const getDefaultBasicInfo = () => {
|
||||||
return `## 请求头
|
return `## 请求头
|
||||||
@@ -825,6 +1011,542 @@ const downloadMarkdown = (type) => {
|
|||||||
|
|
||||||
ElMessage.success('文档下载成功')
|
ElMessage.success('文档下载成功')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查报告下载可用性
|
||||||
|
const checkReportDownloadAvailability = async () => {
|
||||||
|
console.log('[checkReportDownloadAvailability] 开始检查下载可用性')
|
||||||
|
console.log('[checkReportDownloadAvailability] 产品信息:', product.value)
|
||||||
|
console.log('[checkReportDownloadAvailability] sell_ui_component:', product.value?.sell_ui_component)
|
||||||
|
|
||||||
|
if (!product.value?.id || !product.value?.is_package) {
|
||||||
|
console.log('[checkReportDownloadAvailability] 产品不存在或不是组合包,隐藏下载按钮')
|
||||||
|
showDownloadReportButton.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!product.value?.sell_ui_component) {
|
||||||
|
console.log('[checkReportDownloadAvailability] 产品不出售UI组件,隐藏下载按钮')
|
||||||
|
showDownloadReportButton.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[checkReportDownloadAvailability] 检查组件报告可用性,产品ID:', product.value.id)
|
||||||
|
const response = await productApi.checkComponentReportAvailability(product.value.id)
|
||||||
|
console.log('[checkReportDownloadAvailability] 检查可用性响应:', response)
|
||||||
|
const { can_download } = response.data
|
||||||
|
|
||||||
|
showDownloadReportButton.value = true
|
||||||
|
|
||||||
|
console.log('[checkReportDownloadAvailability] 获取组件报告信息,产品ID:', product.value.id)
|
||||||
|
// 获取下载信息和价格
|
||||||
|
const infoResponse = await productApi.getComponentReportInfo(product.value.id)
|
||||||
|
console.log('[checkReportDownloadAvailability] 组件报告信息响应:', infoResponse)
|
||||||
|
|
||||||
|
// 检查响应数据结构
|
||||||
|
if (infoResponse.data && infoResponse.data.data) {
|
||||||
|
// 如果响应是嵌套结构,提取实际数据
|
||||||
|
reportDownloadInfo.value = infoResponse.data.data
|
||||||
|
} else if (infoResponse.data) {
|
||||||
|
// 直接使用响应数据
|
||||||
|
reportDownloadInfo.value = infoResponse.data
|
||||||
|
} else {
|
||||||
|
console.error('[checkReportDownloadAvailability] 响应数据格式不正确:', infoResponse)
|
||||||
|
showDownloadReportButton.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
canDownloadReport.value = reportDownloadInfo.value.can_download
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[checkReportDownloadAvailability] 检查报告下载可用性失败:', error)
|
||||||
|
console.error('[checkReportDownloadAvailability] 错误详情:', {
|
||||||
|
message: error.message,
|
||||||
|
response: error.response?.data,
|
||||||
|
status: error.response?.status
|
||||||
|
})
|
||||||
|
showDownloadReportButton.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理下载报告按钮点击
|
||||||
|
const handleDownloadReport = async () => {
|
||||||
|
console.log('[handleDownloadReport] 开始处理下载报告按钮点击')
|
||||||
|
console.log('[handleDownloadReport] 产品信息:', product.value)
|
||||||
|
|
||||||
|
if (!product.value?.id) {
|
||||||
|
console.log('[handleDownloadReport] 产品信息不存在')
|
||||||
|
ElMessage.warning('产品信息不存在')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果可以直接下载
|
||||||
|
if (canDownloadReport.value) {
|
||||||
|
console.log('[handleDownloadReport] 可以直接下载,开始下载')
|
||||||
|
downloadComponentReport()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[handleDownloadReport] 需要购买,检查下载信息是否存在')
|
||||||
|
// 需要购买,显示支付对话框
|
||||||
|
if (!reportDownloadInfo.value || !reportDownloadInfo.value.product_id) {
|
||||||
|
console.log('[handleDownloadReport] 下载信息不存在或不完整,重新获取')
|
||||||
|
try {
|
||||||
|
const response = await productApi.getComponentReportInfo(product.value.id)
|
||||||
|
console.log('[handleDownloadReport] 获取到的下载信息:', response)
|
||||||
|
|
||||||
|
// 检查响应数据结构
|
||||||
|
if (response.data && response.data.data) {
|
||||||
|
// 如果响应是嵌套结构,提取实际数据
|
||||||
|
reportDownloadInfo.value = response.data.data
|
||||||
|
} else if (response.data) {
|
||||||
|
// 直接使用响应数据
|
||||||
|
reportDownloadInfo.value = response.data
|
||||||
|
} else {
|
||||||
|
console.error('[handleDownloadReport] 响应数据格式不正确:', response)
|
||||||
|
ElMessage.error('获取报告下载信息失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 详细日志记录数据结构
|
||||||
|
console.log('[handleDownloadReport] 下载信息详情:', {
|
||||||
|
产品ID: reportDownloadInfo.value.product_id,
|
||||||
|
产品名称: reportDownloadInfo.value.product_name,
|
||||||
|
是否组合包: reportDownloadInfo.value.is_package,
|
||||||
|
子产品数量: reportDownloadInfo.value.sub_products?.length || 0,
|
||||||
|
子产品列表: reportDownloadInfo.value.sub_products,
|
||||||
|
价格: currentUIComponentPrice.value,
|
||||||
|
价格来源: currentSubscription.value ? '用户订阅' : '产品默认',
|
||||||
|
可下载: reportDownloadInfo.value.can_download
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[handleDownloadReport] 获取报告下载信息失败:', error)
|
||||||
|
console.error('[handleDownloadReport] 错误详情:', {
|
||||||
|
message: error.message,
|
||||||
|
response: error.response?.data,
|
||||||
|
status: error.response?.status
|
||||||
|
})
|
||||||
|
ElMessage.error('获取报告下载信息失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[handleDownloadReport] 下载信息已存在,使用缓存数据')
|
||||||
|
// 详细日志记录数据结构
|
||||||
|
console.log('[handleDownloadReport] 缓存的下载信息详情:', {
|
||||||
|
产品ID: reportDownloadInfo.value.product_id,
|
||||||
|
产品名称: reportDownloadInfo.value.product_name,
|
||||||
|
是否组合包: reportDownloadInfo.value.is_package,
|
||||||
|
子产品数量: reportDownloadInfo.value.sub_products?.length || 0,
|
||||||
|
子产品列表: reportDownloadInfo.value.sub_products,
|
||||||
|
价格: currentUIComponentPrice.value,
|
||||||
|
价格来源: currentSubscription.value ? '用户订阅' : '产品默认',
|
||||||
|
可下载: reportDownloadInfo.value.can_download
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[handleDownloadReport] 显示支付对话框')
|
||||||
|
reportPaymentDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载组件报告
|
||||||
|
const downloadComponentReport = async () => {
|
||||||
|
if (!product.value?.id) {
|
||||||
|
ElMessage.warning('产品信息不存在')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadingReport.value = true
|
||||||
|
try {
|
||||||
|
// 获取子产品代码列表
|
||||||
|
let subProductCodes = []
|
||||||
|
if (reportDownloadInfo.value && reportDownloadInfo.value.sub_products && reportDownloadInfo.value.sub_products.length > 0) {
|
||||||
|
subProductCodes = reportDownloadInfo.value.sub_products.map(item => item.product_code)
|
||||||
|
console.log('[downloadComponentReport] 提取子产品代码列表:', subProductCodes)
|
||||||
|
} else {
|
||||||
|
console.log('[downloadComponentReport] 未找到子产品信息,使用空列表')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await productApi.generateAndDownloadComponentReport({
|
||||||
|
product_id: product.value.id,
|
||||||
|
sub_product_codes: subProductCodes
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建下载链接
|
||||||
|
// 直接从response中获取数据,不再检查Blob类型
|
||||||
|
const url = URL.createObjectURL(response.data)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `${product.value.name || '产品'}_示例报告.zip`
|
||||||
|
|
||||||
|
// 触发下载
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
document.body.removeChild(link)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
|
||||||
|
ElMessage.success('示例报告下载成功')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('下载示例报告失败:', error)
|
||||||
|
ElMessage.error('下载示例报告失败')
|
||||||
|
} finally {
|
||||||
|
downloadingReport.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建支付订单
|
||||||
|
const createReportPaymentOrder = async (paymentType) => {
|
||||||
|
console.log('[createReportPaymentOrder] 开始创建支付订单')
|
||||||
|
console.log('[createReportPaymentOrder] 支付类型:', paymentType)
|
||||||
|
console.log('[createReportPaymentOrder] 产品信息:', product.value)
|
||||||
|
|
||||||
|
if (!product.value?.id) {
|
||||||
|
console.log('[createReportPaymentOrder] 产品信息不存在')
|
||||||
|
ElMessage.warning('产品信息不存在')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 显示加载状态
|
||||||
|
const loadingInstance = ElLoading.service({
|
||||||
|
lock: true,
|
||||||
|
text: '正在创建支付订单,请稍候...',
|
||||||
|
background: 'rgba(0, 0, 0, 0.7)'
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[createReportPaymentOrder] 调用创建支付订单API')
|
||||||
|
const response = await productApi.createComponentReportPaymentOrder(product.value.id, {
|
||||||
|
payment_type: paymentType
|
||||||
|
})
|
||||||
|
console.log('[createReportPaymentOrder] 支付订单创建响应:', response)
|
||||||
|
|
||||||
|
// 关闭加载状态
|
||||||
|
loadingInstance.close()
|
||||||
|
|
||||||
|
// 根据响应拦截器的处理方式,正确访问嵌套的 data 对象
|
||||||
|
// 响应格式为 {code: 200, data: {...}}
|
||||||
|
const responseData = response.data.data || response.data
|
||||||
|
console.log('[createReportPaymentOrder] 解析响应数据:', responseData)
|
||||||
|
|
||||||
|
const { order_id, code_url, pay_url, payment_type } = responseData
|
||||||
|
|
||||||
|
currentReportOrderId.value = order_id
|
||||||
|
reportPaymentStatus.value = 'pending'
|
||||||
|
reportPaymentDialogVisible.value = false
|
||||||
|
|
||||||
|
// 根据支付类型处理
|
||||||
|
if (payment_type === 'wechat' && code_url) {
|
||||||
|
console.log('[createReportPaymentOrder] 微信支付,显示二维码')
|
||||||
|
// 显示微信支付二维码
|
||||||
|
showWechatPaymentQRCode(code_url, order_id)
|
||||||
|
} else if (payment_type === 'alipay' && pay_url) {
|
||||||
|
console.log('[createReportPaymentOrder] 支付宝支付,跳转到支付页面')
|
||||||
|
// 跳转到支付宝支付页面
|
||||||
|
window.open(pay_url, '_blank')
|
||||||
|
// 显示支付状态检查提示
|
||||||
|
ElMessage.info('已打开支付页面,请在支付完成后返回此页面')
|
||||||
|
// 开始检查支付状态
|
||||||
|
startPaymentStatusCheck(order_id)
|
||||||
|
} else {
|
||||||
|
console.error('[createReportPaymentOrder] 支付类型或支付链接不匹配', {
|
||||||
|
payment_type,
|
||||||
|
code_url: !!code_url,
|
||||||
|
pay_url: !!pay_url
|
||||||
|
})
|
||||||
|
ElMessage.error('支付方式不支持或支付链接获取失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[createReportPaymentOrder] 创建支付订单失败:', error)
|
||||||
|
console.error('[createReportPaymentOrder] 错误详情:', {
|
||||||
|
message: error.message,
|
||||||
|
response: error.response?.data,
|
||||||
|
status: error.response?.status
|
||||||
|
})
|
||||||
|
|
||||||
|
// 显示具体错误信息
|
||||||
|
const errorMessage = error.response?.data?.message || error.message || '创建支付订单失败'
|
||||||
|
ElMessage.error(errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示微信支付二维码
|
||||||
|
const showWechatPaymentQRCode = (codeUrl, orderId) => {
|
||||||
|
// 验证二维码URL是否有效
|
||||||
|
if (!codeUrl || typeof codeUrl !== 'string') {
|
||||||
|
ElMessage.error('微信支付二维码获取失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[showWechatPaymentQRCode] 准备显示二维码,URL:', codeUrl)
|
||||||
|
|
||||||
|
// 保存当前订单号用于轮询
|
||||||
|
currentReportOrderId.value = orderId
|
||||||
|
|
||||||
|
// 使用第三方二维码生成服务将微信支付URL转换为二维码图片
|
||||||
|
const qrCodeImageUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(codeUrl)}`
|
||||||
|
|
||||||
|
// 使用自定义对话框而不是ElMessageBox.alert,以确保HTML内容正确渲染
|
||||||
|
const qrDialog = ElMessageBox({
|
||||||
|
title: '微信扫码支付',
|
||||||
|
message: h('div', { style: 'text-align: center; padding: 20px;' }, [
|
||||||
|
h('h3', { style: 'margin-bottom: 20px;' }, '微信扫码支付'),
|
||||||
|
h('div', { style: 'margin-bottom: 20px;' }, '请使用微信扫描下方二维码完成支付'),
|
||||||
|
h('div', { style: 'display: flex; justify-content: center; margin-bottom: 20px;' }, [
|
||||||
|
h('img', {
|
||||||
|
src: qrCodeImageUrl,
|
||||||
|
alt: '微信支付二维码',
|
||||||
|
style: 'width: 200px; height: 200px; border: 1px solid #eee;',
|
||||||
|
onError: (e) => {
|
||||||
|
console.error('[showWechatPaymentQRCode] 二维码图片加载失败:', e)
|
||||||
|
// 尝试使用备用方案:显示一个链接,让用户点击打开微信
|
||||||
|
ElMessage({
|
||||||
|
message: '二维码加载失败,请尝试直接打开微信支付',
|
||||||
|
type: 'warning',
|
||||||
|
duration: 5000,
|
||||||
|
showClose: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新对话框内容,显示支付链接
|
||||||
|
instance.message = h('div', { style: 'text-align: center; padding: 20px;' }, [
|
||||||
|
h('h3', { style: 'margin-bottom: 20px;' }, '微信扫码支付'),
|
||||||
|
h('div', { style: 'margin-bottom: 20px;' }, '二维码生成失败,请点击下方链接打开微信支付'),
|
||||||
|
h('div', { style: 'margin-bottom: 20px; padding: 10px; border: 1px dashed #ccc; border-radius: 4px;' }, [
|
||||||
|
h('a', {
|
||||||
|
href: codeUrl,
|
||||||
|
onClick: (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
// 尝试打开微信支付链接
|
||||||
|
window.open(codeUrl, '_self')
|
||||||
|
},
|
||||||
|
style: 'color: #409EFF; text-decoration: underline; cursor: pointer;'
|
||||||
|
}, '点击打开微信支付')
|
||||||
|
]),
|
||||||
|
h('div', { style: 'font-size: 14px; color: #666;' }, [
|
||||||
|
h('p', `支付金额:¥${formatPrice(currentUIComponentPrice)}`),
|
||||||
|
h('p', '支付完成后,请点击"我已支付"按钮')
|
||||||
|
])
|
||||||
|
])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
h('div', { style: 'font-size: 14px; color: #666;' }, [
|
||||||
|
h('p', `支付金额:¥${formatPrice(currentUIComponentPrice.value || 0)}`),
|
||||||
|
h('p', '支付完成后,系统将自动检测支付状态')
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
confirmButtonText: '我已支付',
|
||||||
|
cancelButtonText: '支付遇到问题',
|
||||||
|
type: 'info',
|
||||||
|
showClose: false,
|
||||||
|
closeOnClickModal: false,
|
||||||
|
closeOnPressEscape: false,
|
||||||
|
beforeClose: (action, instance, done) => {
|
||||||
|
if (action === 'confirm') {
|
||||||
|
// 用户点击了"我已支付"按钮,开始检查支付状态
|
||||||
|
startPaymentStatusCheck(orderId)
|
||||||
|
} else {
|
||||||
|
// 用户点击了"支付遇到问题"按钮
|
||||||
|
ElMessage.warning('如果支付遇到问题,请稍后重试或联系客服')
|
||||||
|
}
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
// 对话框被关闭(通过取消按钮或其他方式)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 开始轮询微信支付状态
|
||||||
|
startWechatPaymentPolling(orderId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始检查支付状态
|
||||||
|
const startPaymentStatusCheck = (orderId) => {
|
||||||
|
if (!orderId) {
|
||||||
|
ElMessage.error('订单ID不存在')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示支付状态检查提示
|
||||||
|
const loadingInstance = ElLoading.service({
|
||||||
|
lock: false, // 不锁定屏幕,允许用户操作
|
||||||
|
text: '正在检查支付状态,请完成支付后等待(检查超过1分钟将自动刷新页面)...',
|
||||||
|
background: 'rgba(0, 0, 0, 0.5)'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 显示一个提示对话框,告知用户正在检查支付状态
|
||||||
|
ElMessageBox.alert(
|
||||||
|
'我们正在检测您的支付状态,支付完成后会自动为您下载示例报告。检查超过1分钟页面将自动刷新以获取最新状态。请勿关闭此页面。',
|
||||||
|
'支付状态检测',
|
||||||
|
{
|
||||||
|
confirmButtonText: '我知道了',
|
||||||
|
type: 'info',
|
||||||
|
showClose: false,
|
||||||
|
closeOnClickModal: false,
|
||||||
|
closeOnPressEscape: false
|
||||||
|
}
|
||||||
|
).catch(() => {
|
||||||
|
// 用户点击了确定按钮,继续检查
|
||||||
|
})
|
||||||
|
|
||||||
|
let checkCount = 0
|
||||||
|
const maxCheckCount = 60 // 最多检查3分钟(60次 * 3秒)
|
||||||
|
|
||||||
|
// 设置定时器检查支付状态
|
||||||
|
const checkInterval = setInterval(async () => {
|
||||||
|
checkCount++
|
||||||
|
|
||||||
|
// 更新提示文本
|
||||||
|
loadingInstance.text = `正在检查支付状态... (${Math.ceil(checkCount/20)}分钟/${Math.ceil(maxCheckCount/20)}分钟)`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await productApi.checkComponentReportPaymentStatus(orderId)
|
||||||
|
// 正确处理响应数据格式
|
||||||
|
const responseData = response.data.data || response.data
|
||||||
|
const { payment_status, can_download } = responseData
|
||||||
|
|
||||||
|
console.log('[startPaymentStatusCheck] 支付状态检查结果:', {
|
||||||
|
payment_status,
|
||||||
|
can_download,
|
||||||
|
check_count: checkCount
|
||||||
|
})
|
||||||
|
|
||||||
|
if (payment_status === 'success' && can_download) {
|
||||||
|
// 支付成功
|
||||||
|
clearInterval(checkInterval)
|
||||||
|
loadingInstance.close()
|
||||||
|
|
||||||
|
canDownloadReport.value = true
|
||||||
|
ElMessage.success('支付成功,现在可以下载示例报告了')
|
||||||
|
|
||||||
|
// 关闭之前的提示框
|
||||||
|
ElMessageBox.close()
|
||||||
|
|
||||||
|
// 自动下载
|
||||||
|
downloadComponentReport()
|
||||||
|
} else if (payment_status === 'failed') {
|
||||||
|
// 支付失败
|
||||||
|
clearInterval(checkInterval)
|
||||||
|
loadingInstance.close()
|
||||||
|
|
||||||
|
// 关闭之前的提示框
|
||||||
|
ElMessageBox.close()
|
||||||
|
|
||||||
|
ElMessage.error('支付失败,请重试')
|
||||||
|
} else if (payment_status === 'cancelled') {
|
||||||
|
// 支付取消
|
||||||
|
clearInterval(checkInterval)
|
||||||
|
loadingInstance.close()
|
||||||
|
// 关闭之前的提示框
|
||||||
|
ElMessageBox.close()
|
||||||
|
ElMessage.info('支付已取消')
|
||||||
|
} else if (checkCount >= 20) {
|
||||||
|
// 检查超过1分钟(20次 * 3秒)
|
||||||
|
clearInterval(checkInterval)
|
||||||
|
loadingInstance.close()
|
||||||
|
|
||||||
|
// 关闭之前的提示框
|
||||||
|
ElMessageBox.close()
|
||||||
|
|
||||||
|
ElMessage.warning('支付状态检查超过1分钟,页面将自动刷新以获取最新状态')
|
||||||
|
|
||||||
|
// 1秒后刷新页面
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload()
|
||||||
|
}, 1000)
|
||||||
|
} else if (checkCount >= maxCheckCount) {
|
||||||
|
// 超时(3分钟)
|
||||||
|
clearInterval(checkInterval)
|
||||||
|
loadingInstance.close()
|
||||||
|
|
||||||
|
// 关闭之前的提示框
|
||||||
|
ElMessageBox.close()
|
||||||
|
|
||||||
|
ElMessage.warning('支付状态检查超时,请刷新页面后检查')
|
||||||
|
|
||||||
|
// 显示手动检查选项
|
||||||
|
ElMessageBox.confirm(
|
||||||
|
'支付状态检查已超时,您可以选择继续等待或稍后手动检查支付状态。',
|
||||||
|
'支付状态检查',
|
||||||
|
{
|
||||||
|
confirmButtonText: '继续等待',
|
||||||
|
cancelButtonText: '稍后手动检查',
|
||||||
|
type: 'warning'
|
||||||
|
}
|
||||||
|
).then(() => {
|
||||||
|
// 继续等待,重置计数器
|
||||||
|
checkCount = 0
|
||||||
|
// 继续检查,不再设置超时
|
||||||
|
}).catch(() => {
|
||||||
|
// 用户选择稍后手动检查
|
||||||
|
ElMessage.info('您可以在页面中点击"下载示例报告"按钮来手动检查支付状态')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[startPaymentStatusCheck] 检查支付状态失败:', error)
|
||||||
|
console.error('[startPaymentStatusCheck] 错误详情:', {
|
||||||
|
message: error.message,
|
||||||
|
response: error.response?.data,
|
||||||
|
status: error.response?.status
|
||||||
|
})
|
||||||
|
|
||||||
|
// 不显示错误消息,避免打扰用户,继续检查
|
||||||
|
}
|
||||||
|
}, 3000) // 每3秒检查一次,减少服务器压力
|
||||||
|
|
||||||
|
// 将定时器ID存储在组件实例中,以便在组件卸载时清理
|
||||||
|
if (!window.paymentCheckIntervals) {
|
||||||
|
window.paymentCheckIntervals = {}
|
||||||
|
}
|
||||||
|
window.paymentCheckIntervals[orderId] = checkInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动检查支付状态
|
||||||
|
const checkPaymentStatusManually = async () => {
|
||||||
|
if (!currentReportOrderId.value) {
|
||||||
|
ElMessage.warning('没有正在处理的支付订单')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
checkingPaymentStatus.value = true
|
||||||
|
try {
|
||||||
|
const response = await productApi.checkComponentReportPaymentStatus(currentReportOrderId.value)
|
||||||
|
const { payment_status, can_download } = response.data
|
||||||
|
|
||||||
|
console.log('[checkPaymentStatusManually] 支付状态检查结果:', {
|
||||||
|
payment_status,
|
||||||
|
can_download
|
||||||
|
})
|
||||||
|
|
||||||
|
if (payment_status === 'success' && can_download) {
|
||||||
|
// 支付成功
|
||||||
|
canDownloadReport.value = true
|
||||||
|
ElMessage.success('支付成功,现在可以下载示例报告了')
|
||||||
|
|
||||||
|
// 自动下载
|
||||||
|
downloadComponentReport()
|
||||||
|
// 停止所有定时器
|
||||||
|
if (window.paymentCheckIntervals) {
|
||||||
|
Object.keys(window.paymentCheckIntervals).forEach(orderId => {
|
||||||
|
clearInterval(window.paymentCheckIntervals[orderId])
|
||||||
|
})
|
||||||
|
window.paymentCheckIntervals = {}
|
||||||
|
}
|
||||||
|
} else if (payment_status === 'failed') {
|
||||||
|
// 支付失败
|
||||||
|
ElMessage.error('支付失败,请重试')
|
||||||
|
} else if (payment_status === 'pending') {
|
||||||
|
// 支付中
|
||||||
|
ElMessage.info('支付仍在处理中,请稍后再次检查')
|
||||||
|
} else {
|
||||||
|
// 其他状态
|
||||||
|
ElMessage.warning(`支付状态: ${payment_status}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[checkPaymentStatusManually] 检查支付状态失败:', error)
|
||||||
|
ElMessage.error('检查支付状态失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
checkingPaymentStatus.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -1658,4 +2380,158 @@ const downloadMarkdown = (type) => {
|
|||||||
border: 1px solid rgba(226, 232, 240, 0.4);
|
border: 1px solid rgba(226, 232, 240, 0.4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 支付对话框样式 */
|
||||||
|
.component-list {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-list h4 {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-tag {
|
||||||
|
position: relative;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloaded-indicator {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-components {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: #909399;
|
||||||
|
background: #f5f7fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-info {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-info h4 {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-products-list {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-product-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-product-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-code {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-price {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-summary {
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.price-row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.price-row.discount {
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
.price-row.total {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px dashed #ebeef5;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.total-price {
|
||||||
|
color: #f56c6c;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.payment-methods {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.payment-methods h4 {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
.payment-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-buttons .el-button {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
/* 微信支付二维码样式 */
|
||||||
|
.el-message-box__message img {
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -550,14 +550,29 @@ const loadAlertSettings = async () => {
|
|||||||
try {
|
try {
|
||||||
// 调用API获取用户的余额预警设置
|
// 调用API获取用户的余额预警设置
|
||||||
const response = await balanceAlertApi.getUserAlertSettings()
|
const response = await balanceAlertApi.getUserAlertSettings()
|
||||||
|
|
||||||
|
// 添加日志追踪:接收到的原始数据
|
||||||
|
console.log('[余额预警] 接收到的原始数据:', JSON.stringify(response))
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
balanceAlertSettings.value = response.data
|
// 转换字段名,确保前端使用camelCase
|
||||||
originalAlertSettings.value = JSON.parse(JSON.stringify(response.data))
|
const transformedData = {
|
||||||
|
enabled: response.data.enabled,
|
||||||
|
threshold: response.data.threshold,
|
||||||
|
alertPhone: response.data.alert_phone || response.data.alertPhone || '' // 支持两种字段名,优先使用alert_phone
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加日志追踪:转换后的数据
|
||||||
|
console.log('[余额预警] 转换后的数据:', JSON.stringify(transformedData))
|
||||||
|
|
||||||
|
balanceAlertSettings.value = transformedData
|
||||||
|
originalAlertSettings.value = JSON.parse(JSON.stringify(transformedData))
|
||||||
} else {
|
} else {
|
||||||
|
console.error('[余额预警] 获取余额预警设置失败:', response.message)
|
||||||
ElMessage.error('获取余额预警设置失败')
|
ElMessage.error('获取余额预警设置失败')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取余额预警设置失败:', error)
|
console.error('[余额预警] 获取余额预警设置异常:', error)
|
||||||
ElMessage.error('获取余额预警设置失败')
|
ElMessage.error('获取余额预警设置失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -565,20 +580,31 @@ const loadAlertSettings = async () => {
|
|||||||
// 处理预警开关变化
|
// 处理预警开关变化
|
||||||
const handleAlertEnabledChange = (value) => {
|
const handleAlertEnabledChange = (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
// 禁用预警时,可以清空手机号
|
// 禁用预警时,保留手机号不清空,但记录日志
|
||||||
balanceAlertSettings.value.alertPhone = ''
|
console.log('[余额预警] 预警已禁用,保留手机号:', balanceAlertSettings.value.alertPhone)
|
||||||
} else {
|
} else {
|
||||||
// 启用预警时,使用用户手机号作为默认值
|
// 启用预警时,如果用户从未设置过预警手机号,则默认使用用户手机号
|
||||||
if (!balanceAlertSettings.value.alertPhone && userInfo.value?.phone) {
|
// 如果原始数据中alertPhone为空,说明用户从未设置过,此时才设置默认值
|
||||||
|
if (!originalAlertSettings.value.alertPhone && userInfo.value?.phone) {
|
||||||
balanceAlertSettings.value.alertPhone = userInfo.value.phone
|
balanceAlertSettings.value.alertPhone = userInfo.value.phone
|
||||||
|
console.log('[余额预警] 首次启用预警,设置默认手机号:', userInfo.value.phone)
|
||||||
|
} else {
|
||||||
|
console.log('[余额预警] 预警已启用,保持现有手机号:', balanceAlertSettings.value.alertPhone)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理阈值变化
|
// 处理阈值变化
|
||||||
const handleThresholdChange = (value) => {
|
const handleThresholdChange = (value) => {
|
||||||
if (value < 0) {
|
// 确保值是数字类型
|
||||||
|
const numValue = Number(value)
|
||||||
|
|
||||||
|
if (isNaN(numValue) || numValue < 0) {
|
||||||
balanceAlertSettings.value.threshold = 0
|
balanceAlertSettings.value.threshold = 0
|
||||||
|
console.log('[余额预警] 阈值已重置为0,输入值无效:', value)
|
||||||
|
} else {
|
||||||
|
balanceAlertSettings.value.threshold = numValue
|
||||||
|
console.log('[余额预警] 阈值已更新:', numValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,7 +614,12 @@ const handlePhoneChange = (event) => {
|
|||||||
// 简单的手机号验证
|
// 简单的手机号验证
|
||||||
if (phone && !/^1[3-9]\d{9}$/.test(phone)) {
|
if (phone && !/^1[3-9]\d{9}$/.test(phone)) {
|
||||||
ElMessage.warning('请输入正确的手机号格式')
|
ElMessage.warning('请输入正确的手机号格式')
|
||||||
|
console.log('[余额预警] 手机号格式不正确:', phone)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新数据模型中的手机号
|
||||||
|
balanceAlertSettings.value.alertPhone = phone
|
||||||
|
console.log('[余额预警] 手机号已更新:', phone)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存余额预警设置
|
// 保存余额预警设置
|
||||||
@@ -600,16 +631,42 @@ const saveAlertSettings = async () => {
|
|||||||
|
|
||||||
alertSettingsLoading.value = true
|
alertSettingsLoading.value = true
|
||||||
try {
|
try {
|
||||||
|
// 准备发送给后端的数据,确保字段名使用后端风格 (snake_case)
|
||||||
|
const dataToSave = {
|
||||||
|
enabled: balanceAlertSettings.value.enabled,
|
||||||
|
threshold: balanceAlertSettings.value.threshold,
|
||||||
|
alert_phone: balanceAlertSettings.value.alertPhone // 转换字段名
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加日志追踪:准备发送的数据
|
||||||
|
console.log('[余额预警] 准备发送的数据:', JSON.stringify(dataToSave))
|
||||||
|
console.log('[余额预警] 当前表单数据:', JSON.stringify(balanceAlertSettings.value))
|
||||||
|
|
||||||
// 调用API保存用户的余额预警设置
|
// 调用API保存用户的余额预警设置
|
||||||
const response = await balanceAlertApi.updateUserAlertSettings(balanceAlertSettings.value)
|
const response = await balanceAlertApi.updateUserAlertSettings(dataToSave)
|
||||||
|
|
||||||
|
// 添加日志追踪:API响应
|
||||||
|
console.log('[余额预警] API响应:', JSON.stringify(response))
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
originalAlertSettings.value = JSON.parse(JSON.stringify(balanceAlertSettings.value))
|
// 确保数据结构一致,只保留前端使用的字段
|
||||||
|
const updatedData = {
|
||||||
|
enabled: balanceAlertSettings.value.enabled,
|
||||||
|
threshold: balanceAlertSettings.value.threshold,
|
||||||
|
alertPhone: balanceAlertSettings.value.alertPhone // 确保使用前端字段名
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加日志追踪:更新后的数据
|
||||||
|
console.log('[余额预警] 更新后的数据:', JSON.stringify(updatedData))
|
||||||
|
|
||||||
|
originalAlertSettings.value = JSON.parse(JSON.stringify(updatedData))
|
||||||
ElMessage.success('余额预警设置保存成功')
|
ElMessage.success('余额预警设置保存成功')
|
||||||
} else {
|
} else {
|
||||||
|
console.error('[余额预警] 保存失败:', response.message)
|
||||||
ElMessage.error('保存失败:' + response.message)
|
ElMessage.error('保存失败:' + response.message)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('保存余额预警设置失败:', error)
|
console.error('[余额预警] 保存余额预警设置失败:', error)
|
||||||
ElMessage.error('保存余额预警设置失败')
|
ElMessage.error('保存余额预警设置失败')
|
||||||
} finally {
|
} finally {
|
||||||
alertSettingsLoading.value = false
|
alertSettingsLoading.value = false
|
||||||
|
|||||||
@@ -136,6 +136,12 @@ const routes = [
|
|||||||
name: 'FinanceRechargeRecords',
|
name: 'FinanceRechargeRecords',
|
||||||
component: () => import('@/pages/finance/recharge-records/index.vue'),
|
component: () => import('@/pages/finance/recharge-records/index.vue'),
|
||||||
meta: { title: '充值记录' }
|
meta: { title: '充值记录' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'purchase-records',
|
||||||
|
name: 'FinancePurchaseRecords',
|
||||||
|
component: () => import('@/pages/finance/purchase-records/index.vue'),
|
||||||
|
meta: { title: '购买记录' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -286,6 +292,24 @@ const routes = [
|
|||||||
name: 'AdminStatistics',
|
name: 'AdminStatistics',
|
||||||
component: () => import('@/pages/admin/statistics/SystemStatisticsPage.vue'),
|
component: () => import('@/pages/admin/statistics/SystemStatisticsPage.vue'),
|
||||||
meta: { title: '系统统计' }
|
meta: { title: '系统统计' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'ui-components',
|
||||||
|
name: 'AdminUIComponents',
|
||||||
|
component: () => import('@/pages/admin/ui-components/index.vue'),
|
||||||
|
meta: { title: '组件管理' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'purchase-records',
|
||||||
|
name: 'AdminPurchaseRecords',
|
||||||
|
component: () => import('@/pages/admin/purchase-records/index.vue'),
|
||||||
|
meta: { title: '购买记录管理' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'certification-reviews',
|
||||||
|
name: 'AdminCertificationReviews',
|
||||||
|
component: () => import('@/pages/admin/certification-reviews/index.vue'),
|
||||||
|
meta: { title: '企业审核' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -94,6 +94,7 @@
|
|||||||
import { userApi } from '@/api'
|
import { userApi } from '@/api'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import { authEventBus } from '@/utils/request'
|
import { authEventBus } from '@/utils/request'
|
||||||
|
import { generateSMSRequest } from '@/utils/smsSignature'
|
||||||
import { clearLocalVersions, saveLocalVersions, VERSION_CONFIG, versionChecker } from '@/utils/version'
|
import { clearLocalVersions, saveLocalVersions, VERSION_CONFIG, versionChecker } from '@/utils/version'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
@@ -328,17 +329,24 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送验证码
|
// 发送验证码(使用自定义编码和签名)
|
||||||
const sendCode = async (phone, scene) => {
|
const sendCode = async (phone, scene, captchaVerifyParam = null) => {
|
||||||
try {
|
try {
|
||||||
const response = await userApi.sendCode({
|
// 1. 生成签名并编码请求数据
|
||||||
phone,
|
const encodedRequest = await generateSMSRequest(phone, scene)
|
||||||
scene
|
|
||||||
})
|
// 2. 如果有滑块验证码参数,添加到请求数据中
|
||||||
|
if (captchaVerifyParam) {
|
||||||
|
encodedRequest.captchaVerifyParam = captchaVerifyParam
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 发送编码后的请求
|
||||||
|
const response = await userApi.sendCode(encodedRequest)
|
||||||
|
|
||||||
// 后端返回格式: { success: true, data: {...}, message, ... }
|
// 后端返回格式: { success: true, data: {...}, message, ... }
|
||||||
return { success: true, data: response.data }
|
return { success: true, data: response.data }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('发送验证码失败:', error)
|
||||||
return { success: false, error }
|
return { success: false, error }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,14 +52,16 @@ request.interceptors.request.use(
|
|||||||
// 响应拦截器
|
// 响应拦截器
|
||||||
request.interceptors.response.use(
|
request.interceptors.response.use(
|
||||||
response => {
|
response => {
|
||||||
const { data, status } = response
|
const { data, status, config } = response
|
||||||
|
|
||||||
// 检查HTTP状态码
|
// 检查HTTP状态码
|
||||||
if (status >= 200 && status < 300) {
|
if (status >= 200 && status < 300) {
|
||||||
// 如果是blob响应(文件下载),直接返回data
|
// 检查是否是文件下载API(通过URL判断)
|
||||||
if (data instanceof Blob) {
|
if (config.url && config.url.includes('/generate-and-download')) {
|
||||||
return data
|
// 直接返回原始响应,让前端处理文件下载
|
||||||
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
// 严格按照后端响应格式处理
|
// 严格按照后端响应格式处理
|
||||||
if (data.success === true) {
|
if (data.success === true) {
|
||||||
// 成功响应,返回data字段
|
// 成功响应,返回data字段
|
||||||
|
|||||||
212
src/utils/smsSignature.js
Normal file
212
src/utils/smsSignature.js
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* 短信发送接口签名和编码工具
|
||||||
|
*
|
||||||
|
* 用于生成HMAC-SHA256签名和自定义编码请求数据
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义编码字符集(与后端保持一致)
|
||||||
|
*/
|
||||||
|
const CUSTOM_ENCODE_CHARSET = "0123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz!@#$%^&*()_+-=[]{}|;:,.<>?"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取签名密钥(通过多种方式混淆,增加破解难度)
|
||||||
|
* 注意:这只是示例,实际使用时应该进一步混淆
|
||||||
|
*/
|
||||||
|
function getSecretKey() {
|
||||||
|
// 方式1: 字符串拆分和拼接
|
||||||
|
const part1 = 'TyApi2024'
|
||||||
|
const part2 = 'SMSSecret'
|
||||||
|
const part3 = 'Key!@#$%^'
|
||||||
|
const part4 = '&*()_+QWERTY'
|
||||||
|
const part5 = 'UIOP'
|
||||||
|
|
||||||
|
// 方式2: 使用数组和join(增加混淆)
|
||||||
|
const arr = [part1, part2, part3, part4, part5]
|
||||||
|
return arr.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成随机字符串(用于nonce)
|
||||||
|
*/
|
||||||
|
export function generateNonce(length = 16) {
|
||||||
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||||
|
let result = ''
|
||||||
|
const array = new Uint8Array(length)
|
||||||
|
crypto.getRandomValues(array)
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += chars[array[i] % chars.length]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用Web Crypto API生成HMAC-SHA256签名
|
||||||
|
*
|
||||||
|
* @param {Object} params - 请求参数对象
|
||||||
|
* @param {string} secretKey - 签名密钥
|
||||||
|
* @param {number} timestamp - 时间戳(秒)
|
||||||
|
* @param {string} nonce - 随机字符串
|
||||||
|
* @returns {Promise<string>} 签名字符串(hex编码)
|
||||||
|
*/
|
||||||
|
async function generateSignature(params, secretKey, timestamp, nonce) {
|
||||||
|
// 1. 构建待签名字符串:按key排序,拼接成 key1=value1&key2=value2 格式
|
||||||
|
const keys = Object.keys(params)
|
||||||
|
.filter(k => k !== 'signature') // 排除签名字段
|
||||||
|
.sort()
|
||||||
|
|
||||||
|
const parts = keys.map(k => `${k}=${params[k]}`)
|
||||||
|
|
||||||
|
// 2. 添加时间戳和随机数
|
||||||
|
parts.push(`timestamp=${timestamp}`)
|
||||||
|
parts.push(`nonce=${nonce}`)
|
||||||
|
|
||||||
|
// 3. 拼接成待签名字符串
|
||||||
|
const signString = parts.join('&')
|
||||||
|
|
||||||
|
// 4. 使用Web Crypto API计算HMAC-SHA256签名
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const keyData = encoder.encode(secretKey)
|
||||||
|
const messageData = encoder.encode(signString)
|
||||||
|
|
||||||
|
// 导入密钥
|
||||||
|
const cryptoKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
keyData,
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
)
|
||||||
|
|
||||||
|
// 计算签名
|
||||||
|
const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData)
|
||||||
|
|
||||||
|
// 转换为hex字符串
|
||||||
|
const hashArray = Array.from(new Uint8Array(signature))
|
||||||
|
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||||
|
|
||||||
|
return hashHex
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义Base64编码(使用自定义字符集)
|
||||||
|
*/
|
||||||
|
function customBase64Encode(data) {
|
||||||
|
if (data.length === 0) return ''
|
||||||
|
|
||||||
|
// 将字符串转换为UTF-8字节数组
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const bytes = encoder.encode(data)
|
||||||
|
const charset = CUSTOM_ENCODE_CHARSET
|
||||||
|
let result = ''
|
||||||
|
|
||||||
|
// 将3个字节(24位)编码为4个字符
|
||||||
|
for (let i = 0; i < bytes.length; i += 3) {
|
||||||
|
const b1 = bytes[i]
|
||||||
|
const b2 = i + 1 < bytes.length ? bytes[i + 1] : 0
|
||||||
|
const b3 = i + 2 < bytes.length ? bytes[i + 2] : 0
|
||||||
|
|
||||||
|
// 组合成24位
|
||||||
|
const combined = (b1 << 16) | (b2 << 8) | b3
|
||||||
|
|
||||||
|
// 分成4个6位段
|
||||||
|
result += charset[(combined >> 18) & 0x3F]
|
||||||
|
result += charset[(combined >> 12) & 0x3F]
|
||||||
|
|
||||||
|
if (i + 1 < bytes.length) {
|
||||||
|
result += charset[(combined >> 6) & 0x3F]
|
||||||
|
} else {
|
||||||
|
result += '=' // 填充字符
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i + 2 < bytes.length) {
|
||||||
|
result += charset[combined & 0x3F]
|
||||||
|
} else {
|
||||||
|
result += '=' // 填充字符
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用字符偏移混淆
|
||||||
|
*/
|
||||||
|
function applyCharShift(data, shift) {
|
||||||
|
const charset = CUSTOM_ENCODE_CHARSET
|
||||||
|
const charsetLen = charset.length
|
||||||
|
let result = ''
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const c = data[i]
|
||||||
|
if (c === '=') {
|
||||||
|
result += c // 填充字符不变
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = charset.indexOf(c)
|
||||||
|
if (idx === -1) {
|
||||||
|
result += c // 不在字符集中,保持不变
|
||||||
|
} else {
|
||||||
|
// 应用偏移
|
||||||
|
const newIdx = (idx + shift) % charsetLen
|
||||||
|
result += charset[newIdx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义编码请求数据
|
||||||
|
*/
|
||||||
|
export function encodeRequest(data) {
|
||||||
|
// 1. 使用自定义Base64编码
|
||||||
|
const encoded = customBase64Encode(data)
|
||||||
|
|
||||||
|
// 2. 应用字符偏移混淆(偏移7个位置)
|
||||||
|
const confused = applyCharShift(encoded, 7)
|
||||||
|
|
||||||
|
return confused
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成并编码短信发送请求数据
|
||||||
|
*
|
||||||
|
* @param {string} phone - 手机号
|
||||||
|
* @param {string} scene - 场景(register/login/change_password/reset_password等)
|
||||||
|
* @returns {Promise<{data: string}>} 编码后的请求数据
|
||||||
|
*/
|
||||||
|
export async function generateSMSRequest(phone, scene) {
|
||||||
|
// 1. 准备参数
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000) // 当前时间戳(秒)
|
||||||
|
const nonce = generateNonce(16) // 生成随机字符串
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
phone: phone,
|
||||||
|
scene: scene
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 生成签名
|
||||||
|
const secretKey = getSecretKey()
|
||||||
|
const signature = await generateSignature(params, secretKey, timestamp, nonce)
|
||||||
|
|
||||||
|
// 3. 构建包含所有参数的JSON对象
|
||||||
|
const allParams = {
|
||||||
|
phone: phone,
|
||||||
|
scene: scene,
|
||||||
|
timestamp: timestamp,
|
||||||
|
nonce: nonce,
|
||||||
|
signature: signature
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 将JSON对象转换为字符串,然后使用自定义编码方案编码
|
||||||
|
const jsonString = JSON.stringify(allParams)
|
||||||
|
const encodedData = encodeRequest(jsonString)
|
||||||
|
|
||||||
|
// 5. 返回编码后的数据
|
||||||
|
return {
|
||||||
|
data: encodedData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -54,6 +54,17 @@
|
|||||||
<span v-if="!userStore.isCertified">完成企业认证,解锁完整数据功能</span>
|
<span v-if="!userStore.isCertified">完成企业认证,解锁完整数据功能</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="notification-area">
|
||||||
|
<div class="notification-scroll-container">
|
||||||
|
<div class="notification-scroll" :style="{ animationDuration: scrollDuration }">
|
||||||
|
<span v-for="(line, index) in allDisplayLines" :key="index" class="notification-item">
|
||||||
|
<span v-if="line.includes('IVYZ') || line.includes('QYGL') || line.includes('FLXG') || line.includes('JRZQ') || line.includes('YYSY')" class="api-item">{{ line }}</span>
|
||||||
|
<span v-else>{{ line }}</span>
|
||||||
|
<span class="separator"> ● </span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 lg:grid-cols-3 gap-3 mb-4">
|
<div class="grid grid-cols-2 lg:grid-cols-3 gap-3 mb-4">
|
||||||
@@ -429,7 +440,7 @@ import { Bell, CreditCard, List, Loading, Lock, Money, Star, TrendCharts } from
|
|||||||
import { ArrowLeftIcon as ArrowLeft, ArrowRightIcon as ArrowRight } from '@heroicons/vue/24/outline'
|
import { ArrowLeftIcon as ArrowLeft, ArrowRightIcon as ArrowRight } from '@heroicons/vue/24/outline'
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
// 路由实例
|
// 路由实例
|
||||||
@@ -468,6 +479,48 @@ const rechargeChart = ref(null)
|
|||||||
// 图表实例存储
|
// 图表实例存储
|
||||||
const chartInstances = ref([])
|
const chartInstances = ref([])
|
||||||
|
|
||||||
|
// 暂停通知内容
|
||||||
|
const lines = ["IVYZ5E3F 单人婚姻状态B 已恢复使用。",
|
||||||
|
// "因政策要求,接上级主管部门通知,从2025年12月25日23:59分开始,我司现暂停部分API接口服务,恢复时间待定,请已接入上述接口的客户尽快转接至平台替代API接口,以确保业务连续性,由此给您带来的不便,我们深表歉意。",
|
||||||
|
// "",
|
||||||
|
// "IVYZ2B2T 能力资质核验(学历)",
|
||||||
|
// "IVYZ5A9O 全国⾃然⼈⻛险评估评分模型",
|
||||||
|
// "IVYZ9K2L 身份认证三要素(人脸图像版)",
|
||||||
|
// "IVYZ81NC 单人婚姻查询(登记时间版)",
|
||||||
|
// "IVYZ7F2A 双人婚姻状态B",
|
||||||
|
// "IVYZ4E8B 单人婚姻状态C",
|
||||||
|
// "QYGL8271 企业司法涉诉(详版)",
|
||||||
|
// "FLXG0V4B 个人司法涉诉",
|
||||||
|
// "FLXG162A 团伙欺诈评估",
|
||||||
|
// "FLXGC9D1 黑灰产等级",
|
||||||
|
// "JRZQ4AA8 偿债压力指数",
|
||||||
|
// "JRZQ8203 借贷行为验证",
|
||||||
|
// "JRZQ0A03 借贷意向验证",
|
||||||
|
// "YYSYD50F 二要素核验(手机号、身份证号)",
|
||||||
|
// "YYSY09CD 运营商三要素验证(简版)",
|
||||||
|
// "YYSY6F2E 运营商三要素核验(高级版)",
|
||||||
|
// "QYGL45BD 企业法人四要素核验",
|
||||||
|
// "QYGL2ACD 企业三要素核验",
|
||||||
|
// "IVYZ9363 双人婚姻状态A",
|
||||||
|
// "QYGL8261 企业综合涉诉",
|
||||||
|
// "FLXGCA3D 个人综合涉诉",
|
||||||
|
// "IVYZ0B03 二要素验证(姓名、手机号)",
|
||||||
|
// "IVYZ5733 单人婚姻状态A",
|
||||||
|
// "FLXG3D56 特殊名单验证"
|
||||||
|
];
|
||||||
|
|
||||||
|
// 通知滚动相关
|
||||||
|
const scrollDuration = ref('60s') // 滚动动画持续时间,延长以便用户看清内容
|
||||||
|
const allDisplayLines = computed(() => {
|
||||||
|
// 过滤掉空行,保留所有有效内容
|
||||||
|
const validLines = lines.filter(line => line.trim() !== '')
|
||||||
|
|
||||||
|
// 为了创建无缝滚动效果,重复数组内容
|
||||||
|
return [...validLines, ' ● ', ...validLines]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 缓存格式化函数结果 - 使用WeakMap提升性能
|
||||||
|
|
||||||
// 缓存格式化函数结果 - 使用WeakMap提升性能
|
// 缓存格式化函数结果 - 使用WeakMap提升性能
|
||||||
const formatCache = new Map()
|
const formatCache = new Map()
|
||||||
|
|
||||||
@@ -1243,6 +1296,7 @@ onMounted(() => {
|
|||||||
loadAnnouncements()
|
loadAnnouncements()
|
||||||
// 添加窗口大小变化监听
|
// 添加窗口大小变化监听
|
||||||
window.addEventListener('resize', handleResize, { passive: true })
|
window.addEventListener('resize', handleResize, { passive: true })
|
||||||
|
// 滚动动画由CSS控制,无需JavaScript启动
|
||||||
})
|
})
|
||||||
|
|
||||||
// 组件卸载时清理
|
// 组件卸载时清理
|
||||||
@@ -1263,6 +1317,8 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
chartInstances.value = []
|
chartInstances.value = []
|
||||||
|
|
||||||
|
// 滚动动画使用CSS实现,无需清理定时器
|
||||||
|
|
||||||
// 清理缓存
|
// 清理缓存
|
||||||
formatCache.clear()
|
formatCache.clear()
|
||||||
})
|
})
|
||||||
@@ -1375,4 +1431,75 @@ onUnmounted(() => {
|
|||||||
background-color: #f9fafb;
|
background-color: #f9fafb;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification-area {
|
||||||
|
width: 800px;
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 15px;
|
||||||
|
border: 1px solid #3b82f6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.8;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-scroll-container {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-scroll {
|
||||||
|
display: inline-block;
|
||||||
|
padding-right: 50px; /* 防止文字紧贴边缘 */
|
||||||
|
white-space: nowrap;
|
||||||
|
animation: marquee linear infinite;
|
||||||
|
width: 200%; /* 确保有足够的空间实现无缝滚动 */
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes marquee {
|
||||||
|
0% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(-500%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item {
|
||||||
|
margin-right: 20px;
|
||||||
|
display: inline-block;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: bold;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.api-item {
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 3px 0;
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
.notification-content::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
.notification-content::-webkit-scrollbar-track {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
.notification-content::-webkit-scrollbar-thumb {
|
||||||
|
background: #60a5fa;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user