feat: 管理端聚合页、小程序/抖音埋点与统计、飞书线索 webhook、API 迁移与路由

- admin:OrdersHub/UsersHub、Commerce/Ops/Enterprise Hub、MpAnalytics、Feishu/小程序配置面板、鉴权存储
- api:Analytics、DataMigration、FeishuLeadWebhook、mp 事件迁移 SQL
- 微信/抖音小程序:analytics 上报与相关页面调整
- 开发文档与 scripts 补充

Made-with: Cursor
This commit is contained in:
卡若
2026-03-27 17:18:25 +08:00
parent 9601b6955b
commit aca2e263bb
188 changed files with 41937 additions and 27625 deletions

View File

@@ -0,0 +1,88 @@
/**
* 管理端与超管端使用独立 localStorage避免互相覆盖 Token。
* 兼容旧键 authToken / userRole首次加载时迁移到新键后删除旧键。
*/
export const ADMIN_TOKEN_KEY = 'adminAuthToken'
export const ADMIN_ROLE_KEY = 'adminUserRole'
export const ADMIN_USER_ID_KEY = 'adminUserId'
export const SUPERADMIN_TOKEN_KEY = 'superadminAuthToken'
export const SUPERADMIN_ROLE_KEY = 'superadminUserRole'
export const SUPERADMIN_USER_ID_KEY = 'superadminUserId'
const LEGACY_TOKEN = 'authToken'
const LEGACY_ROLE = 'userRole'
const LEGACY_USER_ID = 'userId'
/** 应用启动时调用一次 */
export function migrateLegacyAuthStorage(): void {
if (typeof localStorage === 'undefined') return
const legacyToken = localStorage.getItem(LEGACY_TOKEN)
const legacyRole = localStorage.getItem(LEGACY_ROLE)
if (!legacyToken || !legacyRole) return
const hasSplit =
localStorage.getItem(ADMIN_TOKEN_KEY) || localStorage.getItem(SUPERADMIN_TOKEN_KEY)
if (hasSplit) {
localStorage.removeItem(LEGACY_TOKEN)
localStorage.removeItem(LEGACY_ROLE)
localStorage.removeItem(LEGACY_USER_ID)
return
}
const legacyUserId = localStorage.getItem(LEGACY_USER_ID)
if (legacyRole === 'superadmin') {
localStorage.setItem(SUPERADMIN_TOKEN_KEY, legacyToken)
localStorage.setItem(SUPERADMIN_ROLE_KEY, legacyRole)
if (legacyUserId) localStorage.setItem(SUPERADMIN_USER_ID_KEY, legacyUserId)
} else if (['admin', 'enterprise_admin'].includes(legacyRole)) {
localStorage.setItem(ADMIN_TOKEN_KEY, legacyToken)
localStorage.setItem(ADMIN_ROLE_KEY, legacyRole)
if (legacyUserId) localStorage.setItem(ADMIN_USER_ID_KEY, legacyUserId)
}
localStorage.removeItem(LEGACY_TOKEN)
localStorage.removeItem(LEGACY_ROLE)
localStorage.removeItem(LEGACY_USER_ID)
}
export function getAdminToken(): string | null {
return localStorage.getItem(ADMIN_TOKEN_KEY)
}
export function getAdminRole(): string | null {
return localStorage.getItem(ADMIN_ROLE_KEY)
}
export function getSuperadminToken(): string | null {
return localStorage.getItem(SUPERADMIN_TOKEN_KEY)
}
export function getSuperadminRole(): string | null {
return localStorage.getItem(SUPERADMIN_ROLE_KEY)
}
/** axios / 上传:按当前页面路径选择 Bearer */
export function getBearerTokenForCurrentApp(): string | null {
if (typeof window === 'undefined') return null
if (window.location.pathname.startsWith('/superadmin')) {
return getSuperadminToken()
}
return getAdminToken()
}
export function clearAdminAuthKeys(): void {
localStorage.removeItem(ADMIN_TOKEN_KEY)
localStorage.removeItem(ADMIN_ROLE_KEY)
localStorage.removeItem(ADMIN_USER_ID_KEY)
localStorage.removeItem('adminLoggedIn')
}
export function clearSuperadminAuthKeys(): void {
localStorage.removeItem(SUPERADMIN_TOKEN_KEY)
localStorage.removeItem(SUPERADMIN_ROLE_KEY)
localStorage.removeItem(SUPERADMIN_USER_ID_KEY)
localStorage.removeItem('superAdminLoggedIn')
}

View File

