feat: Add base project structure with API and web components

This commit is contained in:
Ke Sun
2025-12-02 20:28:01 +08:00
parent f3de6d6cc9
commit c1adc62ec6
817 changed files with 111226 additions and 106 deletions

5
web/src/utils/auth.ts Normal file
View File

@@ -0,0 +1,5 @@
export const clearAuthData = () => {
console.log("Clearing auth data and redirecting to login");
sessionStorage.clear();
localStorage.clear()
}

28
web/src/utils/common.ts Normal file
View File

@@ -0,0 +1,28 @@
export const randomString = (length: number = 12, isHasSpecialChars: boolean = true) => {
// 定义字符集:大写字母、小写字母、数字和特殊字符
const uppercaseChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const lowercaseChars = 'abcdefghijklmnopqrstuvwxyz';
const numberChars = '0123456789';
const specialChars = '!@#$%^&*_+-=|;:,.?';
// 合并所有字符集
let allChars = uppercaseChars + lowercaseChars + numberChars;
// 确保至少包含每种类型的字符
let str =
uppercaseChars[Math.floor(Math.random() * uppercaseChars.length)] +
lowercaseChars[Math.floor(Math.random() * lowercaseChars.length)] +
numberChars[Math.floor(Math.random() * numberChars.length)]
if (isHasSpecialChars) {
allChars+= specialChars;
str+= specialChars[Math.floor(Math.random() * specialChars.length)];
}
// 填充剩余的字符使总长度为12
for (let i = 4; i < length; i++) {
str += allChars[Math.floor(Math.random() * allChars.length)];
}
// 打乱密码字符顺序
return str.split('').sort(() => Math.random() - 0.5).join('');
}

32
web/src/utils/format.ts Normal file
View File

@@ -0,0 +1,32 @@
/**
* 格式化日期时间
* @param value 时间戳(毫秒)或日期字符串
* @param format 目标格式,支持 YYYY-MM-DD HH:mm:ss、YYYY/MM/DD HH:mm:ss、HH:mm 等
* @returns 格式化后的日期时间字符串
*/
import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
// 扩展dayjs插件
dayjs.extend(utc);
dayjs.extend(timezone);
export const formatDateTime = (
value: string | number | null | undefined,
format: string = 'YYYY-MM-DD HH:mm:ss'
): string => {
if (!value) return '';
// 检查日期是否有效
if (!dayjs(value).isValid()) {
return '';
}
// 每次调用都获取最新的时区设置
const currentTimeZone = localStorage.getItem('timeZone') || 'Asia/Shanghai';
dayjs.tz.setDefault(currentTimeZone);
// 使用最新时区格式化日期
return dayjs.tz(value).format(format);
};

298
web/src/utils/request.ts Normal file
View File

