feat: init

This commit is contained in:
2025-09-05 15:18:10 +08:00
parent ddbee614e8
commit 85244a451e
126 changed files with 3020 additions and 10278 deletions

View File

@@ -8,45 +8,5 @@
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@icon-park/react": "^1.4.2",
"@types/node": "^20.10.0",
"@types/react-router-dom": "^5.3.3",
"@uidotdev/usehooks": "^2.4.1",
"@vitejs/plugin-basic-ssl": "^2.1.0",
"antd-mobile": "^5.33.0",
"antd-mobile-icons": "^0.3.0",
"axios": "^1.6.2",
"axios-hooks": "^5.0.2",
"js-audio-recorder": "^1.0.7",
"jsqr": "^1.4.0",
"less": "^4.2.0",
"query-string": "^8.1.0",
"react": "^18.2.0",
"react-audio-voice-recorder": "^2.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"react-slick": "^0.29.0",
"slick-carousel": "^1.8.1",
"weixin-js-sdk": "^1.6.5",
"zustand": "^4.4.6",
"@workspace/shared": "workspace:*",
"@workspace/utils": "workspace:*"
},
"devDependencies": {
"@types/lodash.isequal": "^4.5.8",
"@types/react": "^18.3.24",
"@types/react-dom": "^18.3.7",
"@types/react-slick": "^0.23.12",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^9.34.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"postcss-pxtorem": "^6.0.0",
"typescript": "^5.2.2",
"vite": "^5.0.0"
}
}

View File

@@ -0,0 +1,14 @@
export default {
plugins: {
"postcss-pxtorem": {
rootValue: 16, // 75表示750设计稿37.5表示375设计稿建议设置成37.5因为可以兼容大部分移动端ui库
unitPrecision: 5,
propList: ["*"],
selectorBlackList: [],
replace: true,
mediaQuery: false,
minPixelValue: 0,
selectorBlackList: ["norem"], // 过滤掉norem-开头的class不进行rem转换
},
},
};

View File

@@ -0,0 +1,12 @@
import zhCN from "@/locales/zh_CN.json";
import enUS from "@/locales/en_US.json";
import { useI18nStore } from "@/store/i18n";
export default function useI18n() {
const { lang } = useI18nStore();
const locales = lang === "en_US" ? enUS : zhCN;
return (name: string) => {
return locales[name as keyof typeof locales] || name;
};
}

View File

@@ -0,0 +1,63 @@
import { useState, useEffect } from "react";
import useI18n from "./i18n";
// 定义位置坐标的类型
export interface Coordinates {
lat: number;
lng: number;
}
// 定义返回的位置状态的类型
export interface LocationState {
loaded: boolean;
coordinates: Coordinates | null;
}
// 定义错误状态的类型
export type ErrorState = string | null;
export const useLocation = (): {
location: LocationState;
error: ErrorState;
} => {
const t = useI18n();
// 用于存储位置信息和加载状态的状态
const [location, setLocation] = useState<LocationState>({
loaded: false,
coordinates: null,
});
// 用于存储任何错误消息的状态
const [error, setError] = useState<ErrorState>(null);
// 地理位置成功处理函数
const onSuccess = (location: GeolocationPosition) => {
setLocation({
loaded: true,
coordinates: {
lat: location.coords.latitude,
lng: location.coords.longitude,
},
});
};
// 地理位置错误处理函数
const onError = (error: GeolocationPositionError) => {
setError(error.message);
};
// 使用 useEffect 在组件挂载时执行地理位置请求
useEffect(() => {
// 检查浏览器是否支持地理位置
if (!navigator.geolocation) {
setError(t("hooks.location.unsupported"));
return;
}
// 请求用户的当前位置
navigator.geolocation.getCurrentPosition(onSuccess, onError);
}, []);
return { location, error };
};

View File

