Compare commits
36 Commits
80ea79edc3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ef9ab4057 | |||
| c28f06411d | |||
| 958139d149 | |||
| 47981086a5 | |||
| 2061b3e361 | |||
| 6fda9d1b51 | |||
| 244459f79a | |||
| f6e7ec7ae8 | |||
| 9a4effe05d | |||
| 0050127cfd | |||
| 02e63dd25a | |||
| 138a0dc288 | |||
| a7cd16848c | |||
| 33dc672440 | |||
| 7c2f828222 | |||
| d3dde79474 | |||
| 51f04d7528 | |||
| 47a9eaebcd | |||
| 40a4c7fa08 | |||
| dfa908f4a3 | |||
| 4cdc6e0308 | |||
| 7d66ed72c8 | |||
| a12c7caa3a | |||
| d05ded72c7 | |||
| 6818456070 | |||
| c90259e3f4 | |||
| ee4b6c4de7 | |||
| 00374f285b | |||
| 792f8d6abe | |||
| 68da50984c | |||
| 37c18be7c2 | |||
| c9ef3f6f9c | |||
| 4db033c557 | |||
| 34d3e4c715 | |||
| fe1147cebf | |||
| a02e27f270 |
8
.env
8
.env
@@ -1 +1,9 @@
|
|||||||
|
# 与主站同仓;/sub/auth/* 子账号登注册不依赖本开关。为 true 时根路径进 /sub/auth/login、侧栏为子账号受限菜单
|
||||||
|
VITE_IS_SUB_PORTAL=false
|
||||||
VITE_API_URL="https://api.tianyuanapi.com"
|
VITE_API_URL="https://api.tianyuanapi.com"
|
||||||
|
VITE_CAPTCHA_SCENE_ID="wynt39to"
|
||||||
|
VITE_CAPTCHA_ENCRYPTED_MODE=false
|
||||||
|
# 子账号专属前端域名(生产建议配置,如 https://subsole.tianyuanapi.com)
|
||||||
|
VITE_SUB_PORTAL_BASE_URL="https://subsole.tianyuanapi.com"
|
||||||
|
# 主控制台前端域名(用于从子域回跳主域,如 https://console.tianyuanapi.com)
|
||||||
|
VITE_MAIN_PORTAL_BASE_URL="https://console.tianyuanapi.com"
|
||||||
@@ -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,
|
||||||
|
|||||||
10
auto-imports.d.ts
vendored
10
auto-imports.d.ts
vendored
@@ -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']>
|
||||||
|
|||||||
1
components.d.ts
vendored
1
components.d.ts
vendored
@@ -55,6 +55,7 @@ declare module 'vue' {
|
|||||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||||
ElRow: typeof import('element-plus/es')['ElRow']
|
ElRow: typeof import('element-plus/es')['ElRow']
|
||||||
|
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
|
|||||||
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>
|
||||||
|
|||||||
BIN
public/2/favicon.ico
Normal file
BIN
public/2/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
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,
|
||||||
|
};
|
||||||
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 7.2 KiB |
@@ -40,6 +40,34 @@ export const userApi = {
|
|||||||
getUserStats: () => request.get('/users/admin/stats')
|
getUserStats: () => request.get('/users/admin/stats')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const subPortalApi = {
|
||||||
|
register: (data) => request.post('/sub-portal/register', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const subordinateApi = {
|
||||||
|
createInvitation: (data) => request.post('/subordinate/invitations', data || {}),
|
||||||
|
listSubordinates: (params) => request.get('/subordinate/subordinates', { params }),
|
||||||
|
updateRemark: (data) => request.patch('/subordinate/subordinates/remark', data),
|
||||||
|
allocate: (data) => request.post('/subordinate/allocate', data),
|
||||||
|
listAllocations: (params) => request.get('/subordinate/allocations', { params }),
|
||||||
|
purchaseQuota: (data) => request.post('/subordinate/purchase-quota', data),
|
||||||
|
listQuotaPurchases: (params) => request.get('/subordinate/quota-purchases', { params }),
|
||||||
|
listChildQuotas: (params) => request.get('/subordinate/child-quotas', { params }),
|
||||||
|
listChildApiCalls: (params) => request.get('/subordinate/child-api-calls', { params }),
|
||||||
|
listMyQuotas: () => request.get('/subordinate/my-quotas'),
|
||||||
|
assignSubscription: (data) => request.post('/subordinate/assign-subscription', data),
|
||||||
|
listChildSubscriptions: (params) => request.get('/subordinate/child-subscriptions', { params }),
|
||||||
|
removeChildSubscription: (subscriptionId, data) => request.delete(`/subordinate/child-subscriptions/${subscriptionId}`, { data })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证码(阿里云滑块)相关接口
|
||||||
|
export const captchaApi = {
|
||||||
|
// 获取加密场景 ID,用于前端加密模式初始化滑块
|
||||||
|
getEncryptedSceneId: (params) => request.post('/captcha/encryptedSceneId', params || {}),
|
||||||
|
// 获取验证码配置(是否启用、场景 ID)
|
||||||
|
getConfig: () => request.get('/captcha/config')
|
||||||
|
}
|
||||||
|
|
||||||
// 产品相关接口
|
// 产品相关接口
|
||||||
export const productApi = {
|
export const productApi = {
|
||||||
// 产品列表(用户端接口)
|
// 产品列表(用户端接口)
|
||||||
@@ -137,8 +165,8 @@ export const financeApi = {
|
|||||||
getAlipayOrderStatus: (params) => request.get('/finance/wallet/alipay-order-status', { params }),
|
getAlipayOrderStatus: (params) => request.get('/finance/wallet/alipay-order-status', { params }),
|
||||||
|
|
||||||
// 管理员充值功能
|
// 管理员充值功能
|
||||||
transferRecharge: (data) => request.post('/admin/finance/transfer-recharge', data),
|
adminTransferRecharge: (data) => request.post('/admin/finance/transfer-recharge', data),
|
||||||
giftRecharge: (data) => request.post('/admin/finance/gift-recharge', data),
|
adminGiftRecharge: (data) => request.post('/admin/finance/gift-recharge', data),
|
||||||
|
|
||||||
// 充值记录相关接口
|
// 充值记录相关接口
|
||||||
getUserRechargeRecords: (params) => request.get('/finance/wallet/recharge-records', { params }),
|
getUserRechargeRecords: (params) => request.get('/finance/wallet/recharge-records', { params }),
|
||||||
@@ -186,8 +214,27 @@ export const certificationApi = {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// 上传认证图片到七牛云(企业信息中的营业执照、办公场地、场景附件、授权代表身份证等)
|
||||||
|
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)
|
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相关接口
|
||||||
@@ -277,6 +324,12 @@ export const productAdminApi = {
|
|||||||
updateProduct: (id, data) => request.put(`/admin/products/${id}`, data),
|
updateProduct: (id, data) => request.put(`/admin/products/${id}`, data),
|
||||||
deleteProduct: (id) => request.delete(`/admin/products/${id}`),
|
deleteProduct: (id) => request.delete(`/admin/products/${id}`),
|
||||||
|
|
||||||
|
// 产品字典导出
|
||||||
|
exportProductDictionary: (params) => request.get('/admin/products/export-dictionary', {
|
||||||
|
params,
|
||||||
|
responseType: 'blob'
|
||||||
|
}),
|
||||||
|
|
||||||
// 组合包管理
|
// 组合包管理
|
||||||
getAvailableProducts: (params) => request.get('/admin/products/available', { params }),
|
getAvailableProducts: (params) => request.get('/admin/products/available', { params }),
|
||||||
addPackageItem: (packageId, data) => request.post(`/admin/products/${packageId}/package-items`, data),
|
addPackageItem: (packageId, data) => request.post(`/admin/products/${packageId}/package-items`, data),
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
BIN
src/assets/logo.png
Normal file
BIN
src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 276 B |
@@ -2,33 +2,36 @@
|
|||||||
|
|
||||||
/* 输入框样式优化 */
|
/* 输入框样式优化 */
|
||||||
.auth-input :deep(.el-input__wrapper) {
|
.auth-input :deep(.el-input__wrapper) {
|
||||||
border-radius: 8px !important;
|
border-radius: 10px !important;
|
||||||
transition: all 0.3s ease !important;
|
transition: all 0.3s ease !important;
|
||||||
border: 1px solid #d1d5db !important;
|
border: 1px solid #d5deef !important;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04) !important;
|
||||||
|
min-height: 44px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-input :deep(.el-input__wrapper:hover) {
|
.auth-input :deep(.el-input__wrapper:hover) {
|
||||||
border-color: #3b82f6 !important;
|
border-color: #7aa9f8 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-input :deep(.el-input__wrapper.is-focus) {
|
.auth-input :deep(.el-input__wrapper.is-focus) {
|
||||||
border-color: #3b82f6 !important;
|
border-color: #2563eb !important;
|
||||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1) !important;
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.13) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 按钮样式优化 */
|
/* 按钮样式优化 */
|
||||||
.auth-button :deep(.el-button--primary) {
|
.auth-button :deep(.el-button--primary) {
|
||||||
border-radius: 8px !important;
|
border-radius: 10px !important;
|
||||||
font-weight: 500 !important;
|
font-weight: 600 !important;
|
||||||
transition: all 0.3s ease !important;
|
transition: all 0.3s ease !important;
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%) !important;
|
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 55%, #1e40af 100%) !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
|
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.25) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-button :deep(.el-button--primary:hover) {
|
.auth-button :deep(.el-button--primary:hover) {
|
||||||
transform: translateY(-1px) !important;
|
transform: translateY(-1px) !important;
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3) !important;
|
box-shadow: 0 12px 24px rgba(37, 99, 235, 0.32) !important;
|
||||||
background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%) !important;
|
background: linear-gradient(135deg, #1d4ed8 0%, #1e40af 100%) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-button :deep(.el-button--primary:active) {
|
.auth-button :deep(.el-button--primary:active) {
|
||||||
@@ -36,19 +39,61 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Radio button 样式优化 */
|
/* Radio button 样式优化 */
|
||||||
|
.auth-method-tabs {
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 4px;
|
||||||
|
background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
display: flex !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-method-tab {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-method-tabs :deep(.el-radio-button) {
|
||||||
|
flex: 1;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-method-tabs :deep(.el-radio-button__inner) {
|
||||||
|
width: 100%;
|
||||||
|
border-left: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-method-tabs :deep(.el-radio-button:first-child .el-radio-button__inner) {
|
||||||
|
border-radius: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-method-tabs :deep(.el-radio-button:last-child .el-radio-button__inner) {
|
||||||
|
border-radius: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-method-tabs :deep(.el-radio-button.is-active .el-radio-button__inner) {
|
||||||
|
border-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-radio :deep(.el-radio-button__inner) {
|
.auth-radio :deep(.el-radio-button__inner) {
|
||||||
|
width: 100% !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
color: #6b7280 !important;
|
color: #475569 !important;
|
||||||
font-weight: 500 !important;
|
font-weight: 600 !important;
|
||||||
transition: all 0.3s ease !important;
|
transition: all 0.3s ease !important;
|
||||||
padding: 12px 16px !important;
|
padding: 10px 14px !important;
|
||||||
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-radio :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
|
.auth-radio :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
|
||||||
background: white !important;
|
background: #ffffff !important;
|
||||||
color: #3b82f6 !important;
|
color: #0f172a !important;
|
||||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06) !important;
|
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-radio :deep(.el-radio-button__inner:hover) {
|
||||||
|
color: #0f172a !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 表单标签样式 */
|
/* 表单标签样式 */
|
||||||
@@ -58,21 +103,21 @@
|
|||||||
|
|
||||||
/* 链接样式 */
|
/* 链接样式 */
|
||||||
.auth-link {
|
.auth-link {
|
||||||
@apply text-gray-600 hover:text-sky-600 transition-colors mx-2;
|
@apply text-slate-500 hover:text-blue-700 transition-colors mx-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 卡片样式 */
|
/* 卡片样式 */
|
||||||
.auth-card {
|
.auth-card {
|
||||||
@apply bg-white/95 backdrop-blur-sm shadow-2xl rounded-2xl border border-white/20;
|
@apply bg-white/96 shadow-2xl rounded-2xl border border-white/40;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 标题样式 */
|
/* 标题样式 */
|
||||||
.auth-title {
|
.auth-title {
|
||||||
@apply text-2xl font-bold text-gray-900 mb-2;
|
@apply text-2xl font-bold text-slate-900 mb-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-subtitle {
|
.auth-subtitle {
|
||||||
@apply text-gray-600 text-sm;
|
@apply text-slate-500 text-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 响应式优化 */
|
/* 响应式优化 */
|
||||||
|
|||||||
@@ -380,13 +380,13 @@ const props = defineProps({
|
|||||||
// 子分类数据
|
// 子分类数据
|
||||||
const subCategories = ref([])
|
const subCategories = ref([])
|
||||||
|
|
||||||
// 级联选择器配置
|
// 级联选择器配置(checkStrictly: true 允许只选一级分类,不强制选子类)
|
||||||
const cascaderProps = {
|
const cascaderProps = {
|
||||||
value: 'id',
|
value: 'id',
|
||||||
label: 'name',
|
label: 'name',
|
||||||
children: 'children',
|
children: 'children',
|
||||||
emitPath: false,
|
emitPath: false,
|
||||||
checkStrictly: false
|
checkStrictly: true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分类选项(包含子分类)
|
// 分类选项(包含子分类)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
>
|
>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<!-- 企业选择 -->
|
<!-- 企业选择 -->
|
||||||
<div v-if="showCompanySelect">
|
<div v-if="showCompanySelect && !isSingleUserMode">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">选择企业</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">选择企业</label>
|
||||||
<el-select
|
<el-select
|
||||||
v-model="exportOptions.companyIds"
|
v-model="exportOptions.companyIds"
|
||||||
@@ -33,6 +33,14 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 单用户模式提示 -->
|
||||||
|
<div v-if="isSingleUserMode" class="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<User class="w-4 h-4 text-blue-600 flex-shrink-0" />
|
||||||
|
<span class="text-sm text-blue-800">当前为单用户模式,将只导出该用户的数据</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 产品选择 -->
|
<!-- 产品选择 -->
|
||||||
<div v-if="showProductSelect">
|
<div v-if="showProductSelect">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">选择产品</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">选择产品</label>
|
||||||
@@ -136,9 +144,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { productApi, userApi } from '@/api'
|
import { productAdminApi, userApi } from '@/api'
|
||||||
import { useMobileTable } from '@/composables/useMobileTable'
|
import { useMobileTable } from '@/composables/useMobileTable'
|
||||||
import { reactive, ref, watch } from 'vue'
|
import { reactive, ref, watch } from 'vue'
|
||||||
|
import { User } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
// 移动端检测
|
// 移动端检测
|
||||||
const { isMobile } = useMobileTable()
|
const { isMobile } = useMobileTable()
|
||||||
@@ -177,6 +186,15 @@ const props = defineProps({
|
|||||||
showDateRange: {
|
showDateRange: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: true
|
||||||
|
},
|
||||||
|
// 单用户模式相关
|
||||||
|
isSingleUserMode: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
currentUserId: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -250,7 +268,8 @@ const loadProductOptions = async () => {
|
|||||||
try {
|
try {
|
||||||
productLoading.value = true
|
productLoading.value = true
|
||||||
|
|
||||||
const response = await productApi.getProducts({
|
// 使用管理员产品API
|
||||||
|
const response = await productAdminApi.getProducts({
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: 1000,
|
page_size: 1000,
|
||||||
name: productSearchKeyword.value
|
name: productSearchKeyword.value
|
||||||
|
|||||||
@@ -12,6 +12,12 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="header-title">
|
<div class="header-title">
|
||||||
|
<img
|
||||||
|
v-if="logo"
|
||||||
|
class="header-logo"
|
||||||
|
:src="logo"
|
||||||
|
alt="天远数据 Logo"
|
||||||
|
/>
|
||||||
<h1 class="title-text">{{ title }}</h1>
|
<h1 class="title-text">{{ title }}</h1>
|
||||||
<el-tag v-if="badge" :type="badgeType" size="small" class="badge-tag">
|
<el-tag v-if="badge" :type="badgeType" size="small" class="badge-tag">
|
||||||
{{ badge }}
|
{{ badge }}
|
||||||
@@ -62,7 +68,7 @@
|
|||||||
</el-icon>
|
</el-icon>
|
||||||
个人中心
|
个人中心
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
<el-dropdown-item command="home">
|
<el-dropdown-item v-if="showHomeEntry" command="home">
|
||||||
<el-icon class="dropdown-item-icon">
|
<el-icon class="dropdown-item-icon">
|
||||||
<Home />
|
<Home />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
@@ -93,11 +99,15 @@ import {
|
|||||||
UserIcon as User
|
UserIcon as User
|
||||||
} from '@heroicons/vue/24/outline'
|
} from '@heroicons/vue/24/outline'
|
||||||
|
|
||||||
const props = defineProps({
|
defineProps({
|
||||||
title: {
|
title: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
|
logo: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
badge: {
|
badge: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
@@ -115,11 +125,10 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['user-command'])
|
const emit = defineEmits(['user-command'])
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
const showNotifications = ref(false)
|
const showHomeEntry = computed(() => userStore.accountKind !== 'subordinate')
|
||||||
|
|
||||||
// 处理用户菜单命令
|
// 处理用户菜单命令
|
||||||
const handleUserCommand = async (command) => {
|
const handleUserCommand = async (command) => {
|
||||||
@@ -191,7 +200,14 @@ const handleUserCommand = async (command) => {
|
|||||||
.header-title {
|
.header-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-text {
|
.title-text {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
<!-- 返回官网链接 -->
|
<!-- 返回官网链接 -->
|
||||||
<div class="home-link-container">
|
<div v-if="showHomeLink" class="home-link-container">
|
||||||
<a href="https://www.tianyuanapi.com/" target="_blank" rel="noopener" class="home-link" @click="handleMenuSelect">
|
<a href="https://www.tianyuanapi.com/" target="_blank" rel="noopener" class="home-link" @click="handleMenuSelect">
|
||||||
<el-icon class="menu-icon">
|
<el-icon class="menu-icon">
|
||||||
<HomeIcon />
|
<HomeIcon />
|
||||||
@@ -56,6 +56,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'user', // 'user' | 'admin'
|
default: 'user', // 'user' | 'admin'
|
||||||
validator: (value) => ['user', 'admin'].includes(value)
|
validator: (value) => ['user', 'admin'].includes(value)
|
||||||
|
},
|
||||||
|
showHomeLink: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
@@ -105,17 +105,69 @@ export const getMenuItems = (userType = 'user') => {
|
|||||||
return userMenuItems
|
return userMenuItems
|
||||||
}
|
}
|
||||||
|
|
||||||
// 新增:获取用户可访问的菜单项(包含管理员菜单)
|
// 子账号/子站壳:受限侧栏(不展示主站运营外链入口由布局控制)
|
||||||
export const getUserAccessibleMenuItems = (userType = 'user') => {
|
export const getSubordinateMenuItems = () => {
|
||||||
const baseMenuItems = [...userMenuItems]
|
const devGroup = userMenuItems.find((g) => g.group === '开发者中心')?.children || []
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
group: '数据中心',
|
||||||
|
icon: ChartBar,
|
||||||
|
children: [
|
||||||
|
{ name: '我的订阅', path: '/subscriptions', icon: ShoppingCart }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: '账户中心',
|
||||||
|
icon: User,
|
||||||
|
children: [
|
||||||
|
{ name: '账户中心', path: '/profile', icon: User },
|
||||||
|
{ name: '企业入驻', path: '/profile/certification', icon: ShieldCheck }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: '开发者中心',
|
||||||
|
icon: Setting,
|
||||||
|
children: devGroup.filter((item) =>
|
||||||
|
['/apis/debugger', '/apis/usage', '/apis/whitelist'].includes(item.path)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:获取用户可访问的菜单项(包含管理员菜单)
|
||||||
|
// options: { accountKind, isSubPortal } 用于下属账号/子站独立构建
|
||||||
|
export const getUserAccessibleMenuItems = (userType = 'user', _isCertified = false, options = {}) => {
|
||||||
|
const { accountKind = 'standalone', isSubPortal: subBuild = false } = options
|
||||||
|
|
||||||
|
if (accountKind === 'subordinate' || subBuild) {
|
||||||
|
return getSubordinateMenuItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseMenuItems = userMenuItems.map((group) => ({
|
||||||
|
...group,
|
||||||
|
children: group.children ? [...group.children] : undefined
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 主站:普通用户与管理员均可在「账户中心」进入下属管理(管理员同时具备用户侧能力)
|
||||||
|
const acc = baseMenuItems.find((g) => g.group === '账户中心')
|
||||||
|
if (acc && acc.children) {
|
||||||
|
const exists = acc.children.some((c) => c.path === '/parent/subordinates')
|
||||||
|
if (!exists) {
|
||||||
|
acc.children = [
|
||||||
|
...acc.children,
|
||||||
|
{ name: '下属', path: '/parent/subordinates', icon: Users, requiresCertification: false }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 如果是管理员,添加管理员菜单组
|
|
||||||
if (userType === 'admin') {
|
if (userType === 'admin') {
|
||||||
baseMenuItems.push({
|
baseMenuItems.push({
|
||||||
group: '管理后台',
|
group: '管理后台',
|
||||||
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 },
|
||||||
|
|||||||
27
src/constants/portal.js
Normal file
27
src/constants/portal.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* 子账号注册/登录为 /sub/auth/*,与主站同前端;VITE_IS_SUB_PORTAL=true 时仅多一层「根进子账号登录 + 侧栏子账号壳」
|
||||||
|
*/
|
||||||
|
export const isSubPortal = import.meta.env.VITE_IS_SUB_PORTAL === 'true'
|
||||||
|
|
||||||
|
const trimTrailingSlash = (value = '') => value.replace(/\/+$/, '')
|
||||||
|
|
||||||
|
export const subPortalBaseURL = trimTrailingSlash(import.meta.env.VITE_SUB_PORTAL_BASE_URL || '')
|
||||||
|
export const mainPortalBaseURL = trimTrailingSlash(import.meta.env.VITE_MAIN_PORTAL_BASE_URL || '')
|
||||||
|
|
||||||
|
const getOrigin = (url) => {
|
||||||
|
if (!url) return ''
|
||||||
|
try {
|
||||||
|
return new URL(url).origin
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const subPortalOrigin = getOrigin(subPortalBaseURL)
|
||||||
|
export const mainPortalOrigin = getOrigin(mainPortalBaseURL)
|
||||||
|
export const isPortalDomainConfigReady = Boolean(subPortalOrigin && mainPortalOrigin)
|
||||||
|
|
||||||
|
export const isCurrentOrigin = (origin) => {
|
||||||
|
if (!origin || typeof window === 'undefined') return false
|
||||||
|
return window.location.origin === origin
|
||||||
|
}
|
||||||
@@ -1,25 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen w-full flex items-center justify-center bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 relative">
|
<div class="auth-shell">
|
||||||
<div class="relative w-full max-w-[480px] auth-card px-8 py-10 flex flex-col justify-center min-h-[480px] sm:min-h-[400px] mx-4">
|
<div class="auth-main-grid">
|
||||||
<!-- Logo与标题 -->
|
<aside class="auth-brand-panel">
|
||||||
<div class="flex flex-col items-center mb-8">
|
<img class="auth-brand-logo" src="@/assets/logo.png" alt="天远数据 Logo" />
|
||||||
<div class="w-16 h-16 flex items-center justify-center bg-gradient-to-br from-blue-600 to-indigo-600 rounded-2xl mb-4 shadow-lg">
|
<p class="auth-panel-kicker auth-panel-kicker-left">欢迎访问</p>
|
||||||
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<h2 class="auth-brand-heading">账号中心</h2>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
<p class="auth-brand-kicker">ENTERPRISE DATA CONSOLE</p>
|
||||||
</svg>
|
<h1 class="auth-brand-title">天远数据控制台</h1>
|
||||||
|
<p class="auth-brand-subtitle">面向企业的数据服务平台,提供稳定认证体系与安全访问能力。</p>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="auth-form-panel">
|
||||||
|
<div class="auth-form-wrap px-8 py-10 sm:px-10 sm:py-12">
|
||||||
|
<router-view />
|
||||||
|
<div class="text-center text-xs text-gray-400 mt-8 pt-6 border-t border-gray-100">
|
||||||
|
© 2026 天远数据控制台. All rights reserved.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="auth-title mb-1">天远数据控制台</h1>
|
</section>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 内容区 -->
|
|
||||||
<div class="flex-1 flex flex-col justify-center">
|
|
||||||
<router-view />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 底部版权 -->
|
|
||||||
<div class="text-center text-xs text-gray-400 mt-8 pt-6 border-t border-gray-100">
|
|
||||||
© 2024 天远数据控制台. All rights reserved.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -29,34 +27,127 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 性能优化:减少动画效果 */
|
.auth-shell {
|
||||||
@media (prefers-reduced-motion: reduce) {
|
min-height: 100vh;
|
||||||
* {
|
width: 100%;
|
||||||
animation-duration: 0.01ms !important;
|
padding: 20px;
|
||||||
animation-iteration-count: 1 !important;
|
background:
|
||||||
transition-duration: 0.01ms !important;
|
radial-gradient(1200px 460px at 10% -10%, rgba(30, 64, 175, 0.12), transparent 60%),
|
||||||
|
radial-gradient(1000px 500px at 100% 0%, rgba(51, 65, 85, 0.1), transparent 60%),
|
||||||
|
linear-gradient(150deg, #f8fbff 0%, #f5f8fd 45%, #f7f9fc 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-main-grid {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: calc(100vh - 40px);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.1fr 0.9fr;
|
||||||
|
border-radius: 22px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 20px 52px rgba(15, 23, 42, 0.09);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand-panel {
|
||||||
|
padding: 56px 52px;
|
||||||
|
color: #fff;
|
||||||
|
background:
|
||||||
|
linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px),
|
||||||
|
radial-gradient(500px 300px at 20% -10%, rgba(255, 255, 255, 0.16), transparent 60%),
|
||||||
|
linear-gradient(150deg, #0b1f3e 0%, #12305f 45%, #173e7a 100%);
|
||||||
|
background-size: 28px 28px, 28px 28px, auto, auto;
|
||||||
|
background-position: 0 0, 0 0, 0 0, 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand-logo {
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
box-shadow: 0 10px 24px rgba(3, 7, 18, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand-kicker {
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
color: rgba(255, 255, 255, 0.68);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand-heading {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand-title {
|
||||||
|
margin-top: 18px;
|
||||||
|
font-size: 34px;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand-subtitle {
|
||||||
|
margin-top: 14px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.9;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form-panel {
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
padding: 26px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form-wrap {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.auth-panel-kicker {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: #1e3a8a;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel-kicker-left {
|
||||||
|
margin-top: 22px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: rgba(255, 255, 255, 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.auth-main-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.auth-brand-panel {
|
||||||
|
padding: 32px 26px;
|
||||||
|
}
|
||||||
|
.auth-form-panel {
|
||||||
|
padding: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 性能优化:低端设备降级 */
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.min-h-\[480px\] {
|
.auth-shell {
|
||||||
min-height: 320px !important;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
.px-8 {
|
|
||||||
padding-left: 1rem !important;
|
|
||||||
padding-right: 1rem !important;
|
|
||||||
}
|
|
||||||
.mx-4 {
|
|
||||||
margin-left: 0.5rem !important;
|
|
||||||
margin-right: 0.5rem !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 性能优化:简化背景渐变 */
|
.auth-main-grid {
|
||||||
@media (max-width: 768px), (max-device-pixel-ratio: 1) {
|
min-height: calc(100vh - 20px);
|
||||||
.bg-gradient-to-br {
|
border-radius: 18px;
|
||||||
background: #f8fafc !important;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<el-header class="el-header">
|
<el-header class="el-header">
|
||||||
<AppHeader
|
<AppHeader
|
||||||
:title="headerTitle"
|
:title="headerTitle"
|
||||||
|
:logo="headerLogo"
|
||||||
:badge="headerBadge"
|
:badge="headerBadge"
|
||||||
:badge-type="headerBadgeType"
|
:badge-type="headerBadgeType"
|
||||||
:theme="headerTheme"
|
:theme="headerTheme"
|
||||||
@@ -18,10 +19,10 @@
|
|||||||
<el-container>
|
<el-container>
|
||||||
<!-- 桌面端显示侧边栏,移动端隐藏(移动端使用抽屉式侧边栏) -->
|
<!-- 桌面端显示侧边栏,移动端隐藏(移动端使用抽屉式侧边栏) -->
|
||||||
<el-aside v-if="!appStore.isMobile" width="240px">
|
<el-aside v-if="!appStore.isMobile" width="240px">
|
||||||
<AppSidebar :menu-items="currentMenuItems" :theme="sidebarTheme" />
|
<AppSidebar :menu-items="currentMenuItems" :theme="sidebarTheme" :show-home-link="showHomeLink" />
|
||||||
</el-aside>
|
</el-aside>
|
||||||
<!-- 移动端也渲染侧边栏,但使用固定定位的抽屉式效果 -->
|
<!-- 移动端也渲染侧边栏,但使用固定定位的抽屉式效果 -->
|
||||||
<AppSidebar v-if="appStore.isMobile" :menu-items="currentMenuItems" :theme="sidebarTheme" />
|
<AppSidebar v-if="appStore.isMobile" :menu-items="currentMenuItems" :theme="sidebarTheme" :show-home-link="showHomeLink" />
|
||||||
<el-main :class="{ 'mobile-main': appStore.isMobile }">
|
<el-main :class="{ 'mobile-main': appStore.isMobile }">
|
||||||
<div class="content-wrapper">
|
<div class="content-wrapper">
|
||||||
<!-- 企业认证提示 - 根据当前页面路径显示 -->
|
<!-- 企业认证提示 - 根据当前页面路径显示 -->
|
||||||
@@ -42,7 +43,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 右侧浮动联系客服 -->
|
<!-- 右侧浮动联系客服 -->
|
||||||
<FloatingCustomerService />
|
<FloatingCustomerService v-if="!isSubPortal" />
|
||||||
</el-container>
|
</el-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -52,6 +53,8 @@ import FloatingCustomerService from '@/components/common/FloatingCustomerService
|
|||||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||||
import AppSidebar from '@/components/layout/AppSidebar.vue'
|
import AppSidebar from '@/components/layout/AppSidebar.vue'
|
||||||
import NotificationPanel from '@/components/layout/NotificationPanel.vue'
|
import NotificationPanel from '@/components/layout/NotificationPanel.vue'
|
||||||
|
import headerLogo from '@/assets/logo.png'
|
||||||
|
import { isSubPortal } from '@/constants/portal'
|
||||||
import { getCurrentPageCertificationConfig, getUserAccessibleMenuItems } from '@/constants/menu'
|
import { getCurrentPageCertificationConfig, getUserAccessibleMenuItems } from '@/constants/menu'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
@@ -63,23 +66,14 @@ const userStore = useUserStore()
|
|||||||
|
|
||||||
const showNotifications = ref(false)
|
const showNotifications = ref(false)
|
||||||
|
|
||||||
// 性能优化:检测设备性能
|
|
||||||
const isLowPerformanceDevice = computed(() => {
|
|
||||||
// 检测硬件并发数、内存等
|
|
||||||
const hardwareConcurrency = navigator.hardwareConcurrency || 4
|
|
||||||
const memory = navigator.deviceMemory || 4
|
|
||||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|
|
||||||
|
|
||||||
return hardwareConcurrency <= 4 || memory <= 4 || isMobile
|
|
||||||
})
|
|
||||||
|
|
||||||
// 根据用户类型计算布局属性
|
// 根据用户类型计算布局属性
|
||||||
const isAdmin = computed(() => userStore.isAdmin)
|
const isAdmin = computed(() => userStore.isAdmin)
|
||||||
|
|
||||||
const headerTitle = computed(() => {
|
const headerTitle =computed(() => {
|
||||||
return isAdmin.value ? '天远数据控制台' : '天远数据控制台'
|
return isAdmin.value ? '天远数据控制台' : '天远数据控制台'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const headerBadge = computed(() => {
|
const headerBadge = computed(() => {
|
||||||
return isAdmin.value ? '管理员模式' : null
|
return isAdmin.value ? '管理员模式' : null
|
||||||
})
|
})
|
||||||
@@ -106,7 +100,10 @@ const notificationTheme = computed(() => {
|
|||||||
|
|
||||||
// 动态菜单项
|
// 动态菜单项
|
||||||
const currentMenuItems = computed(() => {
|
const currentMenuItems = computed(() => {
|
||||||
return getUserAccessibleMenuItems(userStore.userType, userStore.isCertified)
|
return getUserAccessibleMenuItems(userStore.userType, userStore.isCertified, {
|
||||||
|
accountKind: userStore.accountKind,
|
||||||
|
isSubPortal
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 认证相关逻辑
|
// 认证相关逻辑
|
||||||
@@ -135,10 +132,15 @@ const shouldShowCertificationNotice = computed(() => {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const showHomeLink = computed(() => !(userStore.accountKind === 'subordinate' || isSubPortal))
|
||||||
|
|
||||||
// 处理用户菜单命令
|
// 处理用户菜单命令
|
||||||
const handleUserCommand = async (command) => {
|
const handleUserCommand = async (command) => {
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case 'home':
|
case 'home':
|
||||||
|
if (isSubPortal) {
|
||||||
|
return
|
||||||
|
}
|
||||||
window.open('https://www.tianyuanapi.com/', '_blank', 'noopener')
|
window.open('https://www.tianyuanapi.com/', '_blank', 'noopener')
|
||||||
break
|
break
|
||||||
case 'profile':
|
case 'profile':
|
||||||
@@ -163,8 +165,13 @@ const handleUserCommand = async (command) => {
|
|||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
})
|
})
|
||||||
|
const isSubordinateAccount = userStore.accountKind === 'subordinate'
|
||||||
userStore.logout()
|
userStore.logout()
|
||||||
router.push('/auth/login')
|
router.push(
|
||||||
|
isSubordinateAccount || isSubPortal
|
||||||
|
? '/sub/auth/login'
|
||||||
|
: '/auth/login'
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
// 用户取消
|
// 用户取消
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
731
src/pages/admin/certification-reviews/index.vue
Normal file
731
src/pages/admin/certification-reviews/index.vue
Normal file
@@ -0,0 +1,731 @@
|
|||||||
|
<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="canShowApprove(row) || canShowReject(row)">
|
||||||
|
<el-button v-if="canShowApprove(row)" link type="success" @click="handleApprove(row)">通过</el-button>
|
||||||
|
<el-button v-if="canShowReject(row)" 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="canShowApprove(row) || canShowReject(row)">
|
||||||
|
<el-button v-if="canShowApprove(row)" type="success" plain size="small" @click="handleApprove(row)">通过</el-button>
|
||||||
|
<el-button v-if="canShowReject(row)" 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 && (canShowApprove(detail) || canShowReject(detail))">
|
||||||
|
<el-button v-if="canShowApprove(detail)" type="success" size="small" @click="approveFromDrawer">通过</el-button>
|
||||||
|
<el-button v-if="canShowReject(detail)" 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, ElMessageBox } 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 canShowApprove(row) {
|
||||||
|
if (!row) return false
|
||||||
|
if (row.certification_status !== 'info_pending_review') return false
|
||||||
|
return row.status === 'verified'
|
||||||
|
}
|
||||||
|
|
||||||
|
function canShowReject(row) {
|
||||||
|
if (!row) return false
|
||||||
|
const rejectableStatus =
|
||||||
|
row.certification_status === 'info_pending_review' ||
|
||||||
|
row.certification_status === 'info_submitted'
|
||||||
|
if (!rejectableStatus) 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) {
|
||||||
|
const msg = e?.message || '操作失败'
|
||||||
|
if (msg.includes('生成企业认证链接失败')) {
|
||||||
|
const esignError = msg.replace(/^生成企业认证链接失败:\s*/, '')
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`企业认证链接生成失败:${esignError}\n\n请根据 e签宝 返回的错误信息填写拒绝原因并点击「拒绝」,以便用户修正后重新提交。`,
|
||||||
|
'无法通过审核',
|
||||||
|
{
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '去填写拒绝原因',
|
||||||
|
cancelButtonText: '知道了'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
rejectRemark.value = esignError
|
||||||
|
rejectDialogVisible.value = true
|
||||||
|
} catch {
|
||||||
|
// 用户点击「知道了」
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ElMessage.error(msg)
|
||||||
|
}
|
||||||
|
} 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>
|
||||||
@@ -12,6 +12,15 @@
|
|||||||
<span :class="isMobile ? 'hidden sm:inline' : ''">新增产品</span>
|
<span :class="isMobile ? 'hidden sm:inline' : ''">新增产品</span>
|
||||||
<span :class="isMobile ? 'sm:hidden' : 'hidden'">新增</span>
|
<span :class="isMobile ? 'sm:hidden' : 'hidden'">新增</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="success"
|
||||||
|
:size="isMobile ? 'small' : 'default'"
|
||||||
|
@click="exportProductDictionary"
|
||||||
|
>
|
||||||
|
<Download class="w-4 h-4 mr-1" />
|
||||||
|
<span :class="isMobile ? 'hidden sm:inline' : ''">导出产品字典</span>
|
||||||
|
<span :class="isMobile ? 'sm:hidden' : 'hidden'">导出</span>
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #filters>
|
<template #filters>
|
||||||
@@ -123,6 +132,13 @@
|
|||||||
<span class="card-label">成本价</span>
|
<span class="card-label">成本价</span>
|
||||||
<span class="card-value text-gray-600">¥{{ formatPrice(product.cost_price) }}</span>
|
<span class="card-value text-gray-600">¥{{ formatPrice(product.cost_price) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-row">
|
||||||
|
<span class="card-label">备注</span>
|
||||||
|
<el-tag v-if="product.remark" type="warning" size="small" class="max-w-[60%] truncate">
|
||||||
|
{{ product.remark }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-else class="card-value text-gray-400">-</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
@@ -199,6 +215,13 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column prop="remark" label="备注" min-width="150" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.remark" type="warning" size="small">{{ row.remark }}</el-tag>
|
||||||
|
<span v-else class="text-gray-400">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column prop="is_enabled" label="启用状态" width="120">
|
<el-table-column prop="is_enabled" label="启用状态" width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.is_enabled ? 'success' : 'danger'" size="small">
|
<el-tag :type="row.is_enabled ? 'success' : 'danger'" size="small">
|
||||||
@@ -327,8 +350,8 @@ 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 } from '@element-plus/icons-vue'
|
import { ArrowDown, Download } from '@element-plus/icons-vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -571,6 +594,40 @@ const handleFormSuccess = () => {
|
|||||||
loadProducts()
|
loadProducts()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 导出产品字典
|
||||||
|
const exportProductDictionary = async () => {
|
||||||
|
const loading = ElLoading.service({
|
||||||
|
lock: true,
|
||||||
|
text: '正在生成产品字典...',
|
||||||
|
background: 'rgba(0, 0, 0, 0.7)'
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
// 调用导出API,默认使用Excel格式
|
||||||
|
const response = await productAdminApi.exportProductDictionary({ format: 'excel' })
|
||||||
|
|
||||||
|
// 创建下载链接
|
||||||
|
const blob = new Blob([response.data], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `产品字典_${new Date().toLocaleDateString('zh-CN').replace(/\//g, '-')}.xlsx`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
|
||||||
|
ElMessage.success('产品字典导出成功')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导出产品字典失败:', error)
|
||||||
|
ElMessage.error('导出产品字典失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
loading.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 移动端操作处理
|
// 移动端操作处理
|
||||||
const handleMobileAction = (command, product) => {
|
const handleMobileAction = (command, product) => {
|
||||||
switch (command) {
|
switch (command) {
|
||||||
|
|||||||
@@ -746,8 +746,8 @@ const handleExport = async (options) => {
|
|||||||
// 调用导出API(需要在后端添加相应的API)
|
// 调用导出API(需要在后端添加相应的API)
|
||||||
const response = await financeApi.exportAdminPurchaseRecords(params)
|
const response = await financeApi.exportAdminPurchaseRecords(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'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -329,11 +329,12 @@
|
|||||||
v-model="exportDialogVisible"
|
v-model="exportDialogVisible"
|
||||||
title="导出钱包交易记录"
|
title="导出钱包交易记录"
|
||||||
:loading="exportLoading"
|
:loading="exportLoading"
|
||||||
:show-company-select="true"
|
:show-company-select="!singleUserMode"
|
||||||
:show-product-select="true"
|
:show-product-select="true"
|
||||||
:show-recharge-type-select="false"
|
:show-recharge-type-select="false"
|
||||||
:show-status-select="false"
|
:show-status-select="false"
|
||||||
:show-date-range="true"
|
:show-date-range="true"
|
||||||
|
:is-single-user-mode="singleUserMode"
|
||||||
@confirm="handleExport"
|
@confirm="handleExport"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -543,7 +544,12 @@ const handleViewDetail = (transaction) => {
|
|||||||
|
|
||||||
// 导出相关方法
|
// 导出相关方法
|
||||||
const showExportDialog = () => {
|
const showExportDialog = () => {
|
||||||
exportDialogVisible.value = true
|
// 如果是单用户模式,默认选中当前用户
|
||||||
|
if (singleUserMode.value && currentUser.value?.id) {
|
||||||
|
exportDialogVisible.value = true
|
||||||
|
} else {
|
||||||
|
exportDialogVisible.value = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExport = async (options) => {
|
const handleExport = async (options) => {
|
||||||
@@ -555,8 +561,11 @@ const handleExport = async (options) => {
|
|||||||
format: options.format
|
format: options.format
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加企业筛选
|
// 单用户模式:自动添加当前用户ID筛选
|
||||||
if (options.companyIds.length > 0) {
|
if (singleUserMode.value && currentUser.value?.id) {
|
||||||
|
params.user_ids = currentUser.value.id
|
||||||
|
} else if (options.companyIds.length > 0) {
|
||||||
|
// 多用户模式:使用用户选择的企业筛选
|
||||||
params.user_ids = options.companyIds.join(',')
|
params.user_ids = options.companyIds.join(',')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,7 +584,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'
|
||||||
|
|||||||
@@ -337,11 +337,12 @@
|
|||||||
v-model="exportDialogVisible"
|
v-model="exportDialogVisible"
|
||||||
title="导出API调用记录"
|
title="导出API调用记录"
|
||||||
:loading="exportLoading"
|
:loading="exportLoading"
|
||||||
:show-company-select="true"
|
:show-company-select="!singleUserMode"
|
||||||
:show-product-select="true"
|
:show-product-select="true"
|
||||||
:show-recharge-type-select="false"
|
:show-recharge-type-select="false"
|
||||||
:show-status-select="false"
|
:show-status-select="false"
|
||||||
:show-date-range="true"
|
:show-date-range="true"
|
||||||
|
:is-single-user-mode="singleUserMode"
|
||||||
@confirm="handleExport"
|
@confirm="handleExport"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -589,7 +590,12 @@ watch(() => route.query.user_id, async (newUserId) => {
|
|||||||
|
|
||||||
// 导出相关方法
|
// 导出相关方法
|
||||||
const showExportDialog = () => {
|
const showExportDialog = () => {
|
||||||
exportDialogVisible.value = true
|
// 如果是单用户模式,默认选中当前用户
|
||||||
|
if (singleUserMode.value && currentUser.value?.id) {
|
||||||
|
exportDialogVisible.value = true
|
||||||
|
} else {
|
||||||
|
exportDialogVisible.value = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExport = async (options) => {
|
const handleExport = async (options) => {
|
||||||
@@ -601,8 +607,11 @@ const handleExport = async (options) => {
|
|||||||
format: options.format
|
format: options.format
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加企业筛选
|
// 单用户模式:自动添加当前用户ID筛选
|
||||||
if (options.companyIds.length > 0) {
|
if (singleUserMode.value && currentUser.value?.id) {
|
||||||
|
params.user_ids = currentUser.value.id
|
||||||
|
} else if (options.companyIds.length > 0) {
|
||||||
|
// 多用户模式:使用用户选择的企业筛选
|
||||||
params.user_ids = options.companyIds.join(',')
|
params.user_ids = options.companyIds.join(',')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,8 +629,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'
|
||||||
|
|||||||
@@ -1042,10 +1042,10 @@ const handleSubmitRecharge = async () => {
|
|||||||
|
|
||||||
if (rechargeForm.rechargeType === 'transfer') {
|
if (rechargeForm.rechargeType === 'transfer') {
|
||||||
params.transfer_order_id = rechargeForm.transferOrderID
|
params.transfer_order_id = rechargeForm.transferOrderID
|
||||||
await financeApi.transferRecharge(params)
|
await financeApi.adminTransferRecharge(params)
|
||||||
ElMessage.success('对公转账充值成功')
|
ElMessage.success('对公转账充值成功')
|
||||||
} else {
|
} else {
|
||||||
await financeApi.giftRecharge(params)
|
await financeApi.adminGiftRecharge(params)
|
||||||
ElMessage.success('赠送充值成功')
|
ElMessage.success('赠送充值成功')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h4 class="text-sm font-semibold text-gray-800 m-0 mb-1">{{ product.name }}</h4>
|
<h4 class="text-sm font-semibold text-gray-800 m-0 mb-1">{{ product.name }}</h4>
|
||||||
<p class="text-xs text-gray-500 m-0 mb-1 font-mono">{{ product.code }}</p>
|
<p class="text-xs text-gray-500 m-0 mb-1 font-mono">{{ product.code }}</p>
|
||||||
<div class="flex items-center gap-1">
|
<div v-if="!isSubordinate" class="flex items-center gap-1">
|
||||||
<span class="text-xs text-gray-500">价格:</span>
|
<span class="text-xs text-gray-500">价格:</span>
|
||||||
<span class="text-xs font-semibold text-red-600">¥{{ product.price }}</span>
|
<span class="text-xs font-semibold text-red-600">¥{{ product.price }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -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.name === 'vlphoto_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)">
|
||||||
@@ -618,6 +618,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { apiApi, apiKeysApi, consoleApi, formConfigApi, productApi, subscriptionApi } from '@/api'
|
import { apiApi, apiKeysApi, consoleApi, formConfigApi, productApi, subscriptionApi } from '@/api'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||||
@@ -628,7 +629,9 @@ import CodeDisplay from '@/components/common/CodeDisplay.vue'
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const isSubordinate = computed(() => userStore.accountKind === 'subordinate')
|
||||||
|
|
||||||
// 响应式数据
|
// 响应式数据
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -710,6 +713,9 @@ const filteredProducts = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const canDebug = computed(() => {
|
const canDebug = computed(() => {
|
||||||
|
if (!userStore.isCertified) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if (!selectedProduct.value || !debugForm.accessId || !debugForm.secretKey) {
|
if (!selectedProduct.value || !debugForm.accessId || !debugForm.secretKey) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -735,6 +741,10 @@ const canDebug = computed(() => {
|
|||||||
|
|
||||||
// 生命周期
|
// 生命周期
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
if (!userStore.isCertified) {
|
||||||
|
ElMessage.warning('请先完成企业认证后再使用在线调试')
|
||||||
|
return
|
||||||
|
}
|
||||||
await loadApiKeys()
|
await loadApiKeys()
|
||||||
await loadUserProducts()
|
await loadUserProducts()
|
||||||
// 自动选择产品的逻辑已在 loadUserProducts 中处理
|
// 自动选择产品的逻辑已在 loadUserProducts 中处理
|
||||||
@@ -934,21 +944,68 @@ 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图片')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PDF 专用 Base64 字段(与后端 validBase64PDF:仅 PDF、≤500KB) */
|
||||||
|
const isBase64PDFField = (field) => {
|
||||||
|
return getFieldValidationText(field).includes('仅PDF')
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (isBase64PDFField(field)) {
|
||||||
|
return 'application/pdf,.pdf'
|
||||||
|
}
|
||||||
|
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) => {
|
||||||
|
if (isBase64PDFField(field)) {
|
||||||
|
return '上传 PDF 授权书'
|
||||||
|
}
|
||||||
|
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']
|
let allowedTypes
|
||||||
|
let typeErrMsg
|
||||||
|
if (isBase64PDFField(field)) {
|
||||||
|
allowedTypes = ['application/pdf']
|
||||||
|
typeErrMsg = '仅支持 PDF 文件'
|
||||||
|
} else if (isBase64ImageOnlyField(field)) {
|
||||||
|
allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/bmp']
|
||||||
|
typeErrMsg = '只支持 JPG、BMP、PNG 格式的图片'
|
||||||
|
} else {
|
||||||
|
allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/bmp', 'application/pdf']
|
||||||
|
typeErrMsg = '只支持 JPG、BMP、PNG、PDF 格式的文件'
|
||||||
|
}
|
||||||
if (!allowedTypes.includes(fileObj.type)) {
|
if (!allowedTypes.includes(fileObj.type)) {
|
||||||
ElMessage.error('只支持 JPG、BMP、PNG 格式的图片')
|
ElMessage.error(typeErrMsg)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证文件大小(限制为5MB)
|
// PDF 授权书与后端 huibo decode 一致 ≤500KB;其它 Base64 上传仍为 5MB
|
||||||
const maxSize = 5 * 1024 * 1024 // 5MB
|
const maxSize = isBase64PDFField(field) ? 500 * 1024 : 5 * 1024 * 1024
|
||||||
if (fileObj.size > maxSize) {
|
if (fileObj.size > maxSize) {
|
||||||
ElMessage.error('图片大小不能超过 5MB')
|
ElMessage.error(isBase64PDFField(field) ? 'PDF 大小不能超过 500KB' : '文件大小不能超过 5MB')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -959,10 +1016,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)
|
||||||
|
|
||||||
@@ -1166,6 +1223,10 @@ const convertFieldTypes = (data) => {
|
|||||||
|
|
||||||
// 加密参数
|
// 加密参数
|
||||||
const encryptParams = async () => {
|
const encryptParams = async () => {
|
||||||
|
if (!userStore.isCertified) {
|
||||||
|
ElMessage.warning('请先完成企业认证后再进行调试')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!canDebug.value) {
|
if (!canDebug.value) {
|
||||||
ElMessage.warning('请先填写完整的必填参数')
|
ElMessage.warning('请先填写完整的必填参数')
|
||||||
return
|
return
|
||||||
@@ -1251,6 +1312,10 @@ const resetForm = () => {
|
|||||||
|
|
||||||
// 开始调试
|
// 开始调试
|
||||||
const handleDebug = async () => {
|
const handleDebug = async () => {
|
||||||
|
if (!userStore.isCertified) {
|
||||||
|
ElMessage.warning('请先完成企业认证后再发起请求')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!canDebug.value) return
|
if (!canDebug.value) return
|
||||||
|
|
||||||
// 新增:验证动态表单
|
// 新增:验证动态表单
|
||||||
|
|||||||
@@ -233,7 +233,7 @@
|
|||||||
<label class="detail-label">接口名称</label>
|
<label class="detail-label">接口名称</label>
|
||||||
<span class="detail-value">{{ selectedApiCall.product_name || '未知接口' }}</span>
|
<span class="detail-value">{{ selectedApiCall.product_name || '未知接口' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div v-if="!isSubordinate" class="detail-item">
|
||||||
<label class="detail-label">费用</label>
|
<label class="detail-label">费用</label>
|
||||||
<span class="detail-value">
|
<span class="detail-value">
|
||||||
<span v-if="selectedApiCall.cost" class="text-red-600 font-semibold">¥{{ formatPrice(selectedApiCall.cost) }}</span>
|
<span v-if="selectedApiCall.cost" class="text-red-600 font-semibold">¥{{ formatPrice(selectedApiCall.cost) }}</span>
|
||||||
@@ -288,6 +288,9 @@ import { ElMessage } from 'element-plus'
|
|||||||
// 移动端检测
|
// 移动端检测
|
||||||
const { isMobile } = useMobileTable()
|
const { isMobile } = useMobileTable()
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const isSubordinate = computed(() => userStore.accountKind === 'subordinate')
|
||||||
|
|
||||||
// 认证相关
|
// 认证相关
|
||||||
const {
|
const {
|
||||||
isCertified,
|
isCertified,
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
<!-- 标题 -->
|
<!-- 标题 -->
|
||||||
<div class="text-center mb-6">
|
<div class="text-center mb-6">
|
||||||
<h2 class="auth-title">欢迎回来</h2>
|
<h2 class="auth-title">欢迎回来</h2>
|
||||||
<p class="auth-subtitle">请选择登录方式</p>
|
<p class="auth-subtitle">使用账号信息登录控制台</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 登录方式切换 -->
|
<!-- 登录方式切换 -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<el-radio-group v-model="loginMethod" class="w-full flex bg-gray-100 rounded-lg p-1 auth-radio">
|
<el-radio-group v-model="loginMethod" class="w-full flex auth-radio auth-method-tabs">
|
||||||
<el-radio-button value="sms" class="flex-1 !border-0 !rounded-md">
|
<el-radio-button value="sms" class="auth-method-tab !border-0 !rounded-md">
|
||||||
<div class="flex items-center justify-center gap-2">
|
<div class="flex items-center justify-center gap-2">
|
||||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
|
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
验证码登录
|
验证码登录
|
||||||
</div>
|
</div>
|
||||||
</el-radio-button>
|
</el-radio-button>
|
||||||
<el-radio-button value="password" class="flex-1 !border-0 !rounded-md">
|
<el-radio-button value="password" class="auth-method-tab !border-0 !rounded-md">
|
||||||
<div class="flex items-center justify-center gap-2">
|
<div class="flex items-center justify-center gap-2">
|
||||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path fill-rule="evenodd"
|
<path fill-rule="evenodd"
|
||||||
@@ -83,7 +83,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 操作链接 -->
|
<!-- 操作链接 -->
|
||||||
<div class="flex items-center justify-end text-sm ">
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-slate-400">还没有账号?</span>
|
||||||
<router-link to="/auth/reset" class="auth-link">
|
<router-link to="/auth/reset" class="auth-link">
|
||||||
忘记密码?
|
忘记密码?
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -115,10 +116,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 +160,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 {
|
||||||
@@ -239,47 +250,3 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 自定义radio button样式 */
|
|
||||||
:deep(.el-radio-button__inner) {
|
|
||||||
width: 100% !important;
|
|
||||||
border: none !important;
|
|
||||||
background: transparent !important;
|
|
||||||
color: #6b7280 !important;
|
|
||||||
font-weight: 500 !important;
|
|
||||||
transition: all 0.3s ease !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
|
|
||||||
background: white !important;
|
|
||||||
color: #3b82f6 !important;
|
|
||||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 输入框样式优化 */
|
|
||||||
:deep(.el-input__wrapper) {
|
|
||||||
border-radius: 8px !important;
|
|
||||||
transition: all 0.3s ease !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-input__wrapper:hover) {
|
|
||||||
border-color: #3b82f6 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-input__wrapper.is-focus) {
|
|
||||||
border-color: #3b82f6 !important;
|
|
||||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 按钮样式优化 */
|
|
||||||
:deep(.el-button--primary) {
|
|
||||||
border-radius: 8px !important;
|
|
||||||
font-weight: 500 !important;
|
|
||||||
transition: all 0.3s ease !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-button--primary:hover) {
|
|
||||||
transform: translateY(-1px) !important;
|
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3) !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<!-- 标题 -->
|
<!-- 标题 -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<h2 class="auth-title">创建账号</h2>
|
<h2 class="auth-title">创建账号</h2>
|
||||||
<p class="auth-subtitle">请填写以下信息完成注册</p>
|
<p class="auth-subtitle">填写手机号与验证码,快速创建控制台账号</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="space-y-4" @submit.prevent="onRegister">
|
<form class="space-y-4" @submit.prevent="onRegister">
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 操作链接 -->
|
<!-- 操作链接 -->
|
||||||
<div class="text-center py-2">
|
<div class="text-center py-1">
|
||||||
<router-link to="/auth/login" class="auth-link text-sm"> 已有账号?去登录 </router-link>
|
<router-link to="/auth/login" class="auth-link text-sm"> 已有账号?去登录 </router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -133,15 +133,21 @@
|
|||||||
{{ loading ? '注册中...' : '注册' }}
|
{{ loading ? '注册中...' : '注册' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-4 text-xs text-center text-slate-400">
|
||||||
|
注册即表示您同意平台相关服务条款与隐私政策
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<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 +181,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 {
|
||||||
@@ -242,31 +256,3 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 输入框样式优化 */
|
|
||||||
:deep(.el-input__wrapper) {
|
|
||||||
border-radius: 8px !important;
|
|
||||||
transition: all 0.3s ease !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-input__wrapper:hover) {
|
|
||||||
border-color: #3b82f6 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-input__wrapper.is-focus) {
|
|
||||||
border-color: #3b82f6 !important;
|
|
||||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 按钮样式优化 */
|
|
||||||
:deep(.el-button--primary) {
|
|
||||||
border-radius: 8px !important;
|
|
||||||
font-weight: 500 !important;
|
|
||||||
transition: all 0.3s ease !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-button--primary:hover) {
|
|
||||||
transform: translateY(-1px) !important;
|
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3) !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<!-- 标题 -->
|
<!-- 标题 -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<h2 class="auth-title">重置密码</h2>
|
<h2 class="auth-title">重置密码</h2>
|
||||||
<p class="auth-subtitle">请输入手机号和验证码重置密码</p>
|
<p class="auth-subtitle">验证手机号后重新设置登录密码</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="space-y-4" @submit.prevent="onReset">
|
<form class="space-y-4" @submit.prevent="onReset">
|
||||||
@@ -103,8 +103,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 操作链接 -->
|
<!-- 操作链接 -->
|
||||||
<div class="text-center py-2">
|
<div class="text-center py-1">
|
||||||
<router-link to="/auth/login" class="auth-link text-sm">
|
<router-link :to="loginRoutePath" class="auth-link text-sm">
|
||||||
返回登录
|
返回登录
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,15 +121,25 @@
|
|||||||
{{ loading ? '重置中...' : '重置密码' }}
|
{{ loading ? '重置中...' : '重置密码' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-4 text-xs text-center text-slate-400">
|
||||||
|
为了账号安全,请使用强密码并妥善保管
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<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 route = useRoute()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const { runWithCaptcha } = useAliyunCaptcha()
|
||||||
|
const loginRoutePath = computed(() => {
|
||||||
|
return route.path.startsWith('/sub/auth') ? '/sub/auth/login' : '/auth/login'
|
||||||
|
})
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const form = ref({
|
const form = ref({
|
||||||
@@ -157,17 +167,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 {
|
||||||
@@ -207,7 +225,7 @@ const onReset = async () => {
|
|||||||
const result = await userStore.resetPassword(resetData)
|
const result = await userStore.resetPassword(resetData)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
ElMessage.success('密码重置成功')
|
ElMessage.success('密码重置成功')
|
||||||
router.push('/auth/login')
|
router.push(loginRoutePath.value)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('密码重置失败:', error)
|
console.error('密码重置失败:', error)
|
||||||
@@ -224,31 +242,3 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 输入框样式优化 */
|
|
||||||
:deep(.el-input__wrapper) {
|
|
||||||
border-radius: 8px !important;
|
|
||||||
transition: all 0.3s ease !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-input__wrapper:hover) {
|
|
||||||
border-color: #3b82f6 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-input__wrapper.is-focus) {
|
|
||||||
border-color: #3b82f6 !important;
|
|
||||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 按钮样式优化 */
|
|
||||||
:deep(.el-button--primary) {
|
|
||||||
border-radius: 8px !important;
|
|
||||||
font-weight: 500 !important;
|
|
||||||
transition: all 0.3s ease !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-button--primary:hover) {
|
|
||||||
transform: translateY(-1px) !important;
|
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3) !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -14,6 +14,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="showRejectionAlert"
|
||||||
|
type="error"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="rejection-alert"
|
||||||
|
title="上次审核未通过"
|
||||||
|
>
|
||||||
|
<p class="rejection-message">{{ failureMessage }}</p>
|
||||||
|
<p class="rejection-hint">请根据以上原因修改企业信息后重新提交</p>
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
<div class="enterprise-form">
|
<div class="enterprise-form">
|
||||||
<el-form
|
<el-form
|
||||||
ref="enterpriseFormRef"
|
ref="enterpriseFormRef"
|
||||||
@@ -22,7 +34,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 +54,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 +82,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 +95,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 +109,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 +210,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 +370,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,14 +383,37 @@ const props = defineProps({
|
|||||||
legalPersonID: '',
|
legalPersonID: '',
|
||||||
legalPersonPhone: '',
|
legalPersonPhone: '',
|
||||||
enterpriseAddress: '',
|
enterpriseAddress: '',
|
||||||
legalPersonCode: ''
|
legalPersonCode: '',
|
||||||
|
// 扩展:营业执照 & 办公场地 & 场景
|
||||||
|
businessLicenseImageURL: '',
|
||||||
|
officePlaceImageURLs: [],
|
||||||
|
apiUsage: '',
|
||||||
|
scenarioAttachmentURLs: [],
|
||||||
|
// 授权代表信息
|
||||||
|
authorizedRepName: '',
|
||||||
|
authorizedRepID: '',
|
||||||
|
authorizedRepPhone: '',
|
||||||
|
authorizedRepIDImageURLs: []
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
certificationStatus: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
failureMessage: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const showRejectionAlert = computed(
|
||||||
|
() => props.certificationStatus === 'info_rejected' && !!props.failureMessage?.trim()
|
||||||
|
)
|
||||||
|
|
||||||
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 +426,17 @@ const form = ref({
|
|||||||
legalPersonID: '',
|
legalPersonID: '',
|
||||||
legalPersonPhone: '',
|
legalPersonPhone: '',
|
||||||
enterpriseAddress: '',
|
enterpriseAddress: '',
|
||||||
legalPersonCode: ''
|
legalPersonCode: '',
|
||||||
|
// 扩展:营业执照 & 办公场地 & 场景
|
||||||
|
businessLicenseImageURL: '',
|
||||||
|
officePlaceImageURLs: [],
|
||||||
|
apiUsage: '',
|
||||||
|
scenarioAttachmentURLs: [],
|
||||||
|
// 授权代表信息
|
||||||
|
authorizedRepName: '',
|
||||||
|
authorizedRepID: '',
|
||||||
|
authorizedRepPhone: '',
|
||||||
|
authorizedRepIDImageURLs: []
|
||||||
})
|
})
|
||||||
|
|
||||||
// 验证码相关状态
|
// 验证码相关状态
|
||||||
@@ -237,6 +451,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 +517,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 +565,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 +615,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 +650,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 +688,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 +706,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
|
||||||
}
|
}
|
||||||
@@ -459,6 +963,22 @@ onUnmounted(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rejection-alert {
|
||||||
|
margin: 0 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rejection-message {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rejection-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
/* 卡片头部 */
|
/* 卡片头部 */
|
||||||
.card-header {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -584,6 +1104,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 +1135,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 +1277,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>
|
||||||
@@ -65,9 +65,19 @@
|
|||||||
<EnterpriseInfo
|
<EnterpriseInfo
|
||||||
v-if="currentStep === 'enterprise_info'"
|
v-if="currentStep === 'enterprise_info'"
|
||||||
:form-data="enterpriseForm"
|
:form-data="enterpriseForm"
|
||||||
|
:certification-status="certificationData?.status"
|
||||||
|
:failure-message="certificationData?.failure_message"
|
||||||
@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 +130,7 @@ import { useUserStore } from '@/stores/user'
|
|||||||
import {
|
import {
|
||||||
BuildingOfficeIcon,
|
BuildingOfficeIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
|
ClockIcon,
|
||||||
CodeBracketIcon,
|
CodeBracketIcon,
|
||||||
DocumentTextIcon,
|
DocumentTextIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
@@ -133,9 +144,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 +154,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 +193,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 +217,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 +415,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
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
|
|
||||||
<div v-else-if="transactions.length === 0" class="text-center py-12">
|
<div v-else-if="transactions.length === 0" class="text-center py-12">
|
||||||
<el-empty description="暂无消费记录">
|
<el-empty description="暂无消费记录">
|
||||||
<el-button type="primary" @click="$router.push('/finance/wallet')">
|
<el-button v-if="!isSubordinate" type="primary" @click="$router.push('/finance/wallet')">
|
||||||
前往钱包充值
|
前往钱包充值
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-empty>
|
</el-empty>
|
||||||
@@ -203,9 +203,12 @@ 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 { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
// 移动端检测
|
// 移动端检测
|
||||||
const { isMobile } = useMobileTable()
|
const { isMobile } = useMobileTable()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const isSubordinate = computed(() => userStore.accountKind === 'subordinate')
|
||||||
|
|
||||||
// 响应式数据
|
// 响应式数据
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -412,6 +429,7 @@
|
|||||||
:close-on-click-modal="false"
|
:close-on-click-modal="false"
|
||||||
:close-on-press-escape="false"
|
:close-on-press-escape="false"
|
||||||
class="qr-code-dialog"
|
class="qr-code-dialog"
|
||||||
|
@close="handleQrCodeDialogClose"
|
||||||
>
|
>
|
||||||
<div class="qr-code-container">
|
<div class="qr-code-container">
|
||||||
<div class="qr-code-wrapper">
|
<div class="qr-code-wrapper">
|
||||||
@@ -420,6 +438,7 @@
|
|||||||
<p class="qr-code-tip">请使用微信扫描上方二维码完成支付</p>
|
<p class="qr-code-tip">请使用微信扫描上方二维码完成支付</p>
|
||||||
<p class="qr-code-amount">支付金额:¥{{ formatPrice(wechatForm.amount) }}</p>
|
<p class="qr-code-amount">支付金额:¥{{ formatPrice(wechatForm.amount) }}</p>
|
||||||
<p v-if="isCheckingPayment" class="qr-code-checking">正在确认支付状态,请稍候...</p>
|
<p v-if="isCheckingPayment" class="qr-code-checking">正在确认支付状态,请稍候...</p>
|
||||||
|
<p class="qr-code-tip">关闭窗口后将继续确认支付结果,请勿重复支付</p>
|
||||||
<el-button type="primary" @click="closeQrCodeDialog" class="close-qr-btn">关闭</el-button>
|
<el-button type="primary" @click="closeQrCodeDialog" class="close-qr-btn">关闭</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -427,6 +446,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'
|
||||||
@@ -458,6 +478,11 @@ const showQrCodeDialog = ref(false)
|
|||||||
const qrCodeCanvas = ref(null)
|
const qrCodeCanvas = ref(null)
|
||||||
const currentWechatOrderNo = ref(null)
|
const currentWechatOrderNo = ref(null)
|
||||||
const isCheckingPayment = ref(false)
|
const isCheckingPayment = ref(false)
|
||||||
|
const wechatPaymentResolved = ref(false)
|
||||||
|
const skipQrCloseRedirect = ref(false)
|
||||||
|
const wechatDialogPollCount = ref(0)
|
||||||
|
const WECHAT_POLL_INTERVAL_MS = 5000
|
||||||
|
const WECHAT_DIALOG_MAX_POLL_COUNT = 12 // 弹窗内最多轮询12次(约1分钟),之后跳转处理页
|
||||||
let wechatOrderPollTimer = null
|
let wechatOrderPollTimer = null
|
||||||
|
|
||||||
// 钱包信息
|
// 钱包信息
|
||||||
@@ -468,13 +493,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 +688,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 +779,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 +798,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 +813,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'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 支付宝充值
|
// 支付宝充值
|
||||||
@@ -958,26 +1000,78 @@ const showQrCode = async (codeUrl) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭二维码弹窗
|
// 关闭二维码弹窗(用户主动关闭,跳转处理页继续轮询)
|
||||||
const closeQrCodeDialog = () => {
|
const closeQrCodeDialog = () => {
|
||||||
stopWechatOrderPolling()
|
|
||||||
showQrCodeDialog.value = false
|
showQrCodeDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQrCodeDialogClose = () => {
|
||||||
|
if (wechatPaymentResolved.value) {
|
||||||
|
wechatPaymentResolved.value = false
|
||||||
|
stopWechatOrderPolling()
|
||||||
|
currentWechatOrderNo.value = null
|
||||||
|
isCheckingPayment.value = false
|
||||||
|
wechatDialogPollCount.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipQrCloseRedirect.value) {
|
||||||
|
skipQrCloseRedirect.value = false
|
||||||
|
isCheckingPayment.value = false
|
||||||
|
wechatDialogPollCount.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderNo = currentWechatOrderNo.value
|
||||||
|
const orderAmount = wechatForm.amount
|
||||||
|
stopWechatOrderPolling()
|
||||||
currentWechatOrderNo.value = null
|
currentWechatOrderNo.value = null
|
||||||
isCheckingPayment.value = false
|
isCheckingPayment.value = false
|
||||||
|
wechatDialogPollCount.value = 0
|
||||||
|
|
||||||
|
if (orderNo) {
|
||||||
|
router.push({
|
||||||
|
path: '/finance/wallet/processing',
|
||||||
|
query: {
|
||||||
|
out_trade_no: orderNo,
|
||||||
|
amount: orderAmount,
|
||||||
|
payment_type: 'wechat',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectToWechatProcessingPage = () => {
|
||||||
|
const orderNo = currentWechatOrderNo.value
|
||||||
|
const orderAmount = wechatForm.amount
|
||||||
|
if (!orderNo) return
|
||||||
|
|
||||||
|
stopWechatOrderPolling()
|
||||||
|
skipQrCloseRedirect.value = true
|
||||||
|
currentWechatOrderNo.value = null
|
||||||
|
wechatDialogPollCount.value = 0
|
||||||
|
showQrCodeDialog.value = false
|
||||||
|
isCheckingPayment.value = false
|
||||||
|
router.push({
|
||||||
|
path: '/finance/wallet/processing',
|
||||||
|
query: {
|
||||||
|
out_trade_no: orderNo,
|
||||||
|
amount: orderAmount,
|
||||||
|
payment_type: 'wechat',
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 开始轮询微信订单状态
|
// 开始轮询微信订单状态
|
||||||
const startWechatOrderPolling = () => {
|
const startWechatOrderPolling = () => {
|
||||||
// 清除之前的定时器
|
|
||||||
stopWechatOrderPolling()
|
stopWechatOrderPolling()
|
||||||
|
wechatDialogPollCount.value = 0
|
||||||
// 立即检查一次
|
|
||||||
checkWechatOrderStatus()
|
checkWechatOrderStatus()
|
||||||
|
|
||||||
// 每3秒轮询一次
|
|
||||||
wechatOrderPollTimer = setInterval(() => {
|
wechatOrderPollTimer = setInterval(() => {
|
||||||
|
wechatDialogPollCount.value++
|
||||||
checkWechatOrderStatus()
|
checkWechatOrderStatus()
|
||||||
}, 3000)
|
}, WECHAT_POLL_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止轮询
|
// 停止轮询
|
||||||
@@ -994,47 +1088,49 @@ const checkWechatOrderStatus = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (wechatDialogPollCount.value >= WECHAT_DIALOG_MAX_POLL_COUNT) {
|
||||||
|
redirectToWechatProcessingPage()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isCheckingPayment.value = true
|
isCheckingPayment.value = true
|
||||||
const response = await callProtectedAPI(financeApi.getWechatOrderStatus, {
|
const response = await financeApi.getWechatOrderStatus({
|
||||||
out_trade_no: currentWechatOrderNo.value
|
out_trade_no: currentWechatOrderNo.value
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response || !response.data) {
|
if (!response?.data) {
|
||||||
isCheckingPayment.value = false
|
isCheckingPayment.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderStatus = response.data.status
|
const orderStatus = response.data.status
|
||||||
|
|
||||||
// 订单状态:pending, success, failed, closed
|
|
||||||
if (orderStatus === 'success') {
|
if (orderStatus === 'success') {
|
||||||
// 支付成功
|
wechatPaymentResolved.value = true
|
||||||
stopWechatOrderPolling()
|
stopWechatOrderPolling()
|
||||||
isCheckingPayment.value = false
|
isCheckingPayment.value = false
|
||||||
closeQrCodeDialog()
|
showQrCodeDialog.value = false
|
||||||
ElMessage.success('充值成功!')
|
ElMessage.success('充值成功!')
|
||||||
|
|
||||||
// 刷新钱包余额
|
|
||||||
await loadWalletInfo()
|
await loadWalletInfo()
|
||||||
|
|
||||||
// 重置表单
|
|
||||||
wechatForm.amount = ''
|
wechatForm.amount = ''
|
||||||
selectedPresetAmount.value = null
|
selectedPresetAmount.value = null
|
||||||
|
currentWechatOrderNo.value = null
|
||||||
|
wechatDialogPollCount.value = 0
|
||||||
} else if (orderStatus === 'failed' || orderStatus === 'closed') {
|
} else if (orderStatus === 'failed' || orderStatus === 'closed') {
|
||||||
// 支付失败或关闭
|
wechatPaymentResolved.value = true
|
||||||
stopWechatOrderPolling()
|
stopWechatOrderPolling()
|
||||||
isCheckingPayment.value = false
|
isCheckingPayment.value = false
|
||||||
|
showQrCodeDialog.value = false
|
||||||
ElMessage.error('支付失败,请重新支付')
|
ElMessage.error('支付失败,请重新支付')
|
||||||
closeQrCodeDialog()
|
currentWechatOrderNo.value = null
|
||||||
|
wechatDialogPollCount.value = 0
|
||||||
} else {
|
} else {
|
||||||
// pending 状态继续轮询
|
|
||||||
isCheckingPayment.value = false
|
isCheckingPayment.value = false
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('查询微信订单状态失败:', error)
|
console.error('查询微信订单状态失败:', error)
|
||||||
isCheckingPayment.value = false
|
isCheckingPayment.value = false
|
||||||
// 不显示错误,继续轮询
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@
|
|||||||
<span class="font-medium">{{ orderInfo.out_trade_no }}</span>
|
<span class="font-medium">{{ orderInfo.out_trade_no }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-gray-500">支付宝交易号:</span>
|
<span class="text-gray-500">{{ tradeNoLabel }}:</span>
|
||||||
<span class="font-medium">{{ orderInfo.trade_no || '暂无' }}</span>
|
<span class="font-medium">{{ orderInfo.trade_no || '暂无' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
@@ -245,6 +245,57 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 轮询超时(支付可能仍在处理中) -->
|
||||||
|
<div v-else-if="orderStatus === 'timeout'" class="text-center">
|
||||||
|
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-yellow-100">
|
||||||
|
<svg
|
||||||
|
class="h-6 w-6 text-yellow-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="mt-6 text-3xl font-extrabold text-gray-900">仍在确认支付结果</h2>
|
||||||
|
<p class="mt-2 text-sm text-gray-600">
|
||||||
|
若您已完成支付,余额通常会在几分钟内到账,请稍后刷新钱包查看。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-8 bg-white shadow rounded-lg p-6">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500">订单号:</span>
|
||||||
|
<span class="font-medium">{{ orderInfo.out_trade_no }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500">金额:</span>
|
||||||
|
<span class="font-medium text-green-600">¥{{ orderInfo.amount }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 space-y-3">
|
||||||
|
<button
|
||||||
|
@click="goToWallet"
|
||||||
|
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
返回钱包查看余额
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="contactService"
|
||||||
|
class="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
联系客服
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 其他状态 -->
|
<!-- 其他状态 -->
|
||||||
<div v-else class="text-center">
|
<div v-else class="text-center">
|
||||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-yellow-100">
|
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-yellow-100">
|
||||||
@@ -291,12 +342,16 @@ export default {
|
|||||||
const orderStatus = ref('processing')
|
const orderStatus = ref('processing')
|
||||||
const isProcessing = ref(true)
|
const isProcessing = ref(true)
|
||||||
const pollCount = ref(0)
|
const pollCount = ref(0)
|
||||||
const maxPollCount = ref(30) // 最多轮询30次
|
const maxPollCount = ref(36) // 最多轮询36次(约3分钟)
|
||||||
|
const pollIntervalMs = 5000 // 每5秒查询一次,避免过于频繁
|
||||||
const pollInterval = ref(null)
|
const pollInterval = ref(null)
|
||||||
|
|
||||||
// 获取URL参数
|
// 获取URL参数
|
||||||
const outTradeNo = route.query.out_trade_no
|
const outTradeNo = route.query.out_trade_no
|
||||||
const amount = route.query.amount
|
const amount = route.query.amount
|
||||||
|
const paymentType = route.query.payment_type === 'wechat' ? 'wechat' : 'alipay'
|
||||||
|
const isWechatPay = computed(() => paymentType === 'wechat')
|
||||||
|
const tradeNoLabel = computed(() => (isWechatPay.value ? '微信交易号' : '支付宝交易号'))
|
||||||
|
|
||||||
// 初始化订单信息
|
// 初始化订单信息
|
||||||
orderInfo.value = {
|
orderInfo.value = {
|
||||||
@@ -304,6 +359,15 @@ export default {
|
|||||||
amount: amount,
|
amount: amount,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeOrderData = (data) => {
|
||||||
|
if (!data) return data
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
amount: data.amount ?? amount,
|
||||||
|
trade_no: data.trade_no || data.transaction_id || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 格式化时间
|
// 格式化时间
|
||||||
const formatTime = (timeStr) => {
|
const formatTime = (timeStr) => {
|
||||||
if (!timeStr) return '暂无'
|
if (!timeStr) return '暂无'
|
||||||
@@ -313,41 +377,45 @@ export default {
|
|||||||
// 查询订单状态
|
// 查询订单状态
|
||||||
const queryOrderStatus = async () => {
|
const queryOrderStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await financeApi.getAlipayOrderStatus({ out_trade_no: outTradeNo })
|
const response = isWechatPay.value
|
||||||
orderInfo.value = response.data
|
? await financeApi.getWechatOrderStatus({ out_trade_no: outTradeNo })
|
||||||
// 根据状态更新页面
|
: await financeApi.getAlipayOrderStatus({ out_trade_no: outTradeNo })
|
||||||
|
|
||||||
|
if (!response?.data) return
|
||||||
|
|
||||||
|
orderInfo.value = normalizeOrderData(response.data)
|
||||||
|
|
||||||
if (response.data.status === 'success') {
|
if (response.data.status === 'success') {
|
||||||
orderStatus.value = 'success'
|
orderStatus.value = 'success'
|
||||||
isProcessing.value = false
|
isProcessing.value = false
|
||||||
stopPolling()
|
stopPolling()
|
||||||
} else if (response.data.status === 'failed') {
|
} else if (response.data.status === 'failed' || response.data.status === 'closed') {
|
||||||
orderStatus.value = 'failed'
|
orderStatus.value = 'failed'
|
||||||
isProcessing.value = false
|
isProcessing.value = false
|
||||||
stopPolling()
|
stopPolling()
|
||||||
} else if (response.data.status === 'pending') {
|
} else if (pollCount.value >= maxPollCount.value) {
|
||||||
// 继续轮询,轮询次数在setInterval中已经增加
|
orderStatus.value = 'timeout'
|
||||||
if (pollCount.value >= maxPollCount.value) {
|
isProcessing.value = false
|
||||||
// 超过最大轮询次数,停止轮询
|
stopPolling()
|
||||||
stopPolling()
|
|
||||||
// 可以显示超时提示
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('查询订单状态失败:', error)
|
console.error('查询订单状态失败:', error)
|
||||||
// 查询失败时继续轮询,不要停止
|
if (pollCount.value >= maxPollCount.value) {
|
||||||
|
orderStatus.value = 'timeout'
|
||||||
|
isProcessing.value = false
|
||||||
|
stopPolling()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 开始轮询
|
// 开始轮询
|
||||||
const startPolling = () => {
|
const startPolling = () => {
|
||||||
// 立即查询一次
|
|
||||||
queryOrderStatus()
|
queryOrderStatus()
|
||||||
|
|
||||||
// 每3秒查询一次
|
|
||||||
pollInterval.value = setInterval(() => {
|
pollInterval.value = setInterval(() => {
|
||||||
pollCount.value++ // 增加轮询次数
|
pollCount.value++
|
||||||
queryOrderStatus()
|
queryOrderStatus()
|
||||||
}, 3000)
|
}, pollIntervalMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止轮询
|
// 停止轮询
|
||||||
@@ -368,10 +436,13 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const retryPayment = () => {
|
const retryPayment = () => {
|
||||||
// 重新创建支付订单
|
|
||||||
router.push({
|
router.push({
|
||||||
path: '/finance/wallet',
|
path: '/finance/wallet',
|
||||||
query: { retry: 'true', amount: amount },
|
query: {
|
||||||
|
retry: 'true',
|
||||||
|
amount: amount,
|
||||||
|
method: isWechatPay.value ? 'wechat' : 'alipay',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,6 +475,7 @@ export default {
|
|||||||
isProcessing,
|
isProcessing,
|
||||||
pollCount,
|
pollCount,
|
||||||
maxPollCount,
|
maxPollCount,
|
||||||
|
tradeNoLabel,
|
||||||
formatTime,
|
formatTime,
|
||||||
goToWallet,
|
goToWallet,
|
||||||
goToRechargeRecords,
|
goToRechargeRecords,
|
||||||
|
|||||||
980
src/pages/parent/SubordinateManagement.vue
Normal file
980
src/pages/parent/SubordinateManagement.vue
Normal file
@@ -0,0 +1,980 @@
|
|||||||
|
<template>
|
||||||
|
<ListPageLayout
|
||||||
|
title="下属"
|
||||||
|
subtitle="管理下属企业信息与额度购买"
|
||||||
|
>
|
||||||
|
<template #filters>
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-4">
|
||||||
|
<FilterSection>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<FilterItem label="备注">
|
||||||
|
<el-input
|
||||||
|
v-model="filters.remark"
|
||||||
|
placeholder="模糊搜索备注"
|
||||||
|
clearable
|
||||||
|
@input="handleFilterChange"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</FilterItem>
|
||||||
|
<FilterItem label="手机号">
|
||||||
|
<el-input
|
||||||
|
v-model="filters.phone"
|
||||||
|
placeholder="模糊搜索手机号"
|
||||||
|
clearable
|
||||||
|
maxlength="11"
|
||||||
|
@input="handleFilterChange"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</FilterItem>
|
||||||
|
<FilterItem label="下属公司">
|
||||||
|
<el-input
|
||||||
|
v-model="filters.company_name"
|
||||||
|
placeholder="模糊搜索公司名称"
|
||||||
|
clearable
|
||||||
|
@input="handleFilterChange"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</FilterItem>
|
||||||
|
</div>
|
||||||
|
<template #stats>
|
||||||
|
共找到 {{ total }} 个下属账号
|
||||||
|
</template>
|
||||||
|
<template #buttons>
|
||||||
|
<el-button class="toolbar-btn toolbar-btn-secondary" @click="resetFilters">重置</el-button>
|
||||||
|
<el-button class="toolbar-btn toolbar-btn-secondary" :loading="listLoading" @click="loadList">刷新列表</el-button>
|
||||||
|
<el-button class="toolbar-btn toolbar-btn-primary" type="primary" :loading="invLoading" @click="createInvite">复制邀请链接</el-button>
|
||||||
|
</template>
|
||||||
|
</FilterSection>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #table>
|
||||||
|
<div v-if="listLoading" class="flex justify-center items-center py-12">
|
||||||
|
<el-icon class="is-loading text-2xl text-gray-400"><Loading /></el-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="list.length === 0" class="text-center py-12 bg-white rounded-lg shadow-sm border border-gray-200">
|
||||||
|
<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="list"
|
||||||
|
style="width: 100%"
|
||||||
|
:header-cell-style="{
|
||||||
|
background: '#f8fafc',
|
||||||
|
color: '#475569',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '14px'
|
||||||
|
}"
|
||||||
|
:cell-style="{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#1e293b'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<el-table-column prop="child_user_id" label="下属用户ID" min-width="260" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="phone" label="手机号" width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="font-medium text-gray-700">{{ row.phone || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="company_name" label="下属公司" min-width="200" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.is_certified" type="success" size="small">{{ row.company_name }}</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small">未认证</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="remark" label="备注" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-input
|
||||||
|
v-model="row.remark"
|
||||||
|
placeholder="添加备注"
|
||||||
|
size="small"
|
||||||
|
clearable
|
||||||
|
:disabled="row._remarkSaving"
|
||||||
|
@blur="saveRemark(row)"
|
||||||
|
@keyup.enter="($event.target)?.blur()"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="产品与剩余额度" min-width="280">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div v-if="!row.product_quotas?.length" class="text-xs text-gray-400">暂无额度</div>
|
||||||
|
<div v-else class="flex flex-wrap gap-1">
|
||||||
|
<el-tag
|
||||||
|
v-for="item in row.product_quotas"
|
||||||
|
:key="item.product_id"
|
||||||
|
size="small"
|
||||||
|
type="info"
|
||||||
|
class="quota-tag"
|
||||||
|
>
|
||||||
|
{{ item.product_name }}:{{ item.available_quota }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="registered_at" label="注册时间" width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="text-sm text-gray-600">{{ formatDateTime(row.registered_at) }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="balance" label="余额" width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="font-semibold text-emerald-600">¥{{ formatMoney(row.balance) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="200" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="flex flex-wrap items-center gap-1">
|
||||||
|
<el-button
|
||||||
|
class="table-action-btn action-calls"
|
||||||
|
text
|
||||||
|
bg
|
||||||
|
@click="openApiCalls(row)"
|
||||||
|
>
|
||||||
|
调用记录
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
class="table-action-btn action-allocate"
|
||||||
|
:class="{ 'action-disabled': !row.is_certified }"
|
||||||
|
text
|
||||||
|
bg
|
||||||
|
@click="openAlloc(row)"
|
||||||
|
>
|
||||||
|
购买额度
|
||||||
|
</el-button>
|
||||||
|
</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="page"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<template #extra>
|
||||||
|
<el-dialog v-model="allocVisible" title="为下属购买额度" width="560px" @close="resetAllocForm">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="info-label">我的余额</div>
|
||||||
|
<div class="info-value text-blue-600">¥{{ formatMoney(allocForm.parentBalance) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="info-label">预计扣款</div>
|
||||||
|
<div class="info-value text-emerald-600">¥{{ formatMoney(allocTotalAmount) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm text-gray-600">下属企业:{{ allocForm.childCompany || '未认证' }}</div>
|
||||||
|
<div class="text-sm font-medium text-gray-700">当前已订阅产品与剩余额度</div>
|
||||||
|
<el-table :data="subscribedProductRows" border stripe size="small" max-height="220">
|
||||||
|
<el-table-column prop="product_name" label="产品" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column label="剩余额度" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="text-emerald-600 font-medium">{{ row.available_quota }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div v-if="!subscribedProductRows.length" class="text-xs text-gray-500">
|
||||||
|
当前下属暂无已订阅产品,可直接在下方选择产品购买并自动新增。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-select v-model="allocForm.productId" placeholder="请选择产品" filterable class="w-full">
|
||||||
|
<el-option
|
||||||
|
v-for="item in allocProducts"
|
||||||
|
:key="item.product_id"
|
||||||
|
:label="`${item.product_name || item.product_id}(单价 ¥${formatMoney(item.price)} / 次,剩余 ${item.available_quota})`"
|
||||||
|
:value="item.product_id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-input-number v-model="allocForm.callCount" :min="1" :step="1" controls-position="right" class="w-full" />
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<el-input v-model="allocForm.verifyCode" maxlength="6" placeholder="请输入手机验证码" />
|
||||||
|
<el-button
|
||||||
|
class="toolbar-btn toolbar-btn-secondary"
|
||||||
|
:loading="sendCodeLoading"
|
||||||
|
:disabled="allocCodeCountdown > 0"
|
||||||
|
@click="sendAllocVerifyCode"
|
||||||
|
>
|
||||||
|
{{ allocCodeCountdown > 0 ? `${allocCodeCountdown}s` : '获取验证码' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<el-button class="table-action-btn action-subscribe" text bg @click="openAllocationRecords">
|
||||||
|
额度购买记录
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button class="dialog-btn dialog-btn-secondary" @click="allocVisible = false">取消</el-button>
|
||||||
|
<el-button class="dialog-btn dialog-btn-primary" type="primary" :loading="allocLoading" @click="submitAlloc">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="allocRecordVisible" title="划款记录" width="760px">
|
||||||
|
<el-table :data="allocationRecords" border stripe>
|
||||||
|
<el-table-column prop="product_id" label="产品ID" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="call_count" label="购买次数" width="120" />
|
||||||
|
<el-table-column prop="unit_price" label="单价" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="text-blue-600 font-medium">¥{{ formatMoney(row.unit_price) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="total_amount" label="总金额" width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="text-emerald-600 font-medium">¥{{ formatMoney(row.total_amount) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="business_ref" label="业务单号" min-width="220" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="created_at" label="购买时间" width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDateTime(row.created_at) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="pagination-wrapper">
|
||||||
|
<el-pagination
|
||||||
|
v-if="allocationTotal > 0"
|
||||||
|
v-model:current-page="allocationPage"
|
||||||
|
v-model:page-size="allocationPageSize"
|
||||||
|
:total="allocationTotal"
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
@current-change="loadAllocationRecords"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button class="dialog-btn dialog-btn-secondary" @click="allocRecordVisible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="apiCallsVisible"
|
||||||
|
:title="`下属调用记录${apiCallsTarget.company ? ' - ' + apiCallsTarget.company : ''}`"
|
||||||
|
width="960px"
|
||||||
|
@close="resetApiCallsDialog"
|
||||||
|
>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<el-input
|
||||||
|
v-model="apiCallsFilters.transaction_id"
|
||||||
|
placeholder="交易ID"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="searchApiCalls"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-model="apiCallsFilters.product_name"
|
||||||
|
placeholder="产品名称"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="searchApiCalls"
|
||||||
|
/>
|
||||||
|
<el-select v-model="apiCallsFilters.status" placeholder="调用状态" clearable class="w-full">
|
||||||
|
<el-option label="全部" value="" />
|
||||||
|
<el-option label="成功" value="success" />
|
||||||
|
<el-option label="失败" value="failed" />
|
||||||
|
<el-option label="处理中" value="pending" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-sm text-gray-500">共 {{ apiCallsTotal }} 条记录</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<el-button class="toolbar-btn toolbar-btn-secondary" @click="resetApiCallsFilters">重置</el-button>
|
||||||
|
<el-button class="toolbar-btn toolbar-btn-primary" type="primary" :loading="apiCallsLoading" @click="searchApiCalls">查询</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="apiCallsLoading" :data="apiCalls" border stripe max-height="420" size="small">
|
||||||
|
<el-table-column prop="transaction_id" label="交易ID" min-width="170" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="product_name" label="接口名称" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="status" label="状态" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getApiCallStatusType(row.status)" size="small">{{ getApiCallStatusText(row.status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="translated_error_msg" label="错误信息" min-width="160" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ row.translated_error_msg || row.error_msg || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="client_ip" label="客户端IP" width="130" />
|
||||||
|
<el-table-column prop="start_at" label="调用时间" width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDateTime(row.start_at) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="90" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" type="primary" link @click="viewApiCallDetail(row)">详情</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination-wrapper !py-0">
|
||||||
|
<el-pagination
|
||||||
|
v-if="apiCallsTotal > 0"
|
||||||
|
v-model:current-page="apiCallsPage"
|
||||||
|
v-model:page-size="apiCallsPageSize"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
:total="apiCallsTotal"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
@size-change="loadChildApiCalls"
|
||||||
|
@current-change="loadChildApiCalls"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button class="dialog-btn dialog-btn-secondary" @click="apiCallsVisible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="apiCallDetailVisible" title="调用详情" width="760px">
|
||||||
|
<div v-if="selectedApiCall" class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||||
|
<div><span class="text-gray-500">交易ID:</span>{{ selectedApiCall.transaction_id }}</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">状态:</span>
|
||||||
|
<el-tag :type="getApiCallStatusType(selectedApiCall.status)" size="small">
|
||||||
|
{{ getApiCallStatusText(selectedApiCall.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div><span class="text-gray-500">接口名称:</span>{{ selectedApiCall.product_name || '-' }}</div>
|
||||||
|
<div><span class="text-gray-500">客户端IP:</span>{{ selectedApiCall.client_ip || '-' }}</div>
|
||||||
|
<div><span class="text-gray-500">调用时间:</span>{{ formatDateTime(selectedApiCall.start_at) }}</div>
|
||||||
|
<div><span class="text-gray-500">完成时间:</span>{{ selectedApiCall.end_at ? formatDateTime(selectedApiCall.end_at) : '-' }}</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<span class="text-gray-500">错误信息:</span>
|
||||||
|
{{ selectedApiCall.translated_error_msg || selectedApiCall.error_msg || '-' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button class="dialog-btn dialog-btn-secondary" @click="apiCallDetailVisible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
</ListPageLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { financeApi, subordinateApi, subscriptionApi } from '@/api'
|
||||||
|
import FilterItem from '@/components/common/FilterItem.vue'
|
||||||
|
import FilterSection from '@/components/common/FilterSection.vue'
|
||||||
|
import ListPageLayout from '@/components/common/ListPageLayout.vue'
|
||||||
|
import { useAliyunCaptcha } from '@/composables/useAliyunCaptcha'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { Loading } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const { runWithCaptcha } = useAliyunCaptcha()
|
||||||
|
const list = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const listLoading = ref(false)
|
||||||
|
const invLoading = ref(false)
|
||||||
|
const filters = reactive({
|
||||||
|
remark: '',
|
||||||
|
phone: '',
|
||||||
|
company_name: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const allocVisible = ref(false)
|
||||||
|
const allocLoading = ref(false)
|
||||||
|
const allocCodeCountdown = ref(0)
|
||||||
|
const sendCodeLoading = ref(false)
|
||||||
|
let allocCodeTimer = null
|
||||||
|
const allocForm = ref({
|
||||||
|
childId: '',
|
||||||
|
childCompany: '',
|
||||||
|
productId: '',
|
||||||
|
callCount: 1,
|
||||||
|
verifyCode: '',
|
||||||
|
parentBalance: '0.00'
|
||||||
|
})
|
||||||
|
const allocProducts = ref([])
|
||||||
|
const childSubscriptions = ref([])
|
||||||
|
const childQuotaAccounts = ref([])
|
||||||
|
|
||||||
|
const allocRecordVisible = ref(false)
|
||||||
|
const allocationRecords = ref([])
|
||||||
|
const allocationTotal = ref(0)
|
||||||
|
const allocationPage = ref(1)
|
||||||
|
const allocationPageSize = ref(10)
|
||||||
|
|
||||||
|
const parentSubscriptions = ref([])
|
||||||
|
|
||||||
|
const apiCallsVisible = ref(false)
|
||||||
|
const apiCallsLoading = ref(false)
|
||||||
|
const apiCalls = ref([])
|
||||||
|
const apiCallsTotal = ref(0)
|
||||||
|
const apiCallsPage = ref(1)
|
||||||
|
const apiCallsPageSize = ref(10)
|
||||||
|
const apiCallsTarget = ref({ childId: '', company: '', phone: '' })
|
||||||
|
const apiCallsFilters = reactive({
|
||||||
|
transaction_id: '',
|
||||||
|
product_name: '',
|
||||||
|
status: ''
|
||||||
|
})
|
||||||
|
const apiCallDetailVisible = ref(false)
|
||||||
|
const selectedApiCall = ref(null)
|
||||||
|
|
||||||
|
const lastInvite = ref('')
|
||||||
|
const allocTotalAmount = computed(() => {
|
||||||
|
const selected = allocProducts.value.find((item) => item.product_id === allocForm.value.productId)
|
||||||
|
if (!selected) return '0.00'
|
||||||
|
const count = Number(allocForm.value.callCount || 0)
|
||||||
|
const unit = Number(selected.price || 0)
|
||||||
|
if (Number.isNaN(count) || Number.isNaN(unit) || count <= 0 || unit <= 0) return '0.00'
|
||||||
|
return (unit * count).toFixed(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
const subscribedProductRows = computed(() => {
|
||||||
|
return allocProducts.value.filter((item) => item.is_subscribed)
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDateTime = (value) => {
|
||||||
|
if (!value) return '-'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
return date.toLocaleString('zh-CN', { hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMoney = (value) => {
|
||||||
|
const n = Number(value)
|
||||||
|
if (Number.isNaN(n)) return '0.00'
|
||||||
|
return n.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadList = async () => {
|
||||||
|
listLoading.value = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize.value
|
||||||
|
}
|
||||||
|
if (filters.remark.trim()) params.remark = filters.remark.trim()
|
||||||
|
if (filters.phone.trim()) params.phone = filters.phone.trim()
|
||||||
|
if (filters.company_name.trim()) params.company_name = filters.company_name.trim()
|
||||||
|
|
||||||
|
const res = await subordinateApi.listSubordinates(params)
|
||||||
|
if (res?.success && res.data) {
|
||||||
|
list.value = (res.data.items || []).map((item) => ({
|
||||||
|
...item,
|
||||||
|
remark: item.remark || '',
|
||||||
|
_remarkOriginal: item.remark || '',
|
||||||
|
_remarkSaving: false
|
||||||
|
}))
|
||||||
|
total.value = res.data.total || 0
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e?.message || '加载失败')
|
||||||
|
} finally {
|
||||||
|
listLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFilterChange = () => {
|
||||||
|
page.value = 1
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
filters.remark = ''
|
||||||
|
filters.phone = ''
|
||||||
|
filters.company_name = ''
|
||||||
|
page.value = 1
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveRemark = async (row) => {
|
||||||
|
if (row._remarkSaving) return
|
||||||
|
const current = (row.remark || '').trim()
|
||||||
|
const original = (row._remarkOriginal || '').trim()
|
||||||
|
if (current === original) return
|
||||||
|
|
||||||
|
row._remarkSaving = true
|
||||||
|
try {
|
||||||
|
const res = await subordinateApi.updateRemark({
|
||||||
|
child_user_id: row.child_user_id,
|
||||||
|
remark: current
|
||||||
|
})
|
||||||
|
if (res?.success) {
|
||||||
|
row.remark = current
|
||||||
|
row._remarkOriginal = current
|
||||||
|
ElMessage.success('备注已保存')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
row.remark = row._remarkOriginal
|
||||||
|
ElMessage.error(e?.response?.data?.message || e?.message || '备注保存失败')
|
||||||
|
} finally {
|
||||||
|
row._remarkSaving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createInvite = async () => {
|
||||||
|
invLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await subordinateApi.createInvitation({})
|
||||||
|
if (res?.success && res.data) {
|
||||||
|
lastInvite.value = res.data.invite_url || res.data.invite_token
|
||||||
|
ElMessage.success('邀请链接已复制,可邀请多名下属重复使用')
|
||||||
|
await navigator.clipboard.writeText(res.data.invite_url || res.data.invite_token).catch(() => {})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || e?.message || '失败')
|
||||||
|
} finally {
|
||||||
|
invLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetAllocForm = () => {
|
||||||
|
allocForm.value = {
|
||||||
|
childId: '',
|
||||||
|
childCompany: '',
|
||||||
|
productId: '',
|
||||||
|
callCount: 1,
|
||||||
|
verifyCode: '',
|
||||||
|
parentBalance: '0.00'
|
||||||
|
}
|
||||||
|
allocProducts.value = []
|
||||||
|
childSubscriptions.value = []
|
||||||
|
childQuotaAccounts.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureCertified = (row, actionName) => {
|
||||||
|
if (row?.is_certified) return true
|
||||||
|
ElMessage.warning(`下属未完成企业认证,暂不可${actionName}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAlloc = async (row) => {
|
||||||
|
if (!ensureCertified(row, '购买额度')) return
|
||||||
|
resetAllocForm()
|
||||||
|
allocForm.value.childId = row.child_user_id
|
||||||
|
allocForm.value.childCompany = row.company_name
|
||||||
|
try {
|
||||||
|
const walletRes = await financeApi.getWallet().catch(() => null)
|
||||||
|
allocForm.value.parentBalance = walletRes?.data?.balance || '0.00'
|
||||||
|
const [parentSubResult, childSubResult, childQuotaResult] = await Promise.allSettled([
|
||||||
|
subscriptionApi.getMySubscriptions({ page: 1, page_size: 1000 }),
|
||||||
|
subordinateApi.listChildSubscriptions({ child_user_id: row.child_user_id }),
|
||||||
|
subordinateApi.listChildQuotas({ child_user_id: row.child_user_id, page: 1, page_size: 1000 })
|
||||||
|
])
|
||||||
|
parentSubscriptions.value = parentSubResult.status === 'fulfilled' ? (parentSubResult.value?.data?.items || []) : []
|
||||||
|
childSubscriptions.value = childSubResult.status === 'fulfilled' ? (childSubResult.value?.data || []) : []
|
||||||
|
childQuotaAccounts.value = childQuotaResult.status === 'fulfilled'
|
||||||
|
? (childQuotaResult.value?.data?.items || childQuotaResult.value?.data || [])
|
||||||
|
: []
|
||||||
|
|
||||||
|
const childSubProductSet = new Set(
|
||||||
|
childSubscriptions.value
|
||||||
|
.map((item) => item.product_id || item.product?.id)
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
const quotaMap = new Map(
|
||||||
|
childQuotaAccounts.value.map((item) => [
|
||||||
|
item.product_id,
|
||||||
|
{
|
||||||
|
available_quota: Number(item.available_quota || 0),
|
||||||
|
total_quota: Number(item.total_quota || 0)
|
||||||
|
}
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
allocProducts.value = parentSubscriptions.value
|
||||||
|
.map((item) => ({
|
||||||
|
product_id: item.product_id || item.product?.id,
|
||||||
|
product_name: item.product_name || item.product?.name || item.product?.title || item.product_id,
|
||||||
|
price: item.price,
|
||||||
|
is_subscribed: childSubProductSet.has(item.product_id || item.product?.id),
|
||||||
|
available_quota: quotaMap.get(item.product_id || item.product?.id)?.available_quota ?? 0,
|
||||||
|
total_quota: quotaMap.get(item.product_id || item.product?.id)?.total_quota ?? 0
|
||||||
|
}))
|
||||||
|
.filter((item) => item.product_id && Number(item.price) > 0)
|
||||||
|
|
||||||
|
if (!allocProducts.value.some((item) => item.product_id === allocForm.value.productId)) {
|
||||||
|
const subscribedFirst = allocProducts.value.find((item) => item.is_subscribed)
|
||||||
|
allocForm.value.productId = subscribedFirst?.product_id || allocProducts.value[0]?.product_id || ''
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
allocProducts.value = []
|
||||||
|
childSubscriptions.value = []
|
||||||
|
childQuotaAccounts.value = []
|
||||||
|
}
|
||||||
|
allocVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendAllocVerifyCode = async () => {
|
||||||
|
if (allocCodeCountdown.value > 0 || sendCodeLoading.value) return
|
||||||
|
const phone = userStore.userInfo?.phone
|
||||||
|
if (!phone) {
|
||||||
|
ElMessage.warning('未获取到当前登录手机号,请重新登录后重试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendCodeLoading.value = true
|
||||||
|
try {
|
||||||
|
await runWithCaptcha(
|
||||||
|
async (captchaVerifyParam) => {
|
||||||
|
return await userStore.sendCode(phone, 'login', captchaVerifyParam)
|
||||||
|
},
|
||||||
|
(result) => {
|
||||||
|
if (result.success) {
|
||||||
|
ElMessage.success('验证码已发送')
|
||||||
|
allocCodeCountdown.value = 60
|
||||||
|
allocCodeTimer = setInterval(() => {
|
||||||
|
allocCodeCountdown.value--
|
||||||
|
if (allocCodeCountdown.value <= 0 && allocCodeTimer) {
|
||||||
|
clearInterval(allocCodeTimer)
|
||||||
|
allocCodeTimer = null
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
} else {
|
||||||
|
ElMessage.error(result.error?.response?.data?.message || '发送验证码失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
sendCodeLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitAlloc = async () => {
|
||||||
|
if (!allocForm.value.productId) {
|
||||||
|
ElMessage.warning('请选择产品')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!allocForm.value.callCount || Number(allocForm.value.callCount) <= 0) {
|
||||||
|
ElMessage.warning('请输入有效购买次数')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!allocForm.value.verifyCode || allocForm.value.verifyCode.length !== 6) {
|
||||||
|
ElMessage.warning('请输入6位手机验证码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
allocLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await subordinateApi.purchaseQuota({
|
||||||
|
child_user_id: allocForm.value.childId,
|
||||||
|
product_id: allocForm.value.productId,
|
||||||
|
call_count: Number(allocForm.value.callCount),
|
||||||
|
verify_code: allocForm.value.verifyCode
|
||||||
|
})
|
||||||
|
if (res?.success) {
|
||||||
|
ElMessage.success('额度购买成功')
|
||||||
|
allocVisible.value = false
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || e?.message || '额度购买失败')
|
||||||
|
} finally {
|
||||||
|
allocLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAllocationRecords = async () => {
|
||||||
|
allocationPage.value = 1
|
||||||
|
await loadAllocationRecords()
|
||||||
|
allocRecordVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadAllocationRecords = async () => {
|
||||||
|
try {
|
||||||
|
const res = await subordinateApi.listQuotaPurchases({
|
||||||
|
child_user_id: allocForm.value.childId,
|
||||||
|
page: allocationPage.value,
|
||||||
|
page_size: allocationPageSize.value
|
||||||
|
})
|
||||||
|
allocationRecords.value = res?.data?.items || []
|
||||||
|
allocationTotal.value = res?.data?.total || 0
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || e?.message || '加载额度购买记录失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getApiCallStatusType = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'success':
|
||||||
|
return 'success'
|
||||||
|
case 'failed':
|
||||||
|
return 'danger'
|
||||||
|
case 'pending':
|
||||||
|
return 'warning'
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getApiCallStatusText = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'success':
|
||||||
|
return '成功'
|
||||||
|
case 'failed':
|
||||||
|
return '失败'
|
||||||
|
case 'pending':
|
||||||
|
return '处理中'
|
||||||
|
default:
|
||||||
|
return '未知'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetApiCallsDialog = () => {
|
||||||
|
apiCalls.value = []
|
||||||
|
apiCallsTotal.value = 0
|
||||||
|
apiCallsPage.value = 1
|
||||||
|
apiCallsPageSize.value = 10
|
||||||
|
apiCallsTarget.value = { childId: '', company: '', phone: '' }
|
||||||
|
apiCallsFilters.transaction_id = ''
|
||||||
|
apiCallsFilters.product_name = ''
|
||||||
|
apiCallsFilters.status = ''
|
||||||
|
selectedApiCall.value = null
|
||||||
|
apiCallDetailVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetApiCallsFilters = () => {
|
||||||
|
apiCallsFilters.transaction_id = ''
|
||||||
|
apiCallsFilters.product_name = ''
|
||||||
|
apiCallsFilters.status = ''
|
||||||
|
apiCallsPage.value = 1
|
||||||
|
loadChildApiCalls()
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchApiCalls = () => {
|
||||||
|
apiCallsPage.value = 1
|
||||||
|
loadChildApiCalls()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadChildApiCalls = async () => {
|
||||||
|
if (!apiCallsTarget.value.childId) return
|
||||||
|
apiCallsLoading.value = true
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
child_user_id: apiCallsTarget.value.childId,
|
||||||
|
page: apiCallsPage.value,
|
||||||
|
page_size: apiCallsPageSize.value
|
||||||
|
}
|
||||||
|
if (apiCallsFilters.transaction_id.trim()) params.transaction_id = apiCallsFilters.transaction_id.trim()
|
||||||
|
if (apiCallsFilters.product_name.trim()) params.product_name = apiCallsFilters.product_name.trim()
|
||||||
|
if (apiCallsFilters.status) params.status = apiCallsFilters.status
|
||||||
|
|
||||||
|
const res = await subordinateApi.listChildApiCalls(params)
|
||||||
|
if (res?.success && res.data) {
|
||||||
|
apiCalls.value = res.data.items || []
|
||||||
|
apiCallsTotal.value = res.data.total || 0
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || e?.message || '加载调用记录失败')
|
||||||
|
} finally {
|
||||||
|
apiCallsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openApiCalls = async (row) => {
|
||||||
|
apiCallsTarget.value = {
|
||||||
|
childId: row.child_user_id,
|
||||||
|
company: row.is_certified ? row.company_name : '',
|
||||||
|
phone: row.phone || ''
|
||||||
|
}
|
||||||
|
apiCallsPage.value = 1
|
||||||
|
apiCallsVisible.value = true
|
||||||
|
await loadChildApiCalls()
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewApiCallDetail = (row) => {
|
||||||
|
selectedApiCall.value = row
|
||||||
|
apiCallDetailVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSizeChange = (size) => {
|
||||||
|
pageSize.value = size
|
||||||
|
page.value = 1
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCurrentChange = (current) => {
|
||||||
|
page.value = current
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadList()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (allocCodeTimer) {
|
||||||
|
clearInterval(allocCodeTimer)
|
||||||
|
allocCodeTimer = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn {
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 92px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn-primary {
|
||||||
|
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn-secondary {
|
||||||
|
border-color: #d1d5db;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-action-btn {
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-allocate {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-calls {
|
||||||
|
color: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-subscribe {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-danger {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-disabled {
|
||||||
|
color: #9ca3af !important;
|
||||||
|
background-color: #f3f4f6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn {
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 84px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn-primary {
|
||||||
|
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-tag {
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: normal;
|
||||||
|
height: auto;
|
||||||
|
line-height: 1.4;
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
:deep(.el-table) {
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 980px;
|
||||||
|
}
|
||||||
|
|
||||||
|
: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 :deep(.el-pagination) {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
min-width: fit-content;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrapper :deep(.el-pagination__jump) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn {
|
||||||
|
min-width: 82px;
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div v-if="!isSubordinate" class="info-item">
|
||||||
<label class="info-label">价格</label>
|
<label class="info-label">价格</label>
|
||||||
<span class="info-value price">¥{{ formatPrice(product.price) }}</span>
|
<span class="info-value price">¥{{ formatPrice(product.price) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
<div v-if="product.is_package && product.package_items && product.package_items.length > 0" class="detail-section">
|
<div v-if="product.is_package && product.package_items && product.package_items.length > 0" class="detail-section">
|
||||||
<h3 class="section-title">组合包内容</h3>
|
<h3 class="section-title">组合包内容</h3>
|
||||||
<div class="package-items-container">
|
<div class="package-items-container">
|
||||||
<div class="package-summary">
|
<div v-if="!isSubordinate" class="package-summary">
|
||||||
<el-alert
|
<el-alert
|
||||||
title="此组合包包含以下产品"
|
title="此组合包包含以下产品"
|
||||||
type="info"
|
type="info"
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="package-item-price">
|
<div v-if="!isSubordinate" class="package-item-price">
|
||||||
<span class="price-label">价值:</span>
|
<span class="price-label">价值:</span>
|
||||||
<span class="price-value">¥{{ formatPrice(item.price) }}</span>
|
<span class="price-value">¥{{ formatPrice(item.price) }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,7 +335,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="price-info" v-if="currentUIComponentPrice && !canDownloadReport">
|
<div v-if="!isSubordinate && currentUIComponentPrice && !canDownloadReport" class="price-info">
|
||||||
<h4>价格信息</h4>
|
<h4>价格信息</h4>
|
||||||
<div class="price-summary">
|
<div class="price-summary">
|
||||||
<div class="price-row">
|
<div class="price-row">
|
||||||
@@ -385,6 +385,8 @@ import { h } from 'vue'
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const isSubordinate = computed(() => userStore.accountKind === 'subordinate')
|
||||||
|
|
||||||
// 移动端检测
|
// 移动端检测
|
||||||
const { isMobile } = useMobileTable()
|
const { isMobile } = useMobileTable()
|
||||||
@@ -1269,6 +1271,65 @@ const createReportPaymentOrder = async (paymentType) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WECHAT_REPORT_POLL_INTERVAL_MS = 5000
|
||||||
|
const WECHAT_REPORT_MAX_POLL_COUNT = 36
|
||||||
|
|
||||||
|
const stopWechatPaymentPolling = () => {
|
||||||
|
if (wechatOrderPollTimer) {
|
||||||
|
clearInterval(wechatOrderPollTimer)
|
||||||
|
wechatOrderPollTimer = null
|
||||||
|
}
|
||||||
|
isCheckingWechatPayment.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const startWechatPaymentPolling = (orderId) => {
|
||||||
|
if (!orderId) return
|
||||||
|
|
||||||
|
stopWechatPaymentPolling()
|
||||||
|
|
||||||
|
let pollCount = 0
|
||||||
|
|
||||||
|
const checkOnce = async () => {
|
||||||
|
pollCount++
|
||||||
|
if (pollCount > WECHAT_REPORT_MAX_POLL_COUNT) {
|
||||||
|
stopWechatPaymentPolling()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isCheckingWechatPayment.value = true
|
||||||
|
const response = await productApi.checkComponentReportPaymentStatus(orderId)
|
||||||
|
const responseData = response.data?.data || response.data
|
||||||
|
const { payment_status, can_download } = responseData || {}
|
||||||
|
|
||||||
|
if (payment_status === 'success' && can_download) {
|
||||||
|
stopWechatPaymentPolling()
|
||||||
|
ElMessageBox.close()
|
||||||
|
canDownloadReport.value = true
|
||||||
|
reportPaymentStatus.value = 'success'
|
||||||
|
ElMessage.success('支付成功,正在下载示例报告...')
|
||||||
|
await downloadComponentReport()
|
||||||
|
} else if (payment_status === 'failed') {
|
||||||
|
stopWechatPaymentPolling()
|
||||||
|
ElMessageBox.close()
|
||||||
|
ElMessage.error('支付失败,请重试')
|
||||||
|
} else if (payment_status === 'cancelled') {
|
||||||
|
stopWechatPaymentPolling()
|
||||||
|
ElMessageBox.close()
|
||||||
|
ElMessage.info('支付已取消')
|
||||||
|
} else {
|
||||||
|
isCheckingWechatPayment.value = false
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[startWechatPaymentPolling] 检查支付状态失败:', error)
|
||||||
|
isCheckingWechatPayment.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkOnce()
|
||||||
|
wechatOrderPollTimer = setInterval(checkOnce, WECHAT_REPORT_POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
// 显示微信支付二维码
|
// 显示微信支付二维码
|
||||||
const showWechatPaymentQRCode = (codeUrl, orderId) => {
|
const showWechatPaymentQRCode = (codeUrl, orderId) => {
|
||||||
// 验证二维码URL是否有效
|
// 验证二维码URL是否有效
|
||||||
@@ -1322,7 +1383,7 @@ const showWechatPaymentQRCode = (codeUrl, orderId) => {
|
|||||||
}, '点击打开微信支付')
|
}, '点击打开微信支付')
|
||||||
]),
|
]),
|
||||||
h('div', { style: 'font-size: 14px; color: #666;' }, [
|
h('div', { style: 'font-size: 14px; color: #666;' }, [
|
||||||
h('p', `支付金额:¥${formatPrice(currentUIComponentPrice)}`),
|
isSubordinate.value ? null : h('p', `支付金额:¥${formatPrice(currentUIComponentPrice)}`),
|
||||||
h('p', '支付完成后,请点击"我已支付"按钮')
|
h('p', '支付完成后,请点击"我已支付"按钮')
|
||||||
])
|
])
|
||||||
])
|
])
|
||||||
@@ -1330,7 +1391,7 @@ const showWechatPaymentQRCode = (codeUrl, orderId) => {
|
|||||||
})
|
})
|
||||||
]),
|
]),
|
||||||
h('div', { style: 'font-size: 14px; color: #666;' }, [
|
h('div', { style: 'font-size: 14px; color: #666;' }, [
|
||||||
h('p', `支付金额:¥${formatPrice(currentUIComponentPrice.value || 0)}`),
|
isSubordinate.value ? null : h('p', `支付金额:¥${formatPrice(currentUIComponentPrice.value || 0)}`),
|
||||||
h('p', '支付完成后,系统将自动检测支付状态')
|
h('p', '支付完成后,系统将自动检测支付状态')
|
||||||
])
|
])
|
||||||
]),
|
]),
|
||||||
@@ -1341,6 +1402,7 @@ const showWechatPaymentQRCode = (codeUrl, orderId) => {
|
|||||||
closeOnClickModal: false,
|
closeOnClickModal: false,
|
||||||
closeOnPressEscape: false,
|
closeOnPressEscape: false,
|
||||||
beforeClose: (action, instance, done) => {
|
beforeClose: (action, instance, done) => {
|
||||||
|
stopWechatPaymentPolling()
|
||||||
if (action === 'confirm') {
|
if (action === 'confirm') {
|
||||||
// 用户点击了"我已支付"按钮,开始检查支付状态
|
// 用户点击了"我已支付"按钮,开始检查支付状态
|
||||||
startPaymentStatusCheck(orderId)
|
startPaymentStatusCheck(orderId)
|
||||||
@@ -1351,7 +1413,7 @@ const showWechatPaymentQRCode = (codeUrl, orderId) => {
|
|||||||
done()
|
done()
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// 对话框被关闭(通过取消按钮或其他方式)
|
stopWechatPaymentPolling()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 开始轮询微信支付状态
|
// 开始轮询微信支付状态
|
||||||
|
|||||||
@@ -30,6 +30,10 @@
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="!isSubordinateUser" class="overview-balance">
|
||||||
|
<div class="balance-label">当前账户余额</div>
|
||||||
|
<div class="balance-value">¥{{ walletBalance }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -80,10 +84,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 分隔线(仅认证用户显示) -->
|
<!-- 分隔线(仅认证用户显示) -->
|
||||||
<el-divider v-if="userInfo.is_certified" />
|
<el-divider v-if="userInfo.is_certified && !isSubordinateUser" />
|
||||||
|
|
||||||
<!-- 下半部分:余额预警配置(仅认证用户显示) -->
|
<!-- 下半部分:余额预警配置(仅认证用户显示) -->
|
||||||
<div v-if="userInfo.is_certified" class="balance-alert-section">
|
<div v-if="userInfo.is_certified && !isSubordinateUser" class="balance-alert-section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<el-icon class="mr-2 text-orange-600">
|
<el-icon class="mr-2 text-orange-600">
|
||||||
@@ -432,7 +436,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { apiKeysApi, balanceAlertApi } from '@/api'
|
import { apiKeysApi, balanceAlertApi, financeApi } from '@/api'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import {
|
import {
|
||||||
BellIcon,
|
BellIcon,
|
||||||
@@ -445,12 +449,14 @@ import { ElMessage } from 'element-plus'
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const isSubordinateUser = computed(() => userStore.accountKind === 'subordinate')
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const userInfo = ref(null)
|
const userInfo = ref(null)
|
||||||
const apiKeysData = ref(null)
|
const apiKeysData = ref(null)
|
||||||
const showApiKeys = ref(false)
|
const showApiKeys = ref(false)
|
||||||
const apiKeysLoading = ref(false)
|
const apiKeysLoading = ref(false)
|
||||||
|
const walletBalance = ref('0.00')
|
||||||
|
|
||||||
// 余额预警设置相关
|
// 余额预警设置相关
|
||||||
const balanceAlertSettings = ref({
|
const balanceAlertSettings = ref({
|
||||||
@@ -468,8 +474,13 @@ const loadUserInfo = async () => {
|
|||||||
const result = await userStore.fetchUserProfile()
|
const result = await userStore.fetchUserProfile()
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
userInfo.value = result.data
|
userInfo.value = result.data
|
||||||
|
if (!isSubordinateUser.value) {
|
||||||
|
await loadWalletBalance()
|
||||||
|
} else {
|
||||||
|
walletBalance.value = '0.00'
|
||||||
|
}
|
||||||
// 只有认证用户才加载余额预警设置
|
// 只有认证用户才加载余额预警设置
|
||||||
if (result.data.is_certified) {
|
if (result.data.is_certified && !isSubordinateUser.value) {
|
||||||
await loadAlertSettings()
|
await loadAlertSettings()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -483,6 +494,21 @@ const loadUserInfo = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载当前账户余额
|
||||||
|
const loadWalletBalance = async () => {
|
||||||
|
try {
|
||||||
|
const response = await financeApi.getWallet()
|
||||||
|
if (response?.success && response?.data?.balance !== undefined) {
|
||||||
|
const balanceNum = Number(response.data.balance)
|
||||||
|
walletBalance.value = Number.isFinite(balanceNum) ? balanceNum.toFixed(2) : '0.00'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
walletBalance.value = '0.00'
|
||||||
|
} catch {
|
||||||
|
walletBalance.value = '0.00'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 加载API密钥
|
// 加载API密钥
|
||||||
const loadApiKeys = async () => {
|
const loadApiKeys = async () => {
|
||||||
if (showApiKeys.value) return
|
if (showApiKeys.value) return
|
||||||
@@ -725,6 +751,27 @@ onMounted(() => {
|
|||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overview-balance {
|
||||||
|
margin-left: auto;
|
||||||
|
text-align: right;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-label {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.user-avatar {
|
.user-avatar {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
@@ -1093,6 +1140,12 @@ onMounted(() => {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overview-balance {
|
||||||
|
margin-left: 0;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.user-avatar {
|
.user-avatar {
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
|
|||||||
133
src/pages/sub-portal/SubLogin.vue
Normal file
133
src/pages/sub-portal/SubLogin.vue
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full auth-fade-in">
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<h2 class="auth-title">欢迎登录</h2>
|
||||||
|
<p class="auth-subtitle">请输入账号信息继续访问控制台</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<el-radio-group v-model="loginMethod" class="w-full flex auth-radio auth-method-tabs">
|
||||||
|
<el-radio-button value="sms" class="auth-method-tab !border-0 !rounded-md">
|
||||||
|
<div class="flex items-center justify-center gap-2">验证码登录</div>
|
||||||
|
</el-radio-button>
|
||||||
|
<el-radio-button value="password" class="auth-method-tab !border-0 !rounded-md">
|
||||||
|
<div class="flex items-center justify-center gap-2">密码登录</div>
|
||||||
|
</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="space-y-4" @submit.prevent="onLogin">
|
||||||
|
<div>
|
||||||
|
<label class="auth-label">手机号</label>
|
||||||
|
<el-input v-model="form.phone" placeholder="请输入手机号" size="large" clearable maxlength="11"
|
||||||
|
:disabled="loading" class="auth-input" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loginMethod === 'sms'">
|
||||||
|
<label class="auth-label">验证码</label>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<el-input v-model="form.code" placeholder="验证码" size="large" maxlength="6" :disabled="loading"
|
||||||
|
class="auth-input" />
|
||||||
|
<el-button type="primary" size="large" :disabled="!canSendCode || loading" :loading="sendingCode"
|
||||||
|
class="!min-w-[120px]" @click="sendCode">
|
||||||
|
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loginMethod === 'password'">
|
||||||
|
<label class="auth-label">密码</label>
|
||||||
|
<el-input v-model="form.password" type="password" show-password size="large" :disabled="loading"
|
||||||
|
class="auth-input" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-slate-400">还没有账号?</span>
|
||||||
|
<router-link to="/sub/auth/reset" class="auth-link" replace>忘记密码?</router-link>
|
||||||
|
<router-link to="/sub/auth/register" class="auth-link" replace>注册账号</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-button type="primary" size="large" class="auth-button w-full !h-12" native-type="submit" :loading="loading"
|
||||||
|
:disabled="!canSubmit">
|
||||||
|
登录
|
||||||
|
</el-button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup name="SubLogin">
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { useAliyunCaptcha } from '@/composables/useAliyunCaptcha'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const { runWithCaptcha } = useAliyunCaptcha()
|
||||||
|
const loginMethod = ref('sms')
|
||||||
|
const form = ref({ phone: '', password: '', code: '' })
|
||||||
|
const loading = ref(false)
|
||||||
|
const sendingCode = ref(false)
|
||||||
|
const countdown = ref(0)
|
||||||
|
let countdownTimer = null
|
||||||
|
|
||||||
|
const canSendCode = computed(
|
||||||
|
() => form.value.phone?.length === 11 && countdown.value === 0
|
||||||
|
)
|
||||||
|
const canSubmit = computed(() => {
|
||||||
|
if (loginMethod.value === 'sms') {
|
||||||
|
return form.value.phone?.length === 11 && form.value.code?.length === 6
|
||||||
|
}
|
||||||
|
return form.value.phone?.length === 11 && !!form.value.password
|
||||||
|
})
|
||||||
|
|
||||||
|
const sendCode = async () => {
|
||||||
|
if (!canSendCode.value) return
|
||||||
|
sendingCode.value = true
|
||||||
|
try {
|
||||||
|
await runWithCaptcha(
|
||||||
|
async (captchaVerifyParam) => userStore.sendCode(form.value.phone, 'login', captchaVerifyParam),
|
||||||
|
(res) => {
|
||||||
|
if (res.success) {
|
||||||
|
ElMessage.success('验证码发送成功')
|
||||||
|
countdown.value = 60
|
||||||
|
countdownTimer = setInterval(() => {
|
||||||
|
countdown.value--
|
||||||
|
if (countdown.value <= 0) clearInterval(countdownTimer)
|
||||||
|
}, 1000)
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res?.error?.message || '发送失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
sendingCode.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLogin = async () => {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await userStore.login({
|
||||||
|
method: loginMethod.value,
|
||||||
|
phone: form.value.phone,
|
||||||
|
code: form.value.code,
|
||||||
|
password: form.value.password
|
||||||
|
})
|
||||||
|
if (result.success) {
|
||||||
|
ElMessage.success('登录成功')
|
||||||
|
await userStore.fetchUserProfile()
|
||||||
|
router.push('/subscriptions')
|
||||||
|
} else {
|
||||||
|
ElMessage.error(result.error?.response?.data?.message || '登录失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (countdownTimer) clearInterval(countdownTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
148
src/pages/sub-portal/SubRegister.vue
Normal file
148
src/pages/sub-portal/SubRegister.vue
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full auth-fade-in">
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<h2 class="auth-title">注册账号</h2>
|
||||||
|
<p class="auth-subtitle">填写主账号邀请码与手机号,完成账号注册</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="space-y-4" @submit.prevent="onRegister">
|
||||||
|
<div>
|
||||||
|
<label class="auth-label">邀请码 <span class="text-red-500">*</span></label>
|
||||||
|
<el-input v-model="form.inviteToken" placeholder="请输入邀请码" size="large" clearable :disabled="loading"
|
||||||
|
class="auth-input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="auth-label">手机号</label>
|
||||||
|
<el-input v-model="form.phone" maxlength="11" placeholder="手机号" size="large" :disabled="loading" class="auth-input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="auth-label">验证码</label>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<el-input v-model="form.code" maxlength="6" placeholder="短信验证码" size="large" :disabled="loading" class="auth-input" />
|
||||||
|
<el-button type="primary" size="large" :disabled="!canSendCode || loading" :loading="sendingCode"
|
||||||
|
class="auth-button !min-w-[120px]" @click="sendCode">
|
||||||
|
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="auth-label">密码</label>
|
||||||
|
<el-input v-model="form.password" type="password" show-password size="large" :disabled="loading" class="auth-input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="auth-label">确认密码</label>
|
||||||
|
<el-input v-model="form.confirmPassword" type="password" show-password size="large" :disabled="loading"
|
||||||
|
class="auth-input" />
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<router-link to="/sub/auth/login" class="auth-link text-sm">返回登录</router-link>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" size="large" class="auth-button w-full !h-12" native-type="submit" :loading="loading"
|
||||||
|
:disabled="!canSubmit">
|
||||||
|
注册
|
||||||
|
</el-button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-4 text-xs text-center text-slate-400">
|
||||||
|
请确认邀请码来源可信,避免账号安全风险
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { subPortalApi } from '@/api'
|
||||||
|
import { useAliyunCaptcha } from '@/composables/useAliyunCaptcha'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const { runWithCaptcha } = useAliyunCaptcha()
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
inviteToken: '',
|
||||||
|
phone: '',
|
||||||
|
code: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
})
|
||||||
|
const loading = ref(false)
|
||||||
|
const sendingCode = ref(false)
|
||||||
|
const countdown = ref(0)
|
||||||
|
let timer = null
|
||||||
|
|
||||||
|
// 与主站同仓同构建:链接 ?invite= 或主账号复制的完整 URL 均会打开本页并预填邀请码
|
||||||
|
watch(
|
||||||
|
() => route.query.invite,
|
||||||
|
(inv) => {
|
||||||
|
if (typeof inv === 'string' && inv) {
|
||||||
|
form.value.inviteToken = inv
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const canSendCode = computed(
|
||||||
|
() => form.value.phone?.length === 11 && countdown.value === 0
|
||||||
|
)
|
||||||
|
const canSubmit = computed(
|
||||||
|
() =>
|
||||||
|
form.value.inviteToken &&
|
||||||
|
form.value.phone?.length === 11 &&
|
||||||
|
form.value.code?.length === 6 &&
|
||||||
|
form.value.password?.length >= 6 &&
|
||||||
|
form.value.password === form.value.confirmPassword
|
||||||
|
)
|
||||||
|
|
||||||
|
const sendCode = async () => {
|
||||||
|
if (!canSendCode.value) return
|
||||||
|
sendingCode.value = true
|
||||||
|
try {
|
||||||
|
await runWithCaptcha(
|
||||||
|
async (captcha) => userStore.sendCode(form.value.phone, 'register', captcha),
|
||||||
|
(res) => {
|
||||||
|
if (res.success) {
|
||||||
|
ElMessage.success('验证码已发送')
|
||||||
|
countdown.value = 60
|
||||||
|
timer = setInterval(() => {
|
||||||
|
countdown.value--
|
||||||
|
if (countdown.value <= 0) clearInterval(timer)
|
||||||
|
}, 1000)
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res?.error?.message || '发送失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
sendingCode.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRegister = async () => {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await subPortalApi.register({
|
||||||
|
phone: form.value.phone,
|
||||||
|
password: form.value.password,
|
||||||
|
confirm_password: form.value.confirmPassword,
|
||||||
|
code: form.value.code,
|
||||||
|
invite_token: form.value.inviteToken
|
||||||
|
})
|
||||||
|
if (res?.success) {
|
||||||
|
ElMessage.success('注册成功,请登录')
|
||||||
|
router.push('/sub/auth/login')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || e?.message || '注册失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
|
|
||||||
<div v-else-if="subscriptions.length === 0" class="text-center py-12">
|
<div v-else-if="subscriptions.length === 0" class="text-center py-12">
|
||||||
<el-empty description="暂无订阅数据">
|
<el-empty description="暂无订阅数据">
|
||||||
<el-button type="primary" @click="$router.push('/products')">
|
<el-button v-if="!isSubordinate" type="primary" @click="$router.push('/products')">
|
||||||
去数据大厅订阅产品
|
去数据大厅订阅产品
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-empty>
|
</el-empty>
|
||||||
@@ -122,6 +122,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column
|
<el-table-column
|
||||||
|
v-if="!isSubordinate"
|
||||||
prop="price"
|
prop="price"
|
||||||
label="订阅价格"
|
label="订阅价格"
|
||||||
:width="isMobile ? 100 : 120"
|
:width="isMobile ? 100 : 120"
|
||||||
@@ -131,6 +132,26 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column
|
||||||
|
v-if="isSubordinate"
|
||||||
|
label="官方价格"
|
||||||
|
:width="isMobile ? 100 : 120"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="font-semibold text-gray-700">¥{{ formatPrice(row.product?.price) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column
|
||||||
|
v-if="isSubordinate"
|
||||||
|
label="剩余额度"
|
||||||
|
:width="isMobile ? 110 : 130"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="font-semibold text-blue-600">{{ getRemainingQuota(row) }} 次</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
<!-- <el-table-column prop="api_used" label="API调用次数" width="140">
|
<!-- <el-table-column prop="api_used" label="API调用次数" width="140">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="font-medium">{{ row.api_used || 0 }}</span>
|
<span class="font-medium">{{ row.api_used || 0 }}</span>
|
||||||
@@ -258,7 +279,7 @@
|
|||||||
<div class="usage-stat-value">{{ usageData?.api_used || 0 }}</div>
|
<div class="usage-stat-value">{{ usageData?.api_used || 0 }}</div>
|
||||||
<div class="usage-stat-label">API调用次数</div>
|
<div class="usage-stat-label">API调用次数</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="usage-stat-card">
|
<div v-if="!isSubordinate" class="usage-stat-card">
|
||||||
<div class="usage-stat-value">¥{{ formatPrice(selectedSubscription.price) }}</div>
|
<div class="usage-stat-value">¥{{ formatPrice(selectedSubscription.price) }}</div>
|
||||||
<div class="usage-stat-label">订阅价格</div>
|
<div class="usage-stat-label">订阅价格</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,16 +302,19 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { subscriptionApi } from '@/api'
|
import { subordinateApi, subscriptionApi } 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 } from '@element-plus/icons-vue'
|
import { ArrowDown } from '@element-plus/icons-vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isMobile } = useMobileTable()
|
const { isMobile } = useMobileTable()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const isSubordinate = computed(() => userStore.accountKind === 'subordinate')
|
||||||
|
|
||||||
// 响应式数据
|
// 响应式数据
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -302,6 +326,7 @@ const usageDialogVisible = ref(false)
|
|||||||
const selectedSubscription = ref(null)
|
const selectedSubscription = ref(null)
|
||||||
const usageData = ref(null)
|
const usageData = ref(null)
|
||||||
const loadingUsage = ref(false)
|
const loadingUsage = ref(false)
|
||||||
|
const myQuotaMap = ref({})
|
||||||
|
|
||||||
// 统计数据
|
// 统计数据
|
||||||
const stats = ref({
|
const stats = ref({
|
||||||
@@ -344,6 +369,9 @@ const loadSubscriptions = async () => {
|
|||||||
const response = await subscriptionApi.getMySubscriptions(params)
|
const response = await subscriptionApi.getMySubscriptions(params)
|
||||||
subscriptions.value = response.data?.items || []
|
subscriptions.value = response.data?.items || []
|
||||||
total.value = response.data?.total || 0
|
total.value = response.data?.total || 0
|
||||||
|
if (isSubordinate.value) {
|
||||||
|
await loadMyQuotas()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载订阅失败:', error)
|
console.error('加载订阅失败:', error)
|
||||||
ElMessage.error('加载订阅失败')
|
ElMessage.error('加载订阅失败')
|
||||||
@@ -352,6 +380,30 @@ const loadSubscriptions = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载我的额度账户(子账号按次数扣减时展示剩余额度)
|
||||||
|
const loadMyQuotas = async () => {
|
||||||
|
try {
|
||||||
|
const res = await subordinateApi.listMyQuotas()
|
||||||
|
const items = res?.data || []
|
||||||
|
const map = {}
|
||||||
|
items.forEach((item) => {
|
||||||
|
map[item.product_id] = item.available_quota
|
||||||
|
})
|
||||||
|
myQuotaMap.value = map
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载我的额度账户失败:', error)
|
||||||
|
myQuotaMap.value = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRemainingQuota = (row) => {
|
||||||
|
const productID = row?.product_id || row?.product?.id
|
||||||
|
if (!productID) return '-'
|
||||||
|
const value = myQuotaMap.value[productID]
|
||||||
|
if (value === undefined || value === null) return '0'
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
// 加载统计数据
|
// 加载统计数据
|
||||||
const loadStats = async () => {
|
const loadStats = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isCurrentOrigin, isPortalDomainConfigReady, isSubPortal, mainPortalOrigin, subPortalOrigin } from '@/constants/portal'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import { statisticsRoutes } from './modules/statistics'
|
import { statisticsRoutes } from './modules/statistics'
|
||||||
@@ -6,7 +7,7 @@ import { statisticsRoutes } from './modules/statistics'
|
|||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
redirect: '/dashboard'
|
redirect: () => (isSubPortal ? '/sub/auth/login' : '/dashboard')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/auth',
|
path: '/auth',
|
||||||
@@ -51,6 +52,44 @@ const routes = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/sub/auth',
|
||||||
|
component: () => import('@/layouts/AuthLayout.vue'),
|
||||||
|
meta: { requiresAuth: false },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'login',
|
||||||
|
name: 'SubLogin',
|
||||||
|
component: () => import('@/pages/sub-portal/SubLogin.vue'),
|
||||||
|
meta: { title: '登录' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'register',
|
||||||
|
name: 'SubRegister',
|
||||||
|
component: () => import('@/pages/sub-portal/SubRegister.vue'),
|
||||||
|
meta: { title: '注册' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'reset',
|
||||||
|
name: 'SubResetPassword',
|
||||||
|
component: () => import('@/pages/auth/ResetPassword.vue'),
|
||||||
|
meta: { title: '重置密码' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/parent/subordinates',
|
||||||
|
component: () => import('@/layouts/MainLayout.vue'),
|
||||||
|
meta: { requiresAuth: true, title: '下属' },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'ParentSubordinates',
|
||||||
|
component: () => import('@/pages/parent/SubordinateManagement.vue'),
|
||||||
|
meta: { title: '下属' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/products',
|
path: '/products',
|
||||||
component: () => import('@/layouts/MainLayout.vue'),
|
component: () => import('@/layouts/MainLayout.vue'),
|
||||||
@@ -304,6 +343,12 @@ const routes = [
|
|||||||
name: 'AdminPurchaseRecords',
|
name: 'AdminPurchaseRecords',
|
||||||
component: () => import('@/pages/admin/purchase-records/index.vue'),
|
component: () => import('@/pages/admin/purchase-records/index.vue'),
|
||||||
meta: { title: '购买记录管理' }
|
meta: { title: '购买记录管理' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'certification-reviews',
|
||||||
|
name: 'AdminCertificationReviews',
|
||||||
|
component: () => import('@/pages/admin/certification-reviews/index.vue'),
|
||||||
|
meta: { title: '企业审核' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -338,9 +383,34 @@ const router = createRouter({
|
|||||||
// 路由守卫
|
// 路由守卫
|
||||||
router.beforeEach(async (to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const loginPathByRoute = to.path.startsWith('/sub/') ? '/sub/auth/login' : '/auth/login'
|
||||||
|
const isSubAuthRoute = to.path.startsWith('/sub/auth')
|
||||||
|
const isMainAuthRoute = to.path.startsWith('/auth')
|
||||||
|
const subAuthToMainAuthPath = to.path.replace('/sub/auth', '/auth')
|
||||||
|
const mainAuthToSubAuthPath = to.path.replace('/auth', '/sub/auth')
|
||||||
|
|
||||||
|
// 域名级认证路由隔离:子域只允许 /sub/auth/*,主域禁止 /sub/auth/*
|
||||||
|
if (isPortalDomainConfigReady) {
|
||||||
|
const onSubDomain = isCurrentOrigin(subPortalOrigin)
|
||||||
|
const onMainDomain = isCurrentOrigin(mainPortalOrigin)
|
||||||
|
|
||||||
|
if (onSubDomain && isMainAuthRoute) {
|
||||||
|
next(mainAuthToSubAuthPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onMainDomain && isSubAuthRoute) {
|
||||||
|
next(subAuthToMainAuthPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if (isSubPortal && isMainAuthRoute) {
|
||||||
|
// 子站壳模式下,即使未配置双域名,也只允许进入 /sub/auth/*
|
||||||
|
next(mainAuthToSubAuthPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 对于不需要认证的路由(如登录页),不等待初始化,直接放行
|
// 对于不需要认证的路由(如登录页),不等待初始化,直接放行
|
||||||
const isAuthRoute = to.path.startsWith('/auth')
|
const isAuthRoute = to.path.startsWith('/auth') || to.path.startsWith('/sub/auth')
|
||||||
const requiresAuth = to.meta.requiresAuth
|
const requiresAuth = to.meta.requiresAuth
|
||||||
|
|
||||||
// 只有在需要认证的路由上才等待初始化
|
// 只有在需要认证的路由上才等待初始化
|
||||||
@@ -360,7 +430,7 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
|
|
||||||
// 检查是否需要认证
|
// 检查是否需要认证
|
||||||
if (requiresAuth && !userStore.isLoggedIn) {
|
if (requiresAuth && !userStore.isLoggedIn) {
|
||||||
next('/auth/login')
|
next(loginPathByRoute)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,12 +440,54 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 已登录用户访问认证页面,重定向到数据大厅
|
// 登录态下若未配置主/子域名,降级为单域运行(不强制登出)
|
||||||
if (isAuthRoute && userStore.isLoggedIn) {
|
// 否则会出现“登录成功后立即被清会话”的问题。
|
||||||
|
if (userStore.isLoggedIn && !isPortalDomainConfigReady && import.meta.env.PROD) {
|
||||||
|
console.warn('[router] 未配置主/子域名,当前以单域模式运行,跳过域名隔离跳转')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 域名隔离:子账号登录态必须在子账号专属域名
|
||||||
|
if (userStore.isLoggedIn && userStore.accountKind === 'subordinate' && subPortalOrigin && !isCurrentOrigin(subPortalOrigin)) {
|
||||||
|
window.location.replace(`${subPortalOrigin}${to.fullPath}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 域名隔离:普通/管理员账号不应停留在子账号专属域名
|
||||||
|
if (userStore.isLoggedIn && userStore.accountKind !== 'subordinate' && subPortalOrigin && isCurrentOrigin(subPortalOrigin)) {
|
||||||
|
if (mainPortalOrigin) {
|
||||||
|
window.location.replace(`${mainPortalOrigin}${to.fullPath}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
next('/products')
|
next('/products')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 已登录用户访问认证页面,按账号类型重定向到对应首页
|
||||||
|
if (isAuthRoute && userStore.isLoggedIn) {
|
||||||
|
next(userStore.accountKind === 'subordinate' ? '/subscriptions' : '/products')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 子站壳:禁止进入主站登录/注册路由(可合并子账号入口到 /sub/auth)
|
||||||
|
if (isSubPortal && (to.path.startsWith('/auth/login') || to.path.startsWith('/auth/register'))) {
|
||||||
|
next(to.path.replace('/auth/', '/sub/auth/'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主站与「子站壳」共仓:/sub/auth/* 始终可用,子账号注册/登录与主站 /auth 并存,邀请链接不依赖单独构建
|
||||||
|
|
||||||
|
// 下属账号不可进入主账号「下属」管理页
|
||||||
|
if (to.path.startsWith('/parent/subordinates') && userStore.accountKind === 'subordinate') {
|
||||||
|
next('/subscriptions')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下属账号不允许访问仪表盘
|
||||||
|
if (to.path.startsWith('/dashboard') && userStore.accountKind === 'subordinate') {
|
||||||
|
next('/subscriptions')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -92,8 +92,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { userApi } from '@/api'
|
import { userApi } from '@/api'
|
||||||
|
import { isSubPortal } from '@/constants/portal'
|
||||||
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'
|
||||||
|
|
||||||
@@ -132,6 +134,10 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
return user.value?.is_certified || false
|
return user.value?.is_certified || false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const accountKind = computed(() => user.value?.account_kind || 'standalone')
|
||||||
|
|
||||||
|
const isSubordinate = computed(() => accountKind.value === 'subordinate')
|
||||||
|
|
||||||
// 检查用户信息是否完整
|
// 检查用户信息是否完整
|
||||||
const isUserInfoComplete = computed(() => {
|
const isUserInfoComplete = computed(() => {
|
||||||
return user.value &&
|
return user.value &&
|
||||||
@@ -161,6 +167,8 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
return userType.value === role
|
return userType.value === role
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loginHomePath = () => (isSubPortal ? '/sub/auth/login' : '/auth/login')
|
||||||
|
|
||||||
// 监听认证错误事件
|
// 监听认证错误事件
|
||||||
const handleAuthError = (message) => {
|
const handleAuthError = (message) => {
|
||||||
console.log('用户store收到认证错误事件:', message)
|
console.log('用户store收到认证错误事件:', message)
|
||||||
@@ -172,7 +180,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
console.log('收到版本退出登录事件:', event.detail)
|
console.log('收到版本退出登录事件:', event.detail)
|
||||||
logout()
|
logout()
|
||||||
ElMessage.error('系统已更新,请重新登录')
|
ElMessage.error('系统已更新,请重新登录')
|
||||||
router.push('/auth/login')
|
router.push(loginHomePath())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理版本刷新事件
|
// 处理版本刷新事件
|
||||||
@@ -204,7 +212,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
ElMessage.error('系统已更新,请重新登录')
|
ElMessage.error('系统已更新,请重新登录')
|
||||||
|
|
||||||
// 跳转到登录页面
|
// 跳转到登录页面
|
||||||
router.push('/auth/login')
|
router.push(loginHomePath())
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,17 +336,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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -513,6 +528,8 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
isCertified, // 新增
|
isCertified, // 新增
|
||||||
isUserInfoComplete, // 新增
|
isUserInfoComplete, // 新增
|
||||||
hasRole,
|
hasRole,
|
||||||
|
accountKind,
|
||||||
|
isSubordinate,
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
login,
|
login,
|
||||||
|
|||||||
@@ -253,9 +253,12 @@ const handleAuthError = (message) => {
|
|||||||
// 显示错误消息
|
// 显示错误消息
|
||||||
ElMessage.error(message)
|
ElMessage.error(message)
|
||||||
|
|
||||||
|
const currentPath = router.currentRoute.value.path || ''
|
||||||
|
const loginPath = currentPath.startsWith('/sub/') ? '/sub/auth/login' : '/auth/login'
|
||||||
|
|
||||||
// 跳转到登录页面(如果不在登录页面)
|
// 跳转到登录页面(如果不在登录页面)
|
||||||
if (router.currentRoute.value.path !== '/auth/login') {
|
if (currentPath !== loginPath) {
|
||||||
router.push('/auth/login')
|
router.push(loginPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
// // 本地开发时将 /api/v1 的请求代理到 8080 端口
|
// 本地开发时将 /api/v1 的请求代理到 8080 端口
|
||||||
'/api/v1': {
|
'/api/v1': {
|
||||||
target: 'http://localhost:8080',
|
target: 'http://localhost:8080',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user