@@ -0,0 +1,298 @@
import axios from 'axios';
import type { AxiosRequestConfig } from 'axios';
import { clearAuthData } from './auth';
import { message } from 'antd';
import { refreshTokenUrl, refreshToken, loginUrl, logoutUrl } from '@/api/user'
import i18n from '@/i18n'
export interface ResponseData {
code: number;
msg: string;
data: data | Record<string, string | number | boolean | object | null | undefined>[] | object | any[];
error: string;
time: number;
}
interface data {
"items": Record<string, string | number | boolean | object | null | undefined>[];
"page": {
"page": number;
"pagesize": number;
"total": number;
"hasnext": boolean;
}
}
// 创建axios实例
const service = axios.create({
baseURL: '/api', // 与vite.config.ts中的代理配置对应
// timeout: 10000, // 请求超时时间
headers: {
'Content-Type': 'application/json'
},
});
// 是否正在刷新token
let isRefreshing = false;
// 存储待重试的请求队列
interface RequestQueueItem {
config: AxiosRequestConfig;
resolve: (token: string) => void;
reject: (error: Error) => void;
}
let requests: RequestQueueItem[] = [];
// 请求拦截器
service.interceptors.request.use(
(config) => {
if (!config.headers.Authorization) {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
},
(error) => {
// 对请求错误做些什么
console.error('请求错误:', error);
return Promise.reject(error);
}
);
// 刷新token的函数
const tokenRefresh = async (): Promise<string> => {
try {
const refresh_token = localStorage.getItem('refresh_token');
if (window.location.hash.includes('#/invite-register')) {
throw new Error(i18n.t('common.refreshTokenNotExist'));
}
if (!refresh_token) {
throw new Error(i18n.t('common.refreshTokenNotExist'));
}
// 使用原生axios调用refresh接口避免触发拦截器导致的循环调用
const response: any = await refreshToken();
const newToken = response.access_token;
localStorage.setItem('token', newToken);
return newToken;
} catch (error) {
// 如果refresh接口也返回401则退出登录
clearAuthData();
message.warning(i18n.t('common.loginExpired'));
// 这里可以添加重定向到登录页的逻辑
if (!window.location.hash.includes('#/login')) {
window.location.href = `/#/login`;
}
throw error;
}
};
// 响应拦截器
service.interceptors.response.use(
(response) => {
// 对响应数据做点什么
const { data: responseData } = response;
// 如果响应数据不是对象,直接返回
if (!responseData || typeof responseData !== 'object') {
return responseData;
}
const { data, code } = responseData;
switch (code) {
case 0:
case 200:
return data !== undefined ? data : responseData;
case 401:
// 处理未授权情况
return handle401Error(response.config);
default:
if (code === undefined) {
return responseData;
}
if (responseData.error || responseData.msg) {
message.warning(responseData.error || responseData.msg)
}
return Promise.reject(responseData);
}
},
(error) => {
// 处理网络错误、超时等
let msg = error.response?.data?.error || error.response?.error;
const status = error?.response ? error.response.status : error;
// 服务器响应了但状态码不在2xx范围
switch (status) {
case 401:
// 处理未授权情况
return handle401Error(error.config);
case 403:
msg = i18n.t('common.permissionDenied');
break;
case 404:
msg = i18n.t('common.apiNotFound');
break;
case 429:
msg = i18n.t('common.tooManyRequests');
break;
case 500:
case 502:
msg = msg || i18n.t('common.serviceUpgrading');
break;
case 504:
msg = msg || i18n.t('common.serverError');
break;
default:
if (!msg && Array.isArray(error.response?.data?.detail)) {
msg = error.response?.data?.detail?.map(item => item.msg).join(';')
} else {
msg = msg || i18n.t('common.unknownError');
}
break;
}
message.warning(msg);
return Promise.reject(error);
}
);
// 处理401错误的函数
const handle401Error = async (config: AxiosRequestConfig): Promise<unknown> => {
// 如果是refresh接口本身返回401则直接退出登录
if (config.url === refreshTokenUrl) {
clearAuthData();
message.warning(i18n.t('common.loginExpired'));
return Promise.reject(new Error(i18n.t('common.loginExpired')));
}
if (config.url === loginUrl) {
return Promise.reject(new Error(i18n.t('common.loginApiCannotRefreshToken')));
}
if (config.url === logoutUrl) {
window.location.href = `/#/login`;
return Promise.reject(new Error(i18n.t('common.logoutApiCannotRefreshToken')));
}
if (config.url?.includes('/public')) {
return Promise.reject(new Error(i18n.t('common.publicApiCannotRefreshToken')));
}
// 如果正在刷新token则将当前请求加入队列
if (isRefreshing) {
return new Promise((resolve, reject) => {
requests.push({ config, resolve, reject });
}).then((token) => {
// 使用新token重新发送请求
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${token}`;
return service(config);
});
}
// 开始刷新token
isRefreshing = true;
try {
const newToken = await tokenRefresh();
// 更新队列中所有请求的token并重新发送
requests.forEach(({ config, resolve }) => {
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${newToken}`;
resolve(newToken);
});
// 清空队列
requests = [];
// 使用新token重新发送当前请求
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${newToken}`;
return service(config);
} catch (error) {
// 刷新token失败清空队列并拒绝所有请求
requests.forEach(({ reject }) => {
reject(error as Error);
});
requests = [];
return Promise.reject(error);
} finally {
isRefreshing = false;
}
};
interface ObjectWithPush {
_push?: boolean;
[key: string]: string | number | boolean | object | null | undefined;
}
function paramFilter(params: Record<string, string | number | boolean | ObjectWithPush | null | undefined> = {}) {
Object.keys(params).forEach(key => {
const val = params[key];
if (val && typeof(val) === 'object'){
const objVal = val as ObjectWithPush;
if(objVal._push){
delete objVal._push;
}else{
delete params[key];
}
} else if(val || val === 0 || val === false){
if(typeof(val) === 'string'){
params[key] = val.trim();
}
}else{
delete params[key];
}
});
return params;
}
// 封装请求方法
export const request = {
get<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return service.get(url, {
params: paramFilter(data as Record<string, string | number | boolean | ObjectWithPush | null | undefined>),
...config || {}
});
},
post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return service.post(url, data, config);
},
put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return service.put(url, data, config);
},
delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
return service.delete(url, config);
},
patch<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return service.patch(url, data, config);
},
uploadFile<T>(url: string, formData?: unknown, config?: AxiosRequestConfig): Promise<T> {
return service.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
...config
});
},
downloadFile(url: string, fileName: string, data?: unknown) {
service.post(url, data, {
responseType: "blob",
})
.then(res =>{
const link = document.createElement("a");
const blob = new Blob([res.data], { type: "application/vnd.ms-excel" });
link.style.display = "none";
link.href = URL.createObjectURL(blob);
link.setAttribute("download", decodeURI(res.headers['filename'] || fileName));
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
}
};
export default service;

48
web/src/utils/stream.ts Normal file
View File

@@ -0,0 +1,48 @@
import { message } from 'antd';
import i18n from '@/i18n'
const API_PREFIX = '/api'
export const handleSSE = async (url: string, data: any, onMessage?: (data: string) => void, config = {}) => {
try {
const token = localStorage.getItem('token');
const response = await fetch(`${API_PREFIX}${url}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
...config.headers,
},
body: JSON.stringify(data)
});
const { status } = response
switch(status) {
case 401:
if (url?.includes('/public')) {
return message.warning(i18n.t('common.publicApiCannotRefreshToken'));
}
window.location.href = `/#/login`;
break;
default:
if (!response.body) throw new Error('No response body');
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
if (onMessage) {
onMessage(chunk);
}
}
break;
}
} catch (error) {
console.error('Request failed:', error);
throw error;
}
}