@@ -0,0 +1,28 @@
import {useState, useEffect} from 'react';
import isEqual from 'lodash.isequal';
function useSessionStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
// 初始化状态
const [storedValue, setStoredValue] = useState<T>(() => {
const item = sessionStorage.getItem(key);
if (item !== null) {
// 如果 sessionStorage 中有数据,则使用现有数据
return JSON.parse(item);
} else {
// 当 sessionStorage 中没有相应的键时,使用 initialValue 初始化,并写入 sessionStorage
sessionStorage.setItem(key, JSON.stringify(initialValue));
return initialValue;
}
});
// 监听并保存变化到 sessionStorage
useEffect(() => {
if (!isEqual(JSON.parse(sessionStorage.getItem(key) || 'null'), storedValue)) {
sessionStorage.setItem(key, JSON.stringify(storedValue));
}
}, [key, storedValue]);
return [storedValue, setStoredValue];
}
export default useSessionStorage;

View File

@@ -0,0 +1,198 @@
// hooks/useFileUpload.ts (更新)
import { useState, useCallback } from "react";
import {
UploadConfig,
UploadProgress,
UploadResponse,
VoiceUploadStatus,
} from "../types/upload";
interface UseFileUploadReturn {
uploadStatus: VoiceUploadStatus;
uploadFile: (
file: Blob,
fileName: string,
config: UploadConfig
) => Promise<UploadResponse>;
resetUpload: () => void;
}
export const useFileUpload = (): UseFileUploadReturn => {
const [uploadStatus, setUploadStatus] = useState<VoiceUploadStatus>({
status: "idle",
});
// 检测文件类型并转换文件名
const getFileExtension = (mimeType: string): string => {
const mimeToExt: Record<string, string> = {
"audio/webm": ".webm",
"audio/mp4": ".m4a",
"audio/aac": ".aac",
"audio/wav": ".wav",
"audio/ogg": ".ogg",
"audio/mpeg": ".mp3",
};
// 处理带codecs的MIME类型
const baseMimeType = mimeType.split(";")[0];
return mimeToExt[baseMimeType] || ".webm";
};
const uploadFile = useCallback(
async (
file: Blob,
fileName: string,
config: UploadConfig
): Promise<UploadResponse> => {
// 检查文件大小
if (config.maxFileSize && file.size > config.maxFileSize) {
const error = `文件大小超过限制 (${Math.round(
config.maxFileSize / 1024 / 1024
)}MB)`;
setUploadStatus({
status: "error",
error,
});
throw new Error(error);
}
// 更宽松的文件类型检查支持iOS格式
const allowedTypes = config.allowedTypes || [
"audio/webm",
"audio/mp4",
"audio/aac",
"audio/wav",
"audio/ogg",
"audio/mpeg",
];
const baseMimeType = file.type.split(";")[0];
const isTypeAllowed = allowedTypes.some(
(type) => baseMimeType === type || baseMimeType === type.split(";")[0]
);
if (!isTypeAllowed) {
console.warn(`文件类型 ${file.type} 不在允许列表中,但继续上传`);
}
// 根据实际MIME类型调整文件名
const extension = getFileExtension(file.type);
const adjustedFileName = fileName.replace(/\.[^/.]+$/, "") + extension;
setUploadStatus({
status: "uploading",
progress: { loaded: 0, total: file.size, percentage: 0 },
});
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append(config.fieldName || "file", file, adjustedFileName);
// 添加额外的元数据
formData.append("fileName", adjustedFileName);
formData.append("originalFileName", fileName);
formData.append("fileSize", file.size.toString());
formData.append("fileType", file.type);
formData.append("uploadTime", new Date().toISOString());
formData.append("userAgent", navigator.userAgent);
const xhr = new XMLHttpRequest();
// 上传进度监听
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) {
const progress: UploadProgress = {
loaded: event.loaded,
total: event.total,
percentage: Math.round((event.loaded / event.total) * 100),
};
setUploadStatus({
status: "uploading",
progress,
});
}
});
// 上传完成监听
xhr.addEventListener("load", () => {
try {
const response: UploadResponse = JSON.parse(xhr.responseText);
if (xhr.status >= 200 && xhr.status < 300 && response.success) {
setUploadStatus({
status: "success",
response,
progress: {
loaded: file.size,
total: file.size,
percentage: 100,
},
});
resolve(response);
} else {
const error = response.error || `上传失败: ${xhr.status}`;
setUploadStatus({
status: "error",
error,
});
reject(new Error(error));
}
} catch (parseError) {
const error = "服务器响应格式错误";
setUploadStatus({
status: "error",
error,
});
reject(new Error(error));
}
});
// 上传错误监听
xhr.addEventListener("error", () => {
const error = "网络错误,上传失败";
setUploadStatus({
status: "error",
error,
});
reject(new Error(error));
});
// 上传中断监听
xhr.addEventListener("abort", () => {
const error = "上传已取消";
setUploadStatus({
status: "error",
error,
});
reject(new Error(error));
});
// 设置请求头
const headers = {
"X-Requested-With": "XMLHttpRequest",
...config.headers,
};
Object.entries(headers).forEach(([key, value]) => {
xhr.setRequestHeader(key, value);
});
// 发送请求
xhr.open(config.method || "POST", config.url);
xhr.send(formData);
});
},
[]
);
const resetUpload = useCallback(() => {
setUploadStatus({ status: "idle" });
}, []);
return {
uploadStatus,
uploadFile,
resetUpload,
};
};