@@ -1,118 +1,127 @@
import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'
// 获取API基础URL
const getBaseURL = (): string => {
const envURL = import.meta.env.VITE_API_BASE_URL
if (envURL) {
// 如果环境变量是完整URL拼接 /api/v1
return envURL.endsWith('/') ? `${envURL}api/v1` : `${envURL}/api/v1`
}
// 默认使用相对路径
return '/api/v1'
}
// 创建 axios 实例
const service: AxiosInstance = axios.create({
baseURL: getBaseURL(),
timeout: 15000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
service.interceptors.request.use(
(config) => {
// 可以在这里添加 token
const token = localStorage.getItem('authToken')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
console.error('请求错误:', error)
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse) => {
const res = response.data
// 如果返回的状态码不是 200则认为是错误
if (res.code && res.code !== 200) {
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message || '请求失败'))
}
return res
},
(error) => {
console.error('响应错误:', error)
if (error.response) {
const { status, data } = error.response
if (status === 401) {
ElMessage.error('未授权,请重新登录')
// 清除所有登录状态和Token
localStorage.removeItem('authToken')
localStorage.removeItem('userRole')
localStorage.removeItem('userId')
localStorage.removeItem('adminLoggedIn')
localStorage.removeItem('superAdminLoggedIn')
// 根据当前路径跳转到对应登录页
if (window.location.pathname.startsWith('/superadmin')) {
window.location.href = '/superadmin/login'
} else {
window.location.href = '/admin/login'
}
} else if (status === 403) {
ElMessage.error('拒绝访问')
} else if (status === 404) {
ElMessage.error('请求地址不存在')
} else if (status === 500) {
ElMessage.error('服务器错误')
} else {
ElMessage.error(data?.message || '请求失败')
}
} else if (error.request) {
ElMessage.error('网络错误,请检查网络连接')
} else {
ElMessage.error('请求配置错误')
}
return Promise.reject(error)
}
)
// 导出请求方法
export const request = {
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return service.get(url, config)
},
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.post(url, data, config)
},
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.put(url, data, config)
},
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return service.delete(url, config)
},
patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.patch(url, data, config)
}
}
export default service
import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'
import {
getBearerTokenForCurrentApp,
clearAdminAuthKeys,
clearSuperadminAuthKeys
} from '@/utils/authStorage'
let lastBizErrorKey = ''
let lastBizErrorAt = 0
function showBizErrorOnce(message: string) {
const key = message || '请求失败'
const now = Date.now()
if (key === lastBizErrorKey && now - lastBizErrorAt < 1800) return
lastBizErrorKey = key
lastBizErrorAt = now
ElMessage.error(key)
}
// 获取API基础URL开发环境留空 VITE_API_BASE_URL 时走同源 /api/v1由 Vite 代理到本机后端)
const getBaseURL = (): string => {
const raw = import.meta.env.VITE_API_BASE_URL as string | undefined
const envURL = typeof raw === 'string' ? raw.trim() : ''
if (envURL) {
return envURL.endsWith('/') ? `${envURL}api/v1` : `${envURL}/api/v1`
}
return '/api/v1'
}
// 创建 axios 实例
const service: AxiosInstance = axios.create({
baseURL: getBaseURL(),
timeout: 15000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
service.interceptors.request.use(
(config) => {
// 可以在这里添加 token
const token = getBearerTokenForCurrentApp()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
console.error('请求错误:', error)
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse) => {
const res = response.data
// 如果返回的状态码不是 200则认为是错误
if (res.code && res.code !== 200) {
showBizErrorOnce(res.message || '请求失败')
return Promise.reject(new Error(res.message || '请求失败'))
}
return res
},
(error) => {
console.error('响应错误:', error)
if (error.response) {
const { status, data } = error.response
if (status === 401) {
ElMessage.error('未授权,请重新登录')
if (window.location.pathname.startsWith('/superadmin')) {
clearSuperadminAuthKeys()
window.location.href = '/superadmin/login'
} else {
clearAdminAuthKeys()
window.location.href = '/admin/login'
}
} else if (status === 403) {
showBizErrorOnce(data?.message || '拒绝访问')
} else if (status === 404) {
ElMessage.error('请求地址不存在')
} else if (status === 500) {
ElMessage.error('服务器错误')
} else {
ElMessage.error(data?.message || '请求失败')
}
} else if (error.request) {
ElMessage.error('网络错误,请检查网络连接')
} else {
ElMessage.error('请求配置错误')
}
return Promise.reject(error)
}
)
// 导出请求方法
export const request = {
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return service.get(url, config)
},
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.post(url, data, config)
},
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.put(url, data, config)
},
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return service.delete(url, config)
},
patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.patch(url, data, config)
}
}
export default service