168
web/src/utils/timezones.ts Normal file
View File

@@ -0,0 +1,168 @@
import en_US from 'antd/locale/en_US';
import en_GB from 'antd/locale/en_GB';
import de_DE from 'antd/locale/de_DE';
import ru_RU from 'antd/locale/ru_RU';
import hi_IN from 'antd/locale/hi_IN';
import zh_CN from 'antd/locale/zh_CN';
import type { Locale } from 'antd/es/locale';
import 'dayjs/locale/zh'
import 'dayjs/locale/hi'
import 'dayjs/locale/ru'
import 'dayjs/locale/de'
import 'dayjs/locale/en-gb'
import 'dayjs/locale/en'
// 全世界主要时区列表
export const timezones = [
'America/Los_Angeles', // 美国洛杉矶
'America/New_York', // 美国纽约
'Europe/London', // 英国伦敦
'Europe/Berlin', // 德国柏林
'Europe/Moscow', // 俄罗斯莫斯科
'Asia/Kolkata', // 印度加尔各答
'Asia/Shanghai', // 中国上海
// 亚洲
// 'Asia/Tokyo', // 日本东京
// 'Asia/Singapore', // 新加坡
// 'Asia/Hong_Kong', // 中国香港
// 'Asia/Taipei', // 中国台北
// 'Asia/Seoul', // 韩国首尔
// 'Asia/Bangkok', // 泰国曼谷
// 'Asia/Jakarta', // 印度尼西亚雅加达
// 'Asia/Manila', // 菲律宾马尼拉
// 'Asia/Dubai', // 阿联酋迪拜
// 'Asia/Tashkent', // 乌兹别克斯坦塔什干
// 'Asia/Riyadh', // 沙特阿拉伯利雅得
// 'Asia/Baku', // 阿塞拜疆巴库
// 'Asia/Istanbul', // 土耳其伊斯坦布尔
// 欧洲
// 'Europe/Paris', // 法国巴黎
// 'Europe/Rome', // 意大利罗马
// 'Europe/Madrid', // 西班牙马德里
// 'Europe/Amsterdam', // 荷兰阿姆斯特丹
// 'Europe/Vienna', // 奥地利维也纳
// 'Europe/Stockholm', // 瑞典斯德哥尔摩
// 'Europe/Oslo', // 挪威奥斯陆
// 'Europe/Copenhagen', // 丹麦哥本哈根
// 'Europe/Zurich', // 瑞士苏黎世
// 'Europe/Athens', // 希腊雅典
// 'Europe/Warsaw', // 波兰华沙
// 'Europe/Prague', // 捷克布拉格
// 'Europe/Budapest', // 匈牙利布达佩斯
// 'Europe/Belgrade', // 塞尔维亚贝尔格莱德
// 北美洲
// 'America/Chicago', // 美国芝加哥
// 'America/Denver', // 美国丹佛
// 'America/Toronto', // 加拿大多伦多
// 'America/Vancouver', // 加拿大温哥华
// 'America/Mexico_City', // 墨西哥墨西哥城
// 南美洲
// 'America/Sao_Paulo', // 巴西圣保罗
// 'America/Buenos_Aires', // 阿根廷布宜诺斯艾利斯
// 'America/Santiago', // 智利圣地亚哥
// 'America/Lima', // 秘鲁利马
// 'America/Bogota', // 哥伦比亚波哥大
// 'America/Caracas', // 委内瑞拉加拉加斯
// // 大洋洲
// 'Australia/Sydney', // 澳大利亚悉尼
// 'Australia/Melbourne', // 澳大利亚墨尔本
// 'Australia/Brisbane', // 澳大利亚布里斯班
// 'Australia/Perth', // 澳大利亚珀斯
// 'New_Zealand/Auckland', // 新西兰奥克兰
// // 非洲
// 'Africa/Cairo', // 埃及开罗
// 'Africa/Johannesburg', // 南非约翰内斯堡
// 'Africa/Lagos', // 尼日利亚拉各斯
// 'Africa/Casablanca', // 摩洛哥卡萨布兰卡
// 'Africa/Nairobi', // 肯尼亚内罗毕
// 'Africa/Addis_Ababa', // 埃塞俄比亚亚的斯亚贝巴
// // 其他
// 'UTC', // 协调世界时
];
// 注意时区显示名称已移至i18n翻译文件中zh.ts和en.ts
// 请使用i18n.t('timezones.时区名称')来获取本地化的时区显示名称
// 时区与antd本地化文件的映射
// 键为时区值为antd本地化文件的名称
export const timezoneToAntdLocaleMap: Record<string, Locale> = {
// 亚洲
'Asia/Shanghai': zh_CN, // 中国上海 - 中文(中国大陆)
'Asia/Kolkata': hi_IN, // 印度加尔各答 - 印地语
'Europe/Moscow': ru_RU, // 俄罗斯莫斯科 - 俄语
'Europe/Berlin': de_DE, // 德国柏林 - 德语
'Europe/London': en_GB, // 英国伦敦 - 英语(英国)
'America/New_York': en_US, // 美国纽约 - 英语(美国)
'America/Los_Angeles': en_US, // 美国洛杉矶 - 英语(美国)
// 'Asia/Tokyo': 'ja_JP', // 日本东京 - 日语
// 'Asia/Singapore': 'en_SG', // 新加坡 - 英语(新加坡)
// 'Asia/Hong_Kong': 'zh_HK', // 中国香港 - 中文(香港)
// 'Asia/Taipei': 'zh_TW', // 中国台北 - 中文(台湾)
// 'Asia/Seoul': 'ko_KR', // 韩国首尔 - 韩语
// 'Asia/Bangkok': 'th_TH', // 泰国曼谷 - 泰语
// 'Asia/Jakarta': 'id_ID', // 印度尼西亚雅加达 - 印尼语
// 'Asia/Manila': 'en_PH', // 菲律宾马尼拉 - 英语(菲律宾)
// 'Asia/Dubai': 'ar_AE', // 阿联酋迪拜 - 阿拉伯语
// 'Asia/Tashkent': 'uz_UZ', // 乌兹别克斯坦塔什干 - 乌兹别克语
// 'Asia/Riyadh': 'ar_SA', // 沙特阿拉伯利雅得 - 阿拉伯语
// 'Asia/Baku': 'az_AZ', // 阿塞拜疆巴库 - 阿塞拜疆语
// 'Asia/Istanbul': 'tr_TR', // 土耳其伊斯坦布尔 - 土耳其语
// // 欧洲
// 'Europe/Paris': 'fr_FR', // 法国巴黎 - 法语
// 'Europe/Rome': 'it_IT', // 意大利罗马 - 意大利语
// 'Europe/Madrid': 'es_ES', // 西班牙马德里 - 西班牙语
// 'Europe/Amsterdam': 'nl_NL', // 荷兰阿姆斯特丹 - 荷兰语
// 'Europe/Vienna': 'de_AT', // 奥地利维也纳 - 德语(奥地利)
// 'Europe/Stockholm': 'sv_SE', // 瑞典斯德哥尔摩 - 瑞典语
// 'Europe/Oslo': 'nb_NO', // 挪威奥斯陆 - 挪威语
// 'Europe/Copenhagen': 'da_DK', // 丹麦哥本哈根 - 丹麦语
// 'Europe/Zurich': 'de_CH', // 瑞士苏黎世 - 德语(瑞士)
// 'Europe/Athens': 'el_GR', // 希腊雅典 - 希腊语
// 'Europe/Warsaw': 'pl_PL', // 波兰华沙 - 波兰语
// 'Europe/Prague': 'cs_CZ', // 捷克布拉格 - 捷克语
// 'Europe/Budapest': 'hu_HU', // 匈牙利布达佩斯 - 匈牙利语
// 'Europe/Belgrade': 'sr_RS', // 塞尔维亚贝尔格莱德 - 塞尔维亚语
// // 北美洲
// 'America/Chicago': 'en_US', // 美国芝加哥 - 英语(美国)
// 'America/Denver': 'en_US', // 美国丹佛 - 英语(美国)
// 'America/Toronto': 'en_CA', // 加拿大多伦多 - 英语(加拿大)
// 'America/Vancouver': 'en_CA', // 加拿大温哥华 - 英语(加拿大)
// 'America/Mexico_City': 'es_MX', // 墨西哥墨西哥城 - 西班牙语(墨西哥)
// // 南美洲
// 'America/Sao_Paulo': 'pt_BR', // 巴西圣保罗 - 葡萄牙语(巴西)
// 'America/Buenos_Aires': 'es_AR', // 阿根廷布宜诺斯艾利斯 - 西班牙语(阿根廷)
// 'America/Santiago': 'es_CL', // 智利圣地亚哥 - 西班牙语(智利)
// 'America/Lima': 'es_PE', // 秘鲁利马 - 西班牙语(秘鲁)
// 'America/Bogota': 'es_CO', // 哥伦比亚波哥大 - 西班牙语(哥伦比亚)
// 'America/Caracas': 'es_VE', // 委内瑞拉加拉加斯 - 西班牙语(委内瑞拉)
// // 大洋洲
// 'Australia/Sydney': 'en_AU', // 澳大利亚悉尼 - 英语(澳大利亚)
// 'Australia/Melbourne': 'en_AU', // 澳大利亚墨尔本 - 英语(澳大利亚)
// 'Australia/Brisbane': 'en_AU', // 澳大利亚布里斯班 - 英语(澳大利亚)
// 'Australia/Perth': 'en_AU', // 澳大利亚珀斯 - 英语(澳大利亚)
// 'New_Zealand/Auckland': 'en_NZ', // 新西兰奥克兰 - 英语(新西兰)
// // 非洲
// 'Africa/Cairo': 'ar_EG', // 埃及开罗 - 阿拉伯语(埃及)
// 'Africa/Johannesburg': 'en_ZA', // 南非约翰内斯堡 - 英语(南非)
// 'Africa/Lagos': 'en_NG', // 尼日利亚拉各斯 - 英语(尼日利亚)
// 'Africa/Casablanca': 'fr_MA', // 摩洛哥卡萨布兰卡 - 法语(摩洛哥)
// 'Africa/Nairobi': 'en_KE', // 肯尼亚内罗毕 - 英语(肯尼亚)
// 'Africa/Addis_Ababa': 'am_ET', // 埃塞俄比亚亚的斯亚贝巴 - 阿姆哈拉语
// // 其他
// 'UTC': 'en_US', // 协调世界时 - 默认英语(美国)
};