View File

@@ -11,12 +11,7 @@ interface MainLayoutProps {
onLink?: () => void;
}
const MainLayout: React.FC<MainLayoutProps> = ({
isShowNavBar,
children,
onLink,
title,
}) => {
const MainLayout: React.FC<MainLayoutProps> = ({ isShowNavBar, children, onLink, title }) => {
const navigate = useNavigate();
const location = useLocation();
const { pathname } = location;

View File

@@ -1,9 +1,14 @@
import React, {useEffect} from 'react';
import {useNavigate} from 'react-router-dom';
import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { AppRoute } from "./routes";
interface AuthRouteProps {
children: React.ReactNode;
auth?: boolean;
children: React.ReactNode;
auth?: boolean;
path: string;
meta?: {
title: string;
};
}
/**
@@ -12,18 +17,24 @@ interface AuthRouteProps {
* @param auth 是否需要认证
* @constructor 认证路由组件
*/
const AuthRoute: React.FC<AuthRouteProps> = ({children, auth}) => {
const navigate = useNavigate();
const token = localStorage.getItem('token'); // 或者其他认证令牌的获取方式
const isAuthenticated = Boolean(token); // 认证逻辑
const AuthRoute: React.FC<AuthRouteProps> = ({ children, auth, meta }) => {
const navigate = useNavigate();
const token = localStorage.getItem("token"); // 或者其他认证令牌的获取方式
const isAuthenticated = Boolean(token); // 认证逻辑
console.log(auth);
useEffect(() => {
if (meta?.title) {
document.title = meta.title;
}
}, [meta]);
useEffect(() => {
// 检查角色权限
if (auth && !isAuthenticated) {
navigate("/login"); // 如果未认证且路由需要认证,则重定向到登录
}
}, [auth, isAuthenticated, navigate]);
useEffect(() => {
if (auth && !isAuthenticated) {
navigate('/login'); // 如果未认证且路由需要认证,则重定向到登录
}
}, [auth, isAuthenticated, navigate]);
return <>{children}</>;
return <>{children}</>;
};
export default AuthRoute;

View File

@@ -1,27 +1,48 @@
import {Route, Routes} from 'react-router-dom';
import {routes, AppRoute} from './routes';
import AuthRoute from './auth.tsx';
import { Route, Routes } from "react-router-dom";
import { routes, AppRoute } from "./routes";
import AuthRoute from "./auth.tsx";
import { Suspense } from "react";
import { DotLoading } from "antd-mobile";
/**
* 渲染路由
* @constructor RenderRoutes
*/
export const RenderRoutes = () => {
const renderRoutes = (routes: AppRoute[]) => {
return routes.map(route => (
<Route
key={route.path}
path={route.path}
element={
<AuthRoute auth={route.auth}>
{route.element}
</AuthRoute>
}
>
{route.children && renderRoutes(route.children)}
</Route>
));
};
const renderRoutes = (routes: AppRoute[]) => {
return routes.map((route) => (
<Route
key={route.path}
path={route.path}
element={
<AuthRoute auth={route.auth} path={route.path} meta={route.meta}>
{route.element}
</AuthRoute>
}
>
{route.children && renderRoutes(route.children)}
</Route>
));
};
return <Routes>{renderRoutes(routes)}</Routes>;
return (
<Suspense
fallback={
<div
style={{
height: "100%",
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "12px",
}}
>
<DotLoading />
</div>
}
>
<Routes>{renderRoutes(routes)}</Routes>
</Suspense>
);
};

View File

@@ -1,19 +1,35 @@
import React from "react";
import Home from "@/view/home";
import TranslateDetail from "@/view/home/detail";
import Setting from "@/view/setting";
import Page404 from "@/view/error/page404";
import { Navigate } from "react-router-dom";
import { lazy } from "react";
export interface AppRoute {
path: string;
element: React.ReactNode;
auth?: boolean;
children?: AppRoute[];
meta?: {
title: string;
};
}
const Home = lazy(() => import("@/view/home"));
const Page404 = lazy(() => import("@/view/error/page404"));
const TranslateDetail = lazy(() => import("@/view/home/detail"));
export const routes: AppRoute[] = [
{ path: "/", element: <Home />, auth: false },
{ path: "/set", element: <Setting />, auth: false },
{ path: "/detail", element: <TranslateDetail />, auth: false },
{ path: "/mood", element: <Setting />, auth: false },
{
path: "/",
element: <Navigate to="/translate" replace />,
auth: false,
meta: {
title: "宠物翻译",
},
},
{
path: "/translate",
element: <Home />,
auth: false,
meta: {
title: "宠物翻译",
},
},
{ path: "/translate/detail", element: <TranslateDetail />, auth: false },
{ path: "*", element: <Page404 />, auth: false },
];

View File

@@ -0,0 +1,19 @@
// src/types/global.d.ts
declare module "*.module.less" {
const classes: { readonly [key: string]: string };
export default classes;
}
declare module "*.module.css" {
const classes: { readonly [key: string]: string };
export default classes;
}
// 其他资源文件
declare module "*.png";
declare module "*.jpg";
declare module "*.jpeg";
declare module "*.gif";
declare module "*.svg";
declare module "*.ico";
declare module "*.webp";

View File

@@ -15,7 +15,7 @@ interface PropsConfig {
handleAllAni: () => void;
}
const allAni = ["全部宠物", "丑丑", "胖胖", "可可"];
function Index(props: PropsConfig) {
function SearchCom(props: PropsConfig) {
const [aniName, setAniName] = useState<string>("全部宠物");
const animenuRef = useRef<DropdownRef>(null);
const handleAniSelect = (val: RadioValue) => {
@@ -84,4 +84,4 @@ function Index(props: PropsConfig) {
);
}
export default Index;
export default SearchCom;

View File

@@ -1,39 +1,41 @@
.home {
height: 100%;
overflow: hidden;
.adm-tabs {
display: flex;
flex-direction: column;
height: 100%;
}
.adm-tabs-content {
flex: 1;
overflow: hidden;
padding: 0px;
}
.adm-tabs-header {
border: 0 none;
position: sticky;
top: 0px;
background: #fff;
z-index: 99;
}
// .adm-tabs {
// display: flex;
// flex-direction: column;
// height: 100%;
// }
// .adm-tabs-content {
// flex: 1;
// overflow: hidden;
// padding: 0px;
// }
// .adm-tabs-header {
// border: 0 none;
// position: sticky;
// top: 0px;
// background: #fff;
// z-index: 99;
// }
.adm-tabs-tab {
font-size: 20px;
color: rgba(0, 0, 0, 0.25);
font-weight: 600;
&.adm-tabs-tab-active {
color: #000;
}
}
.adm-tabs-tab-line {
height: 0px;
}
// .adm-tabs-tab {
// font-size: 20px;
// color: rgba(0, 0, 0, 0.25);
// font-weight: 600;
// &.adm-tabs-tab-active {
// color: #000;
// }
// }
// .adm-tabs-tab-line {
// height: 0px;
// }
.translate-container {
display: flex;
flex-direction: column;
height: 100%;
.header {
}
}
}

View File

@@ -15,14 +15,18 @@ function Index() {
return (
<MainLayout>
<div className="home">
<Tabs stretch={false}>
<div className="header">
<h3></h3>
<div></div>
</div>
{/* <Tabs stretch={false}>
<Tabs.Tab title="宠物翻译" key="1">
<Translate />
</Tabs.Tab>
<Tabs.Tab title="宠物档案" key="2">
2
</Tabs.Tab>
</Tabs>
</Tabs> */}
</div>
</MainLayout>
);

View File

@@ -2,11 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { Image, Toast } from "antd-mobile";
import MessageCom from "./component/message";
import VoiceRecord from "./component/voice";
import {
XPopup,
FloatingMenu,
type FloatMenuItemConfig,
} from "@workspace/shared";
import { XPopup, FloatingMenu, type FloatMenuItemConfig } from "@workspace/shared";
import type { Message } from "./types";
import { mockTranslateAudio } from "@/utils/voice";
@@ -14,21 +10,14 @@ import dogSvg from "@/assets/translate/dog.svg";
import catSvg from "@/assets/translate/cat.svg";
import pigSvg from "@/assets/translate/pig.svg";
import { MoreTwo } from "@icon-park/react";
import SearchCom from "./component/search";
interface DefinedProps {}
const menuItems: FloatMenuItemConfig[] = [
{ icon: <Image src={dogSvg} />, type: "dog" },
{ icon: <Image src={catSvg} />, type: "cat" },
{ icon: <Image src={pigSvg} />, type: "pig" },
{
icon: (
<MoreTwo
theme="outline"
size="24"
fill="#666"
strokeWidth={3}
strokeLinecap="butt"
/>
),
icon: <MoreTwo theme="outline" size="24" fill="#666" strokeWidth={3} strokeLinecap="butt" />,
type: "add",
},
];
@@ -85,9 +74,7 @@ function Index(props: DefinedProps) {
setMessages((prev) =>
prev.map((msg) =>
msg.id === messageId
? { ...msg, translatedText, isTranslating: false }
: msg
msg.id === messageId ? { ...msg, translatedText, isTranslating: false } : msg
)
);
} catch (error) {
@@ -124,17 +111,17 @@ function Index(props: DefinedProps) {
return (
<div className="translate-container">
<div className="header">
<SearchCom handleAllAni={() => {}} />
</div>
<MessageCom data={messages} isRecording={isRecording}></MessageCom>
<VoiceRecord
onRecordingComplete={onRecordingComplete}
isRecording={isRecording}
onSetIsRecording={onSetIsRecording}
/>
<FloatingMenu
menuItems={menuItems}
value={currentLanguage}
onChange={onLanguage}
/>
<FloatingMenu menuItems={menuItems} value={currentLanguage} onChange={onLanguage} />
<XPopup
title="选择翻译语种"
visible={visible}

View File

@@ -1,13 +0,0 @@
import useI18n from "@/hooks/i18n.ts";
import MainLayout from "@/layout/main/mainLayout";
import { Button } from "antd-mobile";
import { useI18nStore } from "@/store/i18n.ts";
function Index() {
const t = useI18n();
const i18nStore = useI18nStore();
return <MainLayout>qq</MainLayout>;
}
export default Index;

View File

@@ -1,5 +1,4 @@
{
// "extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",

View File

@@ -1,9 +1,9 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import path from "path";
import path, { resolve } from "path";
import { inspectorServer } from "@react-dev-inspector/vite-plugin";
import basicSsl from "@vitejs/plugin-basic-ssl";
export default defineConfig({
// plugins: [react()],
plugins: [react(), basicSsl()],
server: {
https: {},
@@ -11,6 +11,29 @@ export default defineConfig({
host: "0.0.0.0",
open: true,
},
css: {
modules: {
generateScopedName: () => {
if (process.env.NODE_ENV === "production") {
return `[hash:base64:8]`;
}
return `[name]__[local]___[hash:base64:5]`;
},
},
preprocessorOptions: {
less: {
// 全局变量
// additionalData: `@import "${resolve(__dirname, "src/styles/variables.less")}";`,
// Less 选项
javascriptEnabled: true,
modifyVars: {
// 可以在这里定义全局变量
"@primary-color": "#1890ff",
"@border-radius-base": "6px",
},
},
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
@@ -19,6 +42,17 @@ export default defineConfig({
"@utils": path.resolve(__dirname, "../../packages/utils/src"),
},
},
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ["react", "react-dom"],
router: ["react-router-dom"],
},
},
},
chunkSizeWarningLimit: 1000,
},
optimizeDeps: {
include: ["@shared", "@hooks", "@utils"],
},