Compare commits
24 Commits
ddbee614e8
...
qianpw
| Author | SHA1 | Date | |
|---|---|---|---|
| f2cb55a844 | |||
| daf2bf503e | |||
| ae8e5cf384 | |||
| fc789b135f | |||
| bfa3d914e8 | |||
| 81fd471fd9 | |||
| 7f6aaff61c | |||
| e1b0be79f3 | |||
| edb54eef6c | |||
| 8282838dde | |||
| a6d84a1390 | |||
| 4116ef03e6 | |||
| ee96c5feb8 | |||
| 32b4a7e624 | |||
| 60c28c2297 | |||
| 7296a13e88 | |||
| 2378ad3a40 | |||
| 042f8c31a2 | |||
| a84d634949 | |||
| bb330be484 | |||
| f46a851ee5 | |||
| c9c9c8fa67 | |||
| 242a15c589 | |||
| 85244a451e |
16
.editorconfig
Normal file
@@ -0,0 +1,16 @@
|
||||
# @see: http://editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*] # 表示所有文件适用
|
||||
charset = utf-8 # 设置文件字符集为 utf-8
|
||||
end_of_line = lf # 控制换行类型(lf | cr | crlf)
|
||||
insert_final_newline = true # 始终在文件末尾插入一个新行
|
||||
indent_style = space # 缩进风格(tab | space)
|
||||
indent_size = 2 # 缩进大小
|
||||
max_line_length = 100 # 最大行长度
|
||||
|
||||
[*.md] # 表示仅 md 文件适用以下规则
|
||||
insert_final_newline = false # 关闭末尾新行插入
|
||||
max_line_length = off # 关闭最大行长度限制
|
||||
trim_trailing_whitespace = false # 关闭末尾空格修剪
|
||||
42
.eslintrc.js
@@ -1,42 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es2020: true,
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"@typescript-eslint/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
],
|
||||
ignorePatterns: ["dist", ".eslintrc.js", "node_modules"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
},
|
||||
plugins: ["react-refresh"],
|
||||
rules: {
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ["packages/**/*.ts", "packages/**/*.tsx"],
|
||||
rules: {
|
||||
"no-console": "warn",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["projects/**/*.ts", "projects/**/*.tsx"],
|
||||
rules: {
|
||||
"no-console": "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
23
README.md
@@ -22,26 +22,13 @@
|
||||
2. 全局根字体大小断点(`src/index.css`)
|
||||
|
||||
```html
|
||||
html { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-size:
|
||||
100%; } /* 小屏幕设备(如手机)*/ @media (max-width: 600px) { html { font-size:
|
||||
90%; /* 字体稍微小一点 */ } } /* 中等屏幕设备(如平板)*/ @media (min-width:
|
||||
601px) and (max-width: 1024px) { html { font-size: 100%; /* 标准大小 */ } } /*
|
||||
大屏幕设备(如桌面)*/ @media (min-width: 1025px) { html { font-size: 110%; /*
|
||||
字体稍微大一点 */ } }
|
||||
html { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-size: 100%; } /* 小屏幕设备(如手机)*/ @media (max-width: 600px) { html { font-size: 90%; /* 字体稍微小一点 */ } } /* 中等屏幕设备(如平板)*/ @media (min-width: 601px) and (max-width: 1024px) { html { font-size: 100%; /* 标准大小 */ } } /* 大屏幕设备(如桌面)*/ @media (min-width: 1025px) { html { font-size: 110%; /* 字体稍微大一点 */ } }
|
||||
```
|
||||
|
||||
3. 组件库全局配色(`src/index.css`)
|
||||
|
||||
```html
|
||||
:root { --primary-color: #FFC300; } :root:root { --adm-color-primary: #FFC300;
|
||||
--adm-color-success: #00b578; --adm-color-warning: #ff8f1f; --adm-color-danger:
|
||||
#ff3141; --adm-color-white: #ffffff; --adm-color-text: #333333;
|
||||
--adm-color-text-secondary: #666666; --adm-color-weak: #999999;
|
||||
--adm-color-light: #cccccc; --adm-color-border: #eeeeee; --adm-color-box:
|
||||
#f5f5f5; --adm-color-background: #ffffff; --adm-font-size-main:
|
||||
var(--adm-font-size-5); --adm-font-family: -apple-system, blinkmacsystemfont,
|
||||
'Helvetica Neue', helvetica, segoe ui, arial, roboto, 'PingFang SC', 'miui',
|
||||
'Hiragino Sans GB', 'Microsoft Yahei', sans-serif; }
|
||||
:root { --primary-color: #FFC300; } :root:root { --adm-color-primary: #FFC300; --adm-color-success: #00b578; --adm-color-warning: #ff8f1f; --adm-color-danger: #ff3141; --adm-color-white: #ffffff; --adm-color-text: #333333; --adm-color-text-secondary: #666666; --adm-color-weak: #999999; --adm-color-light: #cccccc; --adm-color-border: #eeeeee; --adm-color-box: #f5f5f5; --adm-color-background: #ffffff; --adm-font-size-main: var(--adm-font-size-5); --adm-font-family: -apple-system, blinkmacsystemfont, 'Helvetica Neue', helvetica, segoe ui, arial, roboto, 'PingFang SC', 'miui', 'Hiragino Sans GB', 'Microsoft Yahei', sans-serif; }
|
||||
```
|
||||
|
||||
4. 修改语言
|
||||
@@ -99,8 +86,7 @@ export const useFetchXXX = () => {
|
||||
// set the url
|
||||
const url = `/xxx/xxx`;
|
||||
// fetch the data
|
||||
const [{ data, loading, error }, refetch] =
|
||||
useAxios < Result < MockResult >> url;
|
||||
const [{ data, loading, error }, refetch] = useAxios < Result < MockResult >> url;
|
||||
// to do something
|
||||
return { data, loading, error, refetch };
|
||||
};
|
||||
@@ -113,8 +99,7 @@ export const useFetchPageXXX = (page: number, size: number) => {
|
||||
// set the url
|
||||
const url = `/xxx/xxx?page=${page}&size=${size}`;
|
||||
// fetch the data
|
||||
const [{ data, loading, error }, refetch] =
|
||||
useAxios < Page < MockResult >> url;
|
||||
const [{ data, loading, error }, refetch] = useAxios < Page < MockResult >> url;
|
||||
// to do something
|
||||
return { data, loading, error, refetch };
|
||||
};
|
||||
|
||||
111
eslint.config.js
Normal file
@@ -0,0 +1,111 @@
|
||||
// import antfu from "@antfu/eslint-config";
|
||||
|
||||
// export default antfu({
|
||||
// vue: false,
|
||||
// react: true, // @eslint-react/eslint-plugin
|
||||
// typescript: true,
|
||||
// stylistic: true,
|
||||
// formatters: true, // eslint-plugin-format 格式化
|
||||
// rules: {
|
||||
// "react/no-array-index-key": "off",
|
||||
// "react/no-children-to-array": "off",
|
||||
// "jsdoc/check-alignment": "off",
|
||||
// "react-hooks/exhaustive-deps": "off",
|
||||
// },
|
||||
// });
|
||||
// eslint.config.js
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import tsPlugin from "@typescript-eslint/eslint-plugin";
|
||||
|
||||
export default [
|
||||
// 忽略文件
|
||||
{
|
||||
ignores: [
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
"packages/*/dist/**",
|
||||
"projects/*/dist/**",
|
||||
"**/*.config.js",
|
||||
"**/*.config.ts",
|
||||
],
|
||||
},
|
||||
|
||||
// 基础 JavaScript 配置
|
||||
{
|
||||
files: ["**/*.{js,jsx,ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: "module",
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.es2020,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
},
|
||||
},
|
||||
|
||||
// TypeScript 配置
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: "module",
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": tsPlugin,
|
||||
},
|
||||
rules: {
|
||||
...tsPlugin.configs.recommended.rules,
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
},
|
||||
},
|
||||
|
||||
// React 配置
|
||||
{
|
||||
files: ["**/*.{jsx,tsx}"],
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// 项目特定规则
|
||||
{
|
||||
files: ["projects/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"no-console": "off",
|
||||
},
|
||||
},
|
||||
|
||||
// 包特定规则
|
||||
{
|
||||
files: ["packages/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"no-console": "warn",
|
||||
},
|
||||
},
|
||||
];
|
||||
39
index.html
@@ -1,39 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, viewport-fit=cover, initial-scale=1.0"
|
||||
,
|
||||
/>
|
||||
<title>tashow-h5</title>
|
||||
<style>
|
||||
/* 设置CSS变量用于安全区域 */
|
||||
/* 防止页面滚动的全局样式 */
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
35
package.json
@@ -6,7 +6,6 @@
|
||||
"dev:translate-h5": "pnpm --filter translate-h5 dev",
|
||||
"build:translate-h5": "pnpm --filter translate-h5 build",
|
||||
"dev": "vite",
|
||||
"dev:https": "vite --https",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
@@ -16,11 +15,14 @@
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@vitejs/plugin-basic-ssl": "^2.1.0",
|
||||
"@workspace/hooks": "workspace:*",
|
||||
"@workspace/shared": "workspace:*",
|
||||
"@workspace/utils": "workspace:*",
|
||||
"antd-mobile": "^5.33.0",
|
||||
"antd-mobile-icons": "^0.3.0",
|
||||
"axios": "^1.6.2",
|
||||
"axios-hooks": "^5.0.2",
|
||||
"framer-motion": "^12.23.12",
|
||||
"js-audio-recorder": "^1.0.7",
|
||||
"jsqr": "^1.4.0",
|
||||
"less": "^4.2.0",
|
||||
@@ -31,22 +33,35 @@
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-slick": "^0.29.0",
|
||||
"slick-carousel": "^1.8.1",
|
||||
"vconsole": "^3.15.1",
|
||||
"weixin-js-sdk": "^1.6.5",
|
||||
"zustand": "^4.4.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash.isequal": "^4.5.8",
|
||||
"@eslint-react/eslint-plugin": "^1.22.1",
|
||||
"@eslint/js": "^9.15.0",
|
||||
"@react-dev-inspector/vite-plugin": "^2.0.1",
|
||||
"@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",
|
||||
"@typescript-eslint/eslint-plugin": "^8.15.0",
|
||||
"@typescript-eslint/parser": "^8.15.0",
|
||||
"@vitejs/plugin-basic-ssl": "^2.1.0",
|
||||
"@vitejs/plugin-react-swc": "^3.7.2",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.14",
|
||||
"globals": "^15.12.0",
|
||||
"postcss-pxtorem": "^6.0.0",
|
||||
"react-dev-inspector": "^2.0.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.0"
|
||||
"vite": "^6.0.5",
|
||||
"vite-plugin-checker": "^0.8.0"
|
||||
},
|
||||
"simple-git-hooks": {
|
||||
"pre-commit": "npx lint-staged"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*": "eslint --fix"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import useDocumentTitle from "./src/useDocumentTitle";
|
||||
import useSafeNavigate from "./src/useSafeNavigate";
|
||||
export { useDocumentTitle, useSafeNavigate };
|
||||
|
||||
16
packages/hooks/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@workspace/hooks",
|
||||
"version": "1.0.0",
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./index.ts",
|
||||
"types": "./index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"type-check": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import {useState, useEffect} from 'react';
|
||||
import useI18n from "@/hooks/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};
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
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;
|
||||
16
packages/hooks/src/useDocumentTitle.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// hooks/useDocumentTitle.js
|
||||
import { useEffect } from "react";
|
||||
|
||||
const useDocumentTitle = (title: string) => {
|
||||
useEffect(() => {
|
||||
if (title) {
|
||||
document.title = title;
|
||||
}
|
||||
|
||||
// 组件卸载时可以恢复默认标题
|
||||
return () => {
|
||||
document.title = "默认标题"; // 或者从配置中获取
|
||||
};
|
||||
}, [title]);
|
||||
};
|
||||
export default useDocumentTitle;
|
||||
23
packages/hooks/src/useSafeNavigate.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// hooks/useSafeNavigate.ts
|
||||
import { useNavigate, NavigateOptions } from "react-router-dom";
|
||||
|
||||
const useSafeNavigate = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (path: string, options: NavigateOptions = {}) => {
|
||||
const isBaidu = /baiduboxapp|baidubrowser|baidu/i.test(navigator.userAgent.toLowerCase());
|
||||
|
||||
if (isBaidu) {
|
||||
const fullPath = path.startsWith("http") ? path : window.location.origin + path;
|
||||
|
||||
if (options.replace) {
|
||||
window.location.replace(fullPath);
|
||||
} else {
|
||||
window.location.href = fullPath;
|
||||
}
|
||||
} else {
|
||||
navigate(path, options);
|
||||
}
|
||||
};
|
||||
};
|
||||
export default useSafeNavigate;
|
||||
@@ -12,12 +12,5 @@
|
||||
"scripts": {
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"antd-mobile": "^5.34.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
49
packages/shared/src/error-page/errorBoundary/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
// components/ErrorBoundary/index.tsx
|
||||
import { Component, ReactNode } from "react";
|
||||
import { Result, Button } from "antd-mobile";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: any) {
|
||||
console.error("ErrorBoundary caught an error:", error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: "20px", textAlign: "center" }}>
|
||||
<Result status="error" title="页面出错了" description="抱歉,页面出现了错误" />
|
||||
<Button color="primary" onClick={() => window.location.reload()}>
|
||||
刷新页面
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
20
packages/shared/src/error-page/index.module.less
Normal file
@@ -0,0 +1,20 @@
|
||||
// components/ErrorPages/ErrorPages.module.less
|
||||
.errorPage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
|
||||
.button {
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
||||
25
packages/shared/src/error-page/notFound/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
// components/ErrorPages/NotFound.tsx
|
||||
import React from "react";
|
||||
import { Button, Result } from "antd-mobile";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import styles from "../index.module.less";
|
||||
|
||||
const NotFound: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className={styles.errorPage}>
|
||||
<Result status="error" title="页面不存在" description="抱歉,您访问的页面不存在" />
|
||||
<div className={styles.actions}>
|
||||
<Button color="primary" onClick={() => navigate(-1)} className={styles.button}>
|
||||
返回上页
|
||||
</Button>
|
||||
<Button onClick={() => navigate("/")} className={styles.button}>
|
||||
回到首页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
29
packages/shared/src/error-page/serverError/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
// components/ErrorPages/ServerError.tsx
|
||||
import React from "react";
|
||||
import { Button, Result } from "antd-mobile";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import styles from "../index.module.less";
|
||||
|
||||
const ServerError: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleRefresh = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.errorPage}>
|
||||
<Result status="error" title="服务器错误" description="服务器开小差了,请稍后再试" />
|
||||
<div className={styles.actions}>
|
||||
<Button color="primary" onClick={handleRefresh} className={styles.button}>
|
||||
刷新页面
|
||||
</Button>
|
||||
<Button onClick={() => navigate("/")} className={styles.button}>
|
||||
回到首页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerError;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import "./index.less";
|
||||
|
||||
const VoiceIcon = (props: { isPlaying: boolean; onChange?: () => void }) => {
|
||||
@@ -7,10 +7,7 @@ const VoiceIcon = (props: { isPlaying: boolean; onChange?: () => void }) => {
|
||||
props.onChange?.();
|
||||
}, [isPlaying]);
|
||||
return (
|
||||
<div
|
||||
className={`voice-icon ${isPlaying ? "playing" : ""}`}
|
||||
onClick={onChange}
|
||||
>
|
||||
<div className={`voice-icon ${isPlaying ? "playing" : ""}`} onClick={onChange}>
|
||||
<div className="wave wave1"></div>
|
||||
<div className="wave wave2"></div>
|
||||
<div className="wave wave3"></div>
|
||||
|
||||
@@ -12,7 +12,7 @@ function XPopup(props: DefinedProps) {
|
||||
const { visible, title, children, onClose } = props;
|
||||
|
||||
return (
|
||||
<Popup visible={visible} closeOnMaskClick={true} className="xpopup">
|
||||
<Popup visible={visible} closeOnMaskClick={true} className="xpopup" onMaskClick={onClose}>
|
||||
<div className="header">
|
||||
<h3 className="title">{title}</h3>
|
||||
<span className="closeIcon" onClick={onClose}>
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
"typeRoots": ["./types"]
|
||||
}
|
||||
}
|
||||
|
||||
19
packages/shared/types/global.d.ts
vendored
Normal 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";
|
||||
43
packages/styles/variables.less
Normal file
@@ -0,0 +1,43 @@
|
||||
// variables.less
|
||||
// 颜色变量
|
||||
@primary-color: #1890ff;
|
||||
@secondary-color: #6c757d;
|
||||
@success-color: #52c41a;
|
||||
@warning-color: #faad14;
|
||||
@error-color: #f5222d;
|
||||
@white: #ffffff;
|
||||
@black: #000000;
|
||||
@gray-1: #f7f8fa;
|
||||
@gray-2: #ebedf0;
|
||||
@gray-3: #c8c9cc;
|
||||
@gray-4: #969799;
|
||||
@gray-5: #646566;
|
||||
|
||||
// 尺寸变量
|
||||
@border-radius: 6px;
|
||||
@border-radius-sm: 4px;
|
||||
@border-radius-lg: 8px;
|
||||
|
||||
// 动画变量
|
||||
@ease-out: cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
@ease-in: cubic-bezier(0.55, 0.055, 0.675, 0.19);
|
||||
@ease-in-out: cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
|
||||
// 间距变量
|
||||
@spacing-xs: 4px;
|
||||
@spacing-sm: 8px;
|
||||
@spacing-md: 16px;
|
||||
@spacing-lg: 24px;
|
||||
@spacing-xl: 32px;
|
||||
|
||||
// 字体变量
|
||||
@font-size-sm: 12px;
|
||||
@font-size-base: 14px;
|
||||
@font-size-lg: 16px;
|
||||
@font-size-xl: 18px;
|
||||
@font-size-xxl: 20px;
|
||||
|
||||
// 阴影变量
|
||||
@box-shadow-base: 0 2px 8px fade(@black, 15%);
|
||||
@box-shadow-card: 0 1px 2px -2px fade(@black, 16%), 0 3px 6px 0 fade(@black, 12%),
|
||||
0 5px 12px 4px fade(@black, 9%);
|
||||
2824
pnpm-lock.yaml
generated
@@ -1,13 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'postcss-pxtorem': {
|
||||
rootValue: 16,
|
||||
unitPrecision: 5,
|
||||
propList: ['*'],
|
||||
selectorBlackList: [],
|
||||
replace: true,
|
||||
mediaQuery: false,
|
||||
minPixelValue: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -4,49 +4,11 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:https": "vite --https",
|
||||
"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"
|
||||
"browser-id3-writer": "^6.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
14
projects/translate-h5/postcss.config.js
Normal 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转换
|
||||
},
|
||||
},
|
||||
};
|
||||
40
projects/translate-h5/src/api/getDialog.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import useAxios from "axios-hooks";
|
||||
import { Result } from "@/types/http";
|
||||
|
||||
export interface MockResult {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface MockPage {
|
||||
id: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch the data
|
||||
* 详细使用可以查看 useAxios 的文档
|
||||
*/
|
||||
export const useGetDialog = () => {
|
||||
const url = `/app-api/ai/dialog/getDialog`;
|
||||
|
||||
const [{ data, loading, error }, execute] = useAxios<Result<any>>(
|
||||
{
|
||||
url,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
{ manual: true } // 手动触发
|
||||
);
|
||||
|
||||
const getDialog = (params: { pageNo: number; pageSize: number }) => {
|
||||
return execute({
|
||||
params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return { data, loading, error, getDialog };
|
||||
};
|
||||
40
projects/translate-h5/src/api/translate.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import useAxios from "axios-hooks";
|
||||
import { Result } from "@/types/http";
|
||||
|
||||
export interface MockResult {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface MockPage {
|
||||
id: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch the data
|
||||
* 详细使用可以查看 useAxios 的文档
|
||||
*/
|
||||
export const useUploadAudio = () => {
|
||||
const url = `/app-api/ai/dialog/translate`;
|
||||
|
||||
const [{ data, loading, error }, execute] = useAxios<Result<any>>(
|
||||
{
|
||||
url,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
},
|
||||
{ manual: true } // 手动触发
|
||||
);
|
||||
|
||||
const uploadAudio = (formData: FormData) => {
|
||||
return execute({
|
||||
data: formData,
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return { data, loading, error, uploadAudio };
|
||||
};
|
||||
BIN
projects/translate-h5/src/assets/translate/def-avatar.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
projects/translate-h5/src/assets/translate/emotion/emotion-1.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
projects/translate-h5/src/assets/translate/emotion/emotion-2.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
projects/translate-h5/src/assets/translate/emotion/emotion-3.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
projects/translate-h5/src/assets/translate/emotion/emotion-4.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
projects/translate-h5/src/assets/translate/emotion/emotion-5.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
projects/translate-h5/src/assets/translate/emotion/emotion-6.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
projects/translate-h5/src/assets/translate/emotion/emotion-7.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
projects/translate-h5/src/assets/translate/emotion/emotion-8.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
projects/translate-h5/src/assets/translate/emotion/emotion-9.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
projects/translate-h5/src/assets/user/cart.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
7
projects/translate-h5/src/assets/user/header.svg
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
projects/translate-h5/src/assets/user/tool.png
Normal file
|
After Width: | Height: | Size: 137 KiB |
12
projects/translate-h5/src/hooks/i18n.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
63
projects/translate-h5/src/hooks/location.ts
Normal 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 };
|
||||
};
|
||||
27
projects/translate-h5/src/hooks/session.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// import {useState, useEffect} from 'react';
|
||||
|
||||
// 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;
|
||||
@@ -2,8 +2,8 @@ import Axios, {
|
||||
AxiosError,
|
||||
AxiosInstance as AxiosType,
|
||||
AxiosResponse,
|
||||
InternalAxiosRequestConfig
|
||||
} from 'axios';
|
||||
InternalAxiosRequestConfig,
|
||||
} from "axios";
|
||||
import { STORAGE_AUTHORIZE_KEY } from "@/composables/authorization.ts";
|
||||
|
||||
export interface ResponseBody<T = any> {
|
||||
@@ -12,7 +12,9 @@ export interface ResponseBody<T = any> {
|
||||
msg: string;
|
||||
}
|
||||
|
||||
async function requestHandler(config: InternalAxiosRequestConfig): Promise<InternalAxiosRequestConfig> {
|
||||
async function requestHandler(
|
||||
config: InternalAxiosRequestConfig
|
||||
): Promise<InternalAxiosRequestConfig> {
|
||||
const token = localStorage.getItem(STORAGE_AUTHORIZE_KEY);
|
||||
if (token) {
|
||||
config.headers[STORAGE_AUTHORIZE_KEY] = token;
|
||||
@@ -44,6 +46,10 @@ class AxiosInstance {
|
||||
return this.instance;
|
||||
}
|
||||
}
|
||||
|
||||
const baseURL = import.meta.env.VITE_BASE_URL || 'http://127.0.0.1:8080';
|
||||
console.log(import.meta.env.VITE_BASE_URL, "import.meta.env.VITE_BASE_URL");
|
||||
// https://petshy.tashowz.com
|
||||
//192.168.1.231:48080
|
||||
// || "http://192.168.1.231:48080"
|
||||
const baseURL = import.meta.env.VITE_BASE_URL || "https://petshy.tashowz.com";
|
||||
// const baseURL = import.meta.env.VITE_BASE_URL || "http://192.168.1.231:48080";
|
||||
export const axiosInstance = new AxiosInstance(baseURL).getInstance();
|
||||
|
||||
@@ -91,12 +91,13 @@ img {
|
||||
|
||||
--adm-font-size-main: var(--adm-font-size-5); */
|
||||
|
||||
--adm-font-family: -apple-system, blinkmacsystemfont, "Helvetica Neue",
|
||||
helvetica, segoe ui, arial, roboto, "PingFang SC", "miui",
|
||||
"Hiragino Sans GB", "Microsoft Yahei", sans-serif;
|
||||
--adm-font-family: -apple-system, blinkmacsystemfont, "Helvetica Neue", helvetica, segoe ui, arial,
|
||||
roboto, "PingFang SC", "miui", "Hiragino Sans GB", "Microsoft Yahei", sans-serif;
|
||||
}
|
||||
.i-icon {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
svg {
|
||||
height: 100%;
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
.main-layout {
|
||||
height: 100%;
|
||||
.layout-content {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
// padding-bottom: 150px;
|
||||
padding-bottom: constant(safe-area-inset-bottom); /*兼容 IOS<11.2*/
|
||||
padding-bottom: env(safe-area-inset-bottom); /*兼容 IOS>11.2*/
|
||||
}
|
||||
|
||||
.layout-tab {
|
||||
flex: 0 0 auto;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 0 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
43
projects/translate-h5/src/layout/main/index.module.less
Normal file
@@ -0,0 +1,43 @@
|
||||
.main-layout {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.layout-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-bottom: constant(safe-area-inset-bottom); /*兼容 IOS<11.2*/
|
||||
padding-bottom: env(safe-area-inset-bottom); /*兼容 IOS>11.2*/
|
||||
}
|
||||
|
||||
.layout-tab {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
background-color: #fff;
|
||||
:global {
|
||||
.adm-tab-bar {
|
||||
// 去除默认阴影
|
||||
box-shadow: none;
|
||||
background-color: #fff;
|
||||
padding: 8px 16px;
|
||||
|
||||
.adm-tab-bar-item-active {
|
||||
// 添加选中时的背景圆角
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #0000000a;
|
||||
border-radius: 100px;
|
||||
z-index: 99;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import React from "react";
|
||||
import { NavBar, SafeArea, TabBar, Toast } from "antd-mobile";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { User, CattleZodiac } from "@icon-park/react";
|
||||
import "./index.less";
|
||||
import { NavBar, SafeArea, TabBar } from "antd-mobile";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
// import "./index.less";
|
||||
import styles from "./index.module.less";
|
||||
|
||||
import { Translate, Electrocardiogram, GithubOne, Blossom, User } from "@icon-park/react";
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: React.ReactNode;
|
||||
isShowNavBar?: boolean;
|
||||
title?: string;
|
||||
navBarRight?: React.ReactNode;
|
||||
onLink?: () => void;
|
||||
}
|
||||
|
||||
@@ -16,43 +19,53 @@ const MainLayout: React.FC<MainLayoutProps> = ({
|
||||
children,
|
||||
onLink,
|
||||
title,
|
||||
navBarRight,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { pathname } = location;
|
||||
const [activeKey, setActiveKey] = React.useState(pathname);
|
||||
|
||||
const setRouteActive = (value: string) => {
|
||||
if (value !== "/") {
|
||||
Toast.show("待开发");
|
||||
}
|
||||
};
|
||||
|
||||
// const location = useLocation();
|
||||
const [pathname, setPathname] = React.useState(location.pathname);
|
||||
const tabs = [
|
||||
{
|
||||
key: "/",
|
||||
key: "/translate",
|
||||
title: "宠物翻译",
|
||||
icon: <CattleZodiac />,
|
||||
icon: <Translate />,
|
||||
},
|
||||
{
|
||||
key: "/set",
|
||||
title: "待办",
|
||||
icon: <User />,
|
||||
key: "/mood",
|
||||
title: "情绪监控",
|
||||
icon: <Electrocardiogram />,
|
||||
},
|
||||
{
|
||||
key: "/message",
|
||||
title: "消息",
|
||||
icon: <User />,
|
||||
key: "/archives",
|
||||
title: "我的宠物",
|
||||
icon: <GithubOne />,
|
||||
},
|
||||
{
|
||||
key: "/me",
|
||||
title: "我的",
|
||||
key: "/service",
|
||||
title: "宠物服务",
|
||||
icon: <Blossom />,
|
||||
},
|
||||
{
|
||||
key: "/user",
|
||||
title: "个人中心",
|
||||
|
||||
icon: <User size="24" />,
|
||||
icon: <User />,
|
||||
},
|
||||
];
|
||||
|
||||
const showTabBarRoutes = tabs.map((tab) => tab.key);
|
||||
|
||||
const setRouteActive = (value: string) => {
|
||||
console.log(value);
|
||||
|
||||
setPathname(value);
|
||||
navigate(value);
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
// 打印路由栈
|
||||
|
||||
// debugger;
|
||||
if (onLink) {
|
||||
onLink?.();
|
||||
} else {
|
||||
@@ -60,22 +73,25 @@ const MainLayout: React.FC<MainLayoutProps> = ({
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="main-layout">
|
||||
<div className={styles["main-layout"]}>
|
||||
<SafeArea position="top" />
|
||||
{isShowNavBar ? <NavBar onBack={goBack}>{title}</NavBar> : ""}
|
||||
<div className="layout-content">{children}</div>
|
||||
|
||||
<div className="footer layout-tab">
|
||||
{/* <TabBar
|
||||
activeKey={pathname}
|
||||
onChange={(value) => setRouteActive(value)}
|
||||
safeArea={true}
|
||||
>
|
||||
{isShowNavBar ? (
|
||||
<NavBar onBack={goBack} style={{ backgroundColor: "#f5f5f5" }} right={navBarRight}>
|
||||
{title}
|
||||
</NavBar>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<div className={styles["layout-content"]}>{children}</div>
|
||||
{showTabBarRoutes.includes(location.pathname) && (
|
||||
<div className={styles["layout-tab"]}>
|
||||
<TabBar activeKey={pathname} onChange={(value) => setRouteActive(value)} safeArea={true}>
|
||||
{tabs.map((item) => (
|
||||
<TabBar.Item key={item.key} icon={item.icon} title={item.title} />
|
||||
))}
|
||||
</TabBar> */}
|
||||
</TabBar>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "@/view/app/App.tsx";
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import React, {useEffect} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import React, { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface AuthRouteProps {
|
||||
children: React.ReactNode;
|
||||
auth?: boolean;
|
||||
path: string;
|
||||
meta?: {
|
||||
title: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -12,14 +16,32 @@ interface AuthRouteProps {
|
||||
* @param auth 是否需要认证
|
||||
* @constructor 认证路由组件
|
||||
*/
|
||||
const AuthRoute: React.FC<AuthRouteProps> = ({children, auth}) => {
|
||||
const AuthRoute: React.FC<AuthRouteProps> = ({ children, auth, meta }) => {
|
||||
const navigate = useNavigate();
|
||||
const token = localStorage.getItem('token'); // 或者其他认证令牌的获取方式
|
||||
const token = localStorage.getItem("token"); // 或者其他认证令牌的获取方式
|
||||
const isAuthenticated = Boolean(token); // 认证逻辑
|
||||
|
||||
console.log(auth);
|
||||
useEffect(() => {
|
||||
if (meta?.title) {
|
||||
document.title = meta.title;
|
||||
}
|
||||
}, [meta]);
|
||||
useEffect(() => {
|
||||
// 识别微信浏览器
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
console.log(userAgent.includes("micromessenger"));
|
||||
if (userAgent.includes("micromessenger")) {
|
||||
navigate("/wxIndex");
|
||||
} else {
|
||||
// 如果是'/wxIndex'跳转到首页
|
||||
if (location.pathname === "/wxIndex") {
|
||||
navigate("/");
|
||||
}
|
||||
}
|
||||
|
||||
// 检查角色权限
|
||||
if (auth && !isAuthenticated) {
|
||||
navigate('/login'); // 如果未认证且路由需要认证,则重定向到登录
|
||||
navigate("/login"); // 如果未认证且路由需要认证,则重定向到登录
|
||||
}
|
||||
}, [auth, isAuthenticated, navigate]);
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
* 渲染路由
|
||||
@@ -8,12 +10,12 @@ import AuthRoute from './auth.tsx';
|
||||
*/
|
||||
export const RenderRoutes = () => {
|
||||
const renderRoutes = (routes: AppRoute[]) => {
|
||||
return routes.map(route => (
|
||||
return routes.map((route) => (
|
||||
<Route
|
||||
key={route.path}
|
||||
path={route.path}
|
||||
element={
|
||||
<AuthRoute auth={route.auth}>
|
||||
<AuthRoute auth={route.auth} path={route.path} meta={route.meta}>
|
||||
{route.element}
|
||||
</AuthRoute>
|
||||
}
|
||||
@@ -23,5 +25,24 @@ export const RenderRoutes = () => {
|
||||
));
|
||||
};
|
||||
|
||||
return <Routes>{renderRoutes(routes)}</Routes>;
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "24px",
|
||||
}}
|
||||
>
|
||||
<DotLoading />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Routes>{renderRoutes(routes)}</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,19 +1,122 @@
|
||||
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"));
|
||||
const TranslateMood = lazy(() => import("@/view/mood"));
|
||||
const TranslateArchives = lazy(() => import("@/view/archives"));
|
||||
const TranslateArchivesAdd = lazy(() => import("@/view/addArchives"));
|
||||
const Service = lazy(() => import("@/view/service"));
|
||||
const ServiceDetail = lazy(() => import("@/view/serviceDetail"));
|
||||
const User = lazy(() => import("@/view/user"));
|
||||
const Order = lazy(() => import("@/view/order"));
|
||||
const OrderDetail = lazy(() => import("@/view/orderDetail"));
|
||||
const Payment = lazy(() => import("@/view/payment"));
|
||||
const Result = lazy(() => import("@/view/result"));
|
||||
const WXIndex = lazy(() => import("@/view/wxIndex/index"));
|
||||
|
||||
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: "/wxIndex",
|
||||
element: <WXIndex />,
|
||||
auth: false,
|
||||
meta: {
|
||||
title: "微信跳转",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/translate",
|
||||
element: <Home />,
|
||||
auth: false,
|
||||
meta: {
|
||||
title: "宠物翻译",
|
||||
},
|
||||
},
|
||||
{ path: "/translate/detail", element: <TranslateDetail />, auth: false },
|
||||
{
|
||||
path: "/mood",
|
||||
element: <TranslateMood />,
|
||||
auth: false,
|
||||
meta: {
|
||||
title: "情绪监控",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/archives",
|
||||
element: <TranslateArchives />,
|
||||
auth: false,
|
||||
meta: {
|
||||
title: "宠物档案",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/addArchives",
|
||||
element: <TranslateArchivesAdd />,
|
||||
auth: false,
|
||||
meta: {
|
||||
title: "新建档案",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/service",
|
||||
element: <Service />,
|
||||
auth: false,
|
||||
meta: {
|
||||
title: "宠物服务",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/service/detail",
|
||||
element: <ServiceDetail />,
|
||||
auth: false,
|
||||
meta: {
|
||||
title: "服务详情",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/user",
|
||||
element: <User />,
|
||||
auth: false,
|
||||
meta: {
|
||||
title: "个人中心",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/order",
|
||||
element: <Order />,
|
||||
auth: false,
|
||||
meta: {
|
||||
title: "我的订单",
|
||||
},
|
||||
},
|
||||
|
||||
{ path: "/order/detail", element: <OrderDetail />, auth: false, meta: { title: "订单详情" } },
|
||||
{
|
||||
path: "/payment",
|
||||
element: <Payment />,
|
||||
auth: false,
|
||||
meta: {
|
||||
title: "选择服务",
|
||||
},
|
||||
},
|
||||
{ path: "/result", element: <Result />, auth: false, meta: { title: "支付结果" } },
|
||||
{ path: "*", element: <Page404 />, auth: false },
|
||||
];
|
||||
|
||||
19
projects/translate-h5/src/types/global.d.ts
vendored
Normal 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";
|
||||
94
projects/translate-h5/src/view/addArchives/index.module.less
Normal file
@@ -0,0 +1,94 @@
|
||||
.archives {
|
||||
background-color: #f5f5f5;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
|
||||
.archivesInfo {
|
||||
border-radius: 16px;
|
||||
// 超出隐藏
|
||||
overflow: hidden;
|
||||
background-color: #fff;
|
||||
.archivesFrom {
|
||||
padding: 16px;
|
||||
padding-top: 0;
|
||||
.archivesFromItem {
|
||||
height: 56px;
|
||||
line-height: 56px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-bottom: 0.5px solid #f5f5f5;
|
||||
|
||||
.archivesFromInput {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #9c9c9c;
|
||||
.archivesTag {
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
font-size: 12px;
|
||||
border-radius: 12px;
|
||||
padding: 0 10px;
|
||||
background-color: #f5f5f5;
|
||||
color: #1f1f1f;
|
||||
margin-left: 8px;
|
||||
&.active {
|
||||
background-color: #000;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
&.editInput {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
.saveButton {
|
||||
--background-color: #000;
|
||||
--border-color: #000;
|
||||
--text-color: #fff;
|
||||
height: 40px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.archivesAvatar {
|
||||
width: 100%;
|
||||
height: 128px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.popupHeader {
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.popupBottom {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
// 设置中间间隔
|
||||
.popupButton {
|
||||
flex: 1;
|
||||
margin-right: 22px;
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
.cancelButton {
|
||||
--background-color: #fff;
|
||||
--border-color: #e9e9e9;
|
||||
--text-color: #000000;
|
||||
}
|
||||
.confirmButton {
|
||||
--background-color: #1c1c1c;
|
||||
--border-color: #1c1c1c;
|
||||
--text-color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
189
projects/translate-h5/src/view/addArchives/index.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import MainLayout from "@/layout/main/mainLayout";
|
||||
import styles from "./index.module.less";
|
||||
import { useState } from "react";
|
||||
import { Image, Button, Popup, Space } from "antd-mobile";
|
||||
import { CloseOutline } from "antd-mobile-icons";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Delete } from "@icon-park/react";
|
||||
|
||||
interface DefinedProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
showBottom?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function MyPopup(props: DefinedProps) {
|
||||
const { visible, title, children, onClose, showBottom = false } = props;
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
closeOnMaskClick={true}
|
||||
className={styles.xpopup}
|
||||
onMaskClick={onClose}
|
||||
>
|
||||
<div className={styles.popupHeader}>
|
||||
<h3>{title}</h3>
|
||||
<span onClick={onClose}>
|
||||
<CloseOutline style={{ fontSize: "16px" }} />
|
||||
</span>
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
|
||||
{showBottom && (
|
||||
<div className={styles.popupBottom}>
|
||||
<div className={styles.popupButton}>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
block
|
||||
size="large"
|
||||
shape="rounded"
|
||||
className={styles.cancelButton}
|
||||
>
|
||||
取 消
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.popupButton}>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
block
|
||||
size="large"
|
||||
shape="rounded"
|
||||
className={styles.confirmButton}
|
||||
>
|
||||
确 定
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
function Add() {
|
||||
const archivesAvatarPng = "http://qiniu.bydj.tashowz.com/assets/translate/archives-avatar.png";
|
||||
const catDogPng = "http://qiniu.bydj.tashowz.com/assets/translate/cat-dog.png";
|
||||
const archivesVarietyPng = "http://qiniu.bydj.tashowz.com/assets/translate/archives-variety.png";
|
||||
const archivesDatePng = "http://qiniu.bydj.tashowz.com/assets/translate/archives-date.png";
|
||||
const archivesWeightPng = "http://qiniu.bydj.tashowz.com/assets/translate/archives-weight.png";
|
||||
|
||||
const [visibleVariety, setVisibleVariety] = useState<boolean>(false);
|
||||
const [visibleWeight, setVisibleWeight] = useState<boolean>(false);
|
||||
const [visibleDate, setVisibleDate] = useState<boolean>(false);
|
||||
const [searchParams] = useSearchParams();
|
||||
const flag = searchParams.get("flag");
|
||||
|
||||
const right = (
|
||||
<div style={{ fontSize: 20 }}>
|
||||
<Space style={{ "--gap": "20px" }}>
|
||||
<Delete fill="#FF4D4F" />
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<MainLayout
|
||||
navBarRight={flag === "edit" && right}
|
||||
isShowNavBar={true}
|
||||
title={flag === "add" ? "添加档案" : "编辑档案"}
|
||||
>
|
||||
<div className={styles.archives}>
|
||||
<div className={styles.archivesAvatar}>
|
||||
<Image src={archivesAvatarPng} width={80} height={80} />
|
||||
</div>
|
||||
<div className={styles.archivesInfo}>
|
||||
{flag === "add" && <Image src={catDogPng} />}
|
||||
|
||||
<div className={styles.archivesFrom}>
|
||||
<div className={styles.archivesFromItem}>
|
||||
<div>昵称*</div>
|
||||
<div className={`${styles.archivesFromInput} ${flag === "edit" && styles.editInput}`}>
|
||||
{flag === "add" ? "请输入昵称" : "钱多多"}
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={() => setVisibleVariety(true)} className={styles.archivesFromItem}>
|
||||
<div>品种*</div>
|
||||
<div className={`${styles.archivesFromInput} ${flag === "edit" && styles.editInput}`}>
|
||||
{flag === "add" ? "请选择" : "布偶猫"}
|
||||
{">"}
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={() => setVisibleWeight(true)} className={styles.archivesFromItem}>
|
||||
<div>体重*</div>
|
||||
<div className={`${styles.archivesFromInput} ${flag === "edit" && styles.editInput}`}>
|
||||
{flag === "add" ? "请选择" : "45kg"}
|
||||
{">"}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.archivesFromItem}>
|
||||
<div>性别*</div>
|
||||
<div className={styles.archivesFromInput}>
|
||||
<div className={styles.archivesTag}>未知</div>
|
||||
<div className={styles.archivesTag}>弟弟</div>
|
||||
<div className={`${styles.archivesTag} ${flag === "edit" && styles.active}`}>
|
||||
妹妹
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={() => setVisibleDate(true)} className={styles.archivesFromItem}>
|
||||
<div>出生日期*</div>
|
||||
<div className={`${styles.archivesFromInput} ${flag === "edit" && styles.editInput}`}>
|
||||
{flag === "add" ? "请选择" : "2018-12-01"}
|
||||
{">"}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.archivesFromItem}>
|
||||
<div>绝育情况*</div>
|
||||
<div className={styles.archivesFromInput}>
|
||||
<div className={styles.archivesTag}>未知</div>
|
||||
<div className={styles.archivesTag}>已绝育</div>
|
||||
<div className={`${styles.archivesTag} ${flag === "edit" && styles.active}`}>
|
||||
未绝育
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.archivesFromItem}>
|
||||
<Button block shape="rounded" className={styles.saveButton}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MyPopup
|
||||
title="选择品种"
|
||||
visible={visibleVariety}
|
||||
onClose={() => {
|
||||
setVisibleVariety(false);
|
||||
}}
|
||||
>
|
||||
<Image src={archivesVarietyPng}></Image>
|
||||
</MyPopup>
|
||||
|
||||
<MyPopup
|
||||
title="您宝贝的体重是?"
|
||||
visible={visibleWeight}
|
||||
onClose={() => {
|
||||
setVisibleWeight(false);
|
||||
}}
|
||||
showBottom={true}
|
||||
>
|
||||
<Image src={archivesWeightPng}></Image>
|
||||
</MyPopup>
|
||||
<MyPopup
|
||||
title="选择日期"
|
||||
visible={visibleDate}
|
||||
onClose={() => {
|
||||
setVisibleDate(false);
|
||||
}}
|
||||
showBottom={true}
|
||||
>
|
||||
<Image src={archivesDatePng}></Image>
|
||||
</MyPopup>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default Add;
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { RenderRoutes } from "@/route/render-routes.tsx";
|
||||
import { axiosInstance } from "@/http/axios-instance.ts";
|
||||
import { configure } from "axios-hooks";
|
||||
@@ -12,6 +11,10 @@ function App() {
|
||||
axios: axiosInstance,
|
||||
});
|
||||
const i18nStore = useI18nStore();
|
||||
// 持久化一个messageList的本地化数据
|
||||
console.log("degfmessageList");
|
||||
|
||||
localStorage.setItem("messageList", JSON.stringify([]));
|
||||
return (
|
||||
<>
|
||||
<ConfigProvider locale={i18nStore.lang === "en_US" ? enUS : zhCN}>
|
||||
|
||||
43
projects/translate-h5/src/view/archives/index.module.less
Normal file
@@ -0,0 +1,43 @@
|
||||
.archives {
|
||||
background-color: #f5f5f5;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
.img {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.placeholder-bottom {
|
||||
height: 100px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.homeHeader {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
background: #f5f5f5;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 99;
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom {
|
||||
position: fixed;
|
||||
bottom: 60px;
|
||||
opacity: 0.98;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.archivesAvatar {
|
||||
width: 100%;
|
||||
height: 128px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -1,11 +1,95 @@
|
||||
// 档案
|
||||
import MainLayout from "@/layout/main/mainLayout";
|
||||
import "./index.less";
|
||||
import styles from "./index.module.less";
|
||||
// import archivesPicturePng from "@/assets/translate/archives-picture.png";
|
||||
// import archivesDataPng from "@/assets/translate/archives-data.png";
|
||||
// import archivesEquipmentPng from "@/assets/translate/archives-equipment.png";
|
||||
// import archivesSwPng from "@/assets/translate/archives-sw.png";
|
||||
// import archivesFunctionPng from "@/assets/translate/archives-function.png";
|
||||
// import archivesBottomPng from "@/assets/translate/archives-bottom.png";
|
||||
// import archivesCardPng from "@/assets/translate/archives-card.png";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Outlet } from "react-router-dom"; // 添加这行
|
||||
|
||||
import { Image, Space } from "antd-mobile";
|
||||
import { ListView, More, AddOne, AlignHorizontalCenterTwo } from "@icon-park/react";
|
||||
function Index() {
|
||||
const archivesPicturePng = "http://qiniu.bydj.tashowz.com/assets/translate/archives-picture.png";
|
||||
const archivesDataPng = "http://qiniu.bydj.tashowz.com/assets/translate/archives-data.png";
|
||||
const archivesEquipmentPng =
|
||||
"http://qiniu.bydj.tashowz.com/assets/translate/archives-equipment.png";
|
||||
const archivesSwPng = "http://qiniu.bydj.tashowz.com/assets/translate/archives-sw.png";
|
||||
const archivesFunctionPng =
|
||||
"http://qiniu.bydj.tashowz.com/assets/translate/archives-function.png";
|
||||
const archivesBottomPng = "http://qiniu.bydj.tashowz.com/assets/translate/archives-bottom.png";
|
||||
const archivesCardPng = "http://qiniu.bydj.tashowz.com/assets/translate/archives-card.png";
|
||||
|
||||
const [listShow, setListShow] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onLink = (link: string) => {
|
||||
navigate(link);
|
||||
};
|
||||
|
||||
// const right = (
|
||||
// <div style={{ fontSize: 16 }}>
|
||||
// <Space style={{ "--gap": "16px" }}>
|
||||
// {listShow ? (
|
||||
// <AlignHorizontalCenterTwo onClick={() => setListShow(false)} fill="#333" />
|
||||
// ) : (
|
||||
// <ListView onClick={() => setListShow(true)} fill="#333" />
|
||||
// )}
|
||||
|
||||
// <AddOne onClick={() => onLink("/translate/addArchives?flag=add")} fill="#333" />
|
||||
// <More fill="#333" />
|
||||
// </Space>
|
||||
// </div>
|
||||
// );
|
||||
|
||||
return (
|
||||
<MainLayout isShowNavBar={true}>
|
||||
<div className="archives"></div>
|
||||
<MainLayout>
|
||||
<div className={styles.homeHeader}>
|
||||
<h3>我的宠物</h3>
|
||||
<Space style={{ fontSize: "20px", "--gap": "16px" }}>
|
||||
{listShow ? (
|
||||
<AlignHorizontalCenterTwo onClick={() => setListShow(false)} fill="#333" />
|
||||
) : (
|
||||
<ListView onClick={() => setListShow(true)} fill="#333" />
|
||||
)}
|
||||
|
||||
<AddOne onClick={() => onLink("/addArchives?flag=add")} fill="#333" />
|
||||
<More fill="#333" />
|
||||
</Space>
|
||||
</div>
|
||||
{listShow ? (
|
||||
<div className={styles.archives}>
|
||||
<Image className={styles.img} src={archivesCardPng}></Image>
|
||||
<Image className={styles.img} src={archivesCardPng}></Image>
|
||||
<Image className={styles.img} src={archivesCardPng}></Image>
|
||||
<Image className={styles.img} src={archivesCardPng}></Image>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.bottom}>
|
||||
<Image src={archivesBottomPng}></Image>
|
||||
</div>
|
||||
<div className={styles.archives}>
|
||||
<Image src={archivesPicturePng}></Image>
|
||||
<Image
|
||||
onClick={() => onLink("/addArchives?flag=edit")}
|
||||
className={styles.img}
|
||||
src={archivesDataPng}
|
||||
></Image>
|
||||
<Image className={styles.img} src={archivesEquipmentPng}></Image>
|
||||
<Image className={styles.img} src={archivesSwPng}></Image>
|
||||
<Image className={styles.img} src={archivesFunctionPng}></Image>
|
||||
<div className={styles["placeholder-bottom"]}></div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Outlet /> {/* 添加这行 */}
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
.message {
|
||||
flex: 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
.item {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 40px;
|
||||
background: #eee;
|
||||
}
|
||||
.voice-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: 8px;
|
||||
margin-top: 8px;
|
||||
padding-right: 12px;
|
||||
.time {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
.tips {
|
||||
// align-self: stretch;
|
||||
// display: grid;
|
||||
// align-items: center;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
}
|
||||
.translate {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border-radius: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Divider, Image, SpinLoading, Toast } from "antd-mobile";
|
||||
import { VoiceIcon } from "@workspace/shared";
|
||||
import dogSvg from "@/assets/translate/dog.svg";
|
||||
import catSvg from "@/assets/translate/cat.svg";
|
||||
import pigSvg from "@/assets/translate/pig.svg";
|
||||
import { Message } from "../../types";
|
||||
import "./index.less";
|
||||
|
||||
interface DefinedProps {
|
||||
data: Message[];
|
||||
isRecording: boolean;
|
||||
}
|
||||
|
||||
function Index(props: DefinedProps) {
|
||||
const { data, isRecording } = props;
|
||||
const audioRefs = useRef<{ [key: string]: HTMLAudioElement }>({});
|
||||
const [isPlaying, setIsPlating] = useState(false);
|
||||
const [currentPlayingId, setCurrentPlayingId] = useState<number>();
|
||||
|
||||
useEffect(() => {
|
||||
if (isRecording) {
|
||||
stopAllAudio();
|
||||
}
|
||||
}, [isRecording]);
|
||||
const onVoiceChange = () => {
|
||||
setIsPlating(!isPlaying);
|
||||
};
|
||||
const playAudio = (messageId: number, audioUrl: string) => {
|
||||
if (isRecording) {
|
||||
Toast.show("录音中,无法播放音频");
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPlayingId === messageId) {
|
||||
if (audioRefs.current[messageId]) {
|
||||
audioRefs.current[messageId].pause();
|
||||
audioRefs.current[messageId].currentTime = 0;
|
||||
}
|
||||
setCurrentPlayingId(undefined);
|
||||
setIsPlating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
stopAllAudio();
|
||||
if (!audioRefs.current[messageId]) {
|
||||
audioRefs.current[messageId] = new Audio(audioUrl);
|
||||
}
|
||||
|
||||
const audio = audioRefs.current[messageId];
|
||||
audio.currentTime = 0;
|
||||
|
||||
audio.onended = () => {
|
||||
setCurrentPlayingId(undefined);
|
||||
setIsPlating(false);
|
||||
};
|
||||
|
||||
audio.onerror = (error) => {
|
||||
console.error("音频播放错误:", error);
|
||||
Toast.show("音频播放失败");
|
||||
setIsPlating(false);
|
||||
};
|
||||
|
||||
audio
|
||||
.play()
|
||||
.then(() => {
|
||||
setCurrentPlayingId(messageId);
|
||||
setIsPlating(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("音频播放失败:", error);
|
||||
Toast.show("音频播放失败");
|
||||
});
|
||||
};
|
||||
const stopAllAudio = () => {
|
||||
if (currentPlayingId && audioRefs.current[currentPlayingId]) {
|
||||
audioRefs.current[currentPlayingId].pause();
|
||||
audioRefs.current[currentPlayingId].currentTime = 0;
|
||||
setIsPlating(false);
|
||||
setCurrentPlayingId(undefined);
|
||||
}
|
||||
|
||||
Object.values(audioRefs.current).forEach((audio) => {
|
||||
if (!audio.paused) {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
}
|
||||
});
|
||||
};
|
||||
const renderAvatar = (type?: "pig" | "cat" | "dog") => {
|
||||
if (type === "pig") {
|
||||
<Image
|
||||
src={pigSvg}
|
||||
width={40}
|
||||
height={40}
|
||||
fit="cover"
|
||||
style={{ borderRadius: 32 }}
|
||||
/>;
|
||||
}
|
||||
if (type === "cat") {
|
||||
return (
|
||||
<Image
|
||||
src={catSvg}
|
||||
width={40}
|
||||
height={40}
|
||||
fit="cover"
|
||||
style={{ borderRadius: 32 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Image
|
||||
src={dogSvg}
|
||||
width={40}
|
||||
height={40}
|
||||
fit="cover"
|
||||
style={{ borderRadius: 32 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="message">
|
||||
{data.map((item, index) => (
|
||||
<div
|
||||
className="item"
|
||||
key={index}
|
||||
onClick={() => playAudio(item.id, item.audioUrl)}
|
||||
>
|
||||
{renderAvatar(item.type)}
|
||||
<div className="rig">
|
||||
<div>
|
||||
<span className="name">{item.name}</span>
|
||||
<Divider direction="vertical" style={{ margin: "0px 8px" }} />
|
||||
<span className="">{item.timestamp}</span>
|
||||
</div>
|
||||
<div className="voice-container">
|
||||
<VoiceIcon
|
||||
onChange={onVoiceChange}
|
||||
isPlaying={isPlaying && currentPlayingId === item.id}
|
||||
/>
|
||||
<div className="time">{item.duration}''</div>
|
||||
</div>
|
||||
{item.isTranslating ? (
|
||||
<div className="translate">
|
||||
<SpinLoading color="default" style={{ "--size": "12px" }} />
|
||||
<span>翻译中...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="translate">{item.translatedText}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="item">
|
||||
<div className="avatar"></div>
|
||||
<div className="rig">
|
||||
<div>
|
||||
<span className="name">生无可恋喵</span>
|
||||
<Divider direction="vertical" style={{ margin: "0px 8px" }} />
|
||||
<span className="">15:00</span>
|
||||
</div>
|
||||
<div className="voice-container">
|
||||
<VoiceIcon isPlaying={false} />
|
||||
<div className="tips">
|
||||
{isRecording ? "录制中..." : "轻点麦克风录制"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Index;
|
||||
@@ -1,87 +0,0 @@
|
||||
import { DownOne } from "@icon-park/react";
|
||||
import {
|
||||
ActionSheet,
|
||||
Dropdown,
|
||||
type DropdownRef,
|
||||
Popup,
|
||||
Radio,
|
||||
SearchBar,
|
||||
Space,
|
||||
} from "antd-mobile";
|
||||
import { RadioValue } from "antd-mobile/es/components/radio";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
interface PropsConfig {
|
||||
handleAllAni: () => void;
|
||||
}
|
||||
const allAni = ["全部宠物", "丑丑", "胖胖", "可可"];
|
||||
function Index(props: PropsConfig) {
|
||||
const [aniName, setAniName] = useState<string>("全部宠物");
|
||||
const animenuRef = useRef<DropdownRef>(null);
|
||||
const handleAniSelect = (val: RadioValue) => {
|
||||
setAniName(allAni[val as number]);
|
||||
animenuRef.current?.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="search">
|
||||
<SearchBar
|
||||
placeholder="请输入翻译内容"
|
||||
style={{
|
||||
"--border-radius": "6px",
|
||||
"--height": "32px",
|
||||
"--padding-left": "12px",
|
||||
}}
|
||||
/>
|
||||
<Dropdown className="all" ref={animenuRef}>
|
||||
<Dropdown.Item key="ani" title={aniName}>
|
||||
<div style={{ padding: 12 }}>
|
||||
<Radio.Group defaultValue="default" onChange={handleAniSelect}>
|
||||
<Space direction="vertical" block>
|
||||
<Radio block value="0">
|
||||
全部宠物
|
||||
</Radio>
|
||||
<Radio block value="1">
|
||||
丑丑
|
||||
</Radio>
|
||||
<Radio block value="2">
|
||||
胖胖
|
||||
</Radio>
|
||||
<Radio block value="3">
|
||||
可可
|
||||
</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
</Dropdown>
|
||||
{/* <div className="all" onClick={handleAllAni}>
|
||||
<span>全部宠物</span>
|
||||
<DownOne
|
||||
theme="filled"
|
||||
size="16"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="butt"
|
||||
/>
|
||||
</div>
|
||||
<ActionSheet
|
||||
visible={visible}
|
||||
actions={actions}
|
||||
onClose={() => setVisible(false)}
|
||||
/> */}
|
||||
{/* <Popup
|
||||
visible={visible}
|
||||
onClose={() => {
|
||||
setVisible(false);
|
||||
}}
|
||||
>
|
||||
<div className="popup-head">
|
||||
<span>宠物</span>
|
||||
</div>
|
||||
11111
|
||||
</Popup> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Index;
|
||||
@@ -1,54 +0,0 @@
|
||||
.voice-record {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
padding: 12px 0px;
|
||||
box-shadow: 1px 2px 4px 3px #eee;
|
||||
.adm-progress-circle-info {
|
||||
height: 32px;
|
||||
}
|
||||
.isRecording {
|
||||
.adm-progress-circle {
|
||||
border-radius: 50%;
|
||||
animation: recordingButtonPulse 1s infinite;
|
||||
}
|
||||
}
|
||||
.tips {
|
||||
color: #9f9f9f;
|
||||
}
|
||||
.circle {
|
||||
display: inline-block;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: rgba(22, 119, 255, 1);
|
||||
}
|
||||
|
||||
.cancleBtn {
|
||||
position: absolute;
|
||||
border: 2px solid rgba(230, 244, 255, 1);
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
right: 60px;
|
||||
border-radius: 50%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
@keyframes recordingButtonPulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(22, 119, 255, 0.5);
|
||||
}
|
||||
30% {
|
||||
box-shadow: 0 0 0 14px rgba(255, 77, 79, 0);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 14px rgba(255, 77, 79, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(22, 119, 255, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AudioRecorder, useAudioRecorder } from "react-audio-voice-recorder";
|
||||
import { Button, Dialog, Image, ProgressCircle, Toast } from "antd-mobile";
|
||||
import microphoneSvg from "@/assets/translate/microphone.svg";
|
||||
import microphoneDisabledSvg from "@/assets/translate/microphoneDisabledSvg.svg";
|
||||
import { createStartRecordSound, createSendSound } from "@/utils/voice";
|
||||
import "./index.less";
|
||||
import { Message } from "../../types";
|
||||
import { CloseCircleOutline } from "antd-mobile-icons";
|
||||
|
||||
interface DefinedProps {
|
||||
onRecordingComplete: (url: string, finalDuration: number) => void;
|
||||
isRecording: boolean;
|
||||
onSetIsRecording: (flag: boolean) => void;
|
||||
}
|
||||
function Index(props: DefinedProps) {
|
||||
const { isRecording } = props;
|
||||
const [hasPermission, setHasPermission] = useState<boolean>(false); //是否有权限
|
||||
const [isPermissioning, setIsPermissioning] = useState<boolean>(true); //获取权限中
|
||||
const [recordingDuration, setRecordingDuration] = useState<number>(0); //录音时长进度
|
||||
const [isModal, setIsModal] = useState<boolean>(false);
|
||||
const recordingTimerRef = useRef<NodeJS.Timeout>();
|
||||
const isCancelledRef = useRef(false);
|
||||
const recordingStartTimeRef = useRef<number>(0); //录音时长
|
||||
// 音效相关
|
||||
const sendSoundRef = useRef<HTMLAudioElement | null>(null);
|
||||
const startRecordSoundRef = useRef<HTMLAudioElement | null>(null);
|
||||
useEffect(() => {
|
||||
initializeSounds();
|
||||
checkMicrophonePermission();
|
||||
}, [hasPermission]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRecording) {
|
||||
recorderControls.startRecording();
|
||||
} else {
|
||||
}
|
||||
}, [isRecording]);
|
||||
|
||||
//重置状态
|
||||
const onResetRecordingState = () => {
|
||||
props.onSetIsRecording(false);
|
||||
setRecordingDuration(0);
|
||||
recordingStartTimeRef.current = 0;
|
||||
if (recordingTimerRef.current) {
|
||||
clearInterval(recordingTimerRef.current);
|
||||
recordingTimerRef.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const initializeSounds = () => {
|
||||
try {
|
||||
// 发送音效 - 使用Web Audio API生成
|
||||
sendSoundRef.current = createSendSound();
|
||||
|
||||
// 开始录音音效
|
||||
startRecordSoundRef.current = createStartRecordSound();
|
||||
|
||||
console.log("音效初始化完成");
|
||||
} catch (error) {
|
||||
console.error("音效初始化失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const renderBtn = useCallback(() => {
|
||||
if (!hasPermission) {
|
||||
//没有权限
|
||||
return (
|
||||
<>
|
||||
<Image height={80} width={80} src={microphoneDisabledSvg} />
|
||||
{isPermissioning ? (
|
||||
<div className="tips">获取麦克风权限中...</div>
|
||||
) : (
|
||||
<div className="tips">麦克风没有开启权限</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (isRecording) {
|
||||
//正在录音中
|
||||
return (
|
||||
<div onClick={onStopRecording} className="isRecording">
|
||||
<ProgressCircle
|
||||
percent={recordingDuration}
|
||||
style={{ "--size": "80px" }}
|
||||
>
|
||||
<div className="recording-dot">
|
||||
<span className="circle"></span>
|
||||
</div>
|
||||
</ProgressCircle>
|
||||
<Button fill="none" onClick={cancelRecording} className="cancleBtn">
|
||||
取消录制
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
//麦克风状态
|
||||
return (
|
||||
<Image
|
||||
height={80}
|
||||
width={80}
|
||||
src={microphoneSvg}
|
||||
onClick={onStartRecording}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [hasPermission, isRecording, recordingDuration]);
|
||||
const checkMicrophonePermission = useCallback(async () => {
|
||||
try {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
setHasPermission(false);
|
||||
setIsPermissioning(false);
|
||||
Toast.show("浏览器不支持录音功能");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (navigator.permissions && navigator.permissions.query) {
|
||||
try {
|
||||
const permissionStatus = await navigator.permissions.query({
|
||||
name: "microphone" as PermissionName,
|
||||
});
|
||||
|
||||
if (permissionStatus.state === "denied") {
|
||||
setHasPermission(false);
|
||||
setIsPermissioning(false);
|
||||
setIsModal(true);
|
||||
|
||||
return false;
|
||||
}
|
||||
if (permissionStatus.state === "granted") {
|
||||
setHasPermission(true);
|
||||
setIsModal(false);
|
||||
return true;
|
||||
}
|
||||
} catch (permError) {
|
||||
console.log("权限查询不支持,继续使用getUserMedia检查");
|
||||
}
|
||||
}
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
});
|
||||
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
setHasPermission(true);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error.message.includes("user denied permission")) {
|
||||
setIsModal(true);
|
||||
}
|
||||
|
||||
setHasPermission(false);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isModal) {
|
||||
Dialog.confirm({
|
||||
content: "重新获取麦克风权限",
|
||||
onConfirm: async () => {
|
||||
setIsModal(true);
|
||||
await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
});
|
||||
setHasPermission(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [isModal]);
|
||||
|
||||
const recorderControls = useAudioRecorder(
|
||||
{
|
||||
noiseSuppression: true,
|
||||
echoCancellation: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
(err) => {
|
||||
console.error("录音错误:", err);
|
||||
Toast.show("录音失败,请重试");
|
||||
onResetRecordingState();
|
||||
}
|
||||
);
|
||||
|
||||
// 播放音效
|
||||
const playSound = async (soundRef: React.RefObject<HTMLAudioElement>) => {
|
||||
try {
|
||||
if (soundRef.current) {
|
||||
await soundRef.current.play();
|
||||
}
|
||||
} catch (error: any) {
|
||||
Toast.show(`播放音效失败:${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
//开始录音
|
||||
const onStartRecording = () => {
|
||||
isCancelledRef.current = false;
|
||||
if (recordingTimerRef.current) {
|
||||
clearInterval(recordingTimerRef.current);
|
||||
recordingTimerRef.current = undefined;
|
||||
}
|
||||
props.onSetIsRecording(true);
|
||||
// recorderControls.startRecording();
|
||||
recordingStartTimeRef.current = Date.now();
|
||||
// 立即开始计时
|
||||
recordingTimerRef.current = setInterval(() => {
|
||||
setRecordingDuration((prev) => prev + 1);
|
||||
}, 1000);
|
||||
};
|
||||
const onStopRecording = useCallback(() => {
|
||||
recorderControls.stopRecording();
|
||||
onResetRecordingState();
|
||||
}, [recorderControls, recordingDuration]);
|
||||
//录音完成
|
||||
// 在发送时检查录音时长
|
||||
const onRecordingComplete = useCallback(
|
||||
(blob: Blob) => {
|
||||
if (isCancelledRef.current) {
|
||||
Toast.show("已取消");
|
||||
return;
|
||||
}
|
||||
// 检查blob有效性
|
||||
if (!blob || blob.size === 0) {
|
||||
Toast.show("录音数据无效,请重新录音");
|
||||
|
||||
return;
|
||||
}
|
||||
const audioUrl = URL.createObjectURL(blob);
|
||||
const audio = new Audio();
|
||||
audio.src = audioUrl;
|
||||
// 计算实际录音时长
|
||||
|
||||
audio.addEventListener("loadedmetadata", () => {
|
||||
if (audio.duration < 1) {
|
||||
Toast.show("录音时间太短,请重新录音");
|
||||
return;
|
||||
}
|
||||
alert(audio.duration);
|
||||
playSound(sendSoundRef);
|
||||
props.onRecordingComplete?.(audioUrl, Math.floor(audio.duration));
|
||||
});
|
||||
},
|
||||
[isCancelledRef, isRecording, sendSoundRef]
|
||||
);
|
||||
|
||||
const cancelRecording = useCallback(() => {
|
||||
isCancelledRef.current = true;
|
||||
recorderControls.stopRecording();
|
||||
onResetRecordingState();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AudioRecorder
|
||||
onRecordingComplete={onRecordingComplete}
|
||||
recorderControls={recorderControls}
|
||||
audioTrackConstraints={{
|
||||
noiseSuppression: true,
|
||||
echoCancellation: true,
|
||||
autoGainControl: true,
|
||||
}}
|
||||
showVisualizer={false}
|
||||
/>
|
||||
<div className={` voice-record`}>{renderBtn()}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Index);
|
||||
@@ -1,39 +1,59 @@
|
||||
.home {
|
||||
height: 100%;
|
||||
width: 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;
|
||||
.home-header {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
background: #fff;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.adm-tabs-tab {
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
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 {
|
||||
// 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;
|
||||
// font-size: 20px;
|
||||
// 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 {
|
||||
padding: 0px 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,55 @@
|
||||
import MainLayout from "@/layout/main/mainLayout";
|
||||
import { Button, Tabs } from "antd-mobile";
|
||||
import Translate from "./translate";
|
||||
import { useState } from "react";
|
||||
import { Divider, Space } from "antd-mobile";
|
||||
import Translate from "./translate/index";
|
||||
import { Filter, HamburgerButton } from "@icon-park/react";
|
||||
|
||||
// import { useSafeNavigate } from "@workspace/hooks";
|
||||
import "./index.less";
|
||||
|
||||
function Index() {
|
||||
const handleRecordComplete = (audioData: AudioData): void => {
|
||||
console.log("录音完成:", audioData);
|
||||
};
|
||||
|
||||
const handleError = (error: Error): void => {
|
||||
console.error("录音错误:", error);
|
||||
};
|
||||
const [visible, setVisible] = useState<boolean>(false);
|
||||
// const safeNavigate = useSafeNavigate();
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<div className="home">
|
||||
<Tabs stretch={false}>
|
||||
<Tabs.Tab title="宠物翻译" key="1">
|
||||
<Translate />
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab title="宠物档案" key="2">
|
||||
2
|
||||
</Tabs.Tab>
|
||||
</Tabs>
|
||||
<div className="home-header">
|
||||
<h3>宠物翻译</h3>
|
||||
|
||||
<Space style={{ fontSize: "20px", "--gap": "16px" }}>
|
||||
<Filter
|
||||
theme="outline"
|
||||
fill={visible ? "#118fff" : "#333"}
|
||||
strokeWidth={3}
|
||||
strokeLinecap="butt"
|
||||
onClick={() => setVisible(!visible)}
|
||||
/>
|
||||
<Divider direction="vertical" />
|
||||
{/* <Electrocardiogram
|
||||
theme="outline"
|
||||
fill="#333"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="butt"
|
||||
onClick={() => onLink("/translate/mood")}
|
||||
/>
|
||||
<GithubOne
|
||||
theme="outline"
|
||||
fill="#333"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="butt"
|
||||
onClick={() => onLink("/translate/archives")}
|
||||
/> */}
|
||||
<HamburgerButton
|
||||
theme="outline"
|
||||
size="24"
|
||||
fill="#333"
|
||||
strokeWidth={3}
|
||||
strokeLinecap="butt"
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
<Translate searchVisible={visible} />
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
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 type { Message } from "./types";
|
||||
|
||||
import { mockTranslateAudio } from "@/utils/voice";
|
||||
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";
|
||||
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"
|
||||
/>
|
||||
),
|
||||
type: "add",
|
||||
},
|
||||
];
|
||||
function Index(props: DefinedProps) {
|
||||
// const data: Message[] = [
|
||||
// {
|
||||
// id: 1,
|
||||
// audioUrl: "",
|
||||
// duration: 0,
|
||||
// translatedText: "",
|
||||
// isTranslating: true,
|
||||
// isPlaying: false,
|
||||
// timestamp: 0,
|
||||
// },
|
||||
// ];
|
||||
const [currentPlayingId, setCurrentPlayingId] = useState<string | null>(null); //当前播放id
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isRecording, setIsRecording] = useState(false); //是否录音中
|
||||
const [currentLanguage, setCurrentLanguage] = useState<FloatMenuItemConfig>();
|
||||
const [visible, setVisible] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentLanguage(menuItems[0]);
|
||||
}, []);
|
||||
|
||||
//完成录音
|
||||
const onRecordingComplete = useCallback(
|
||||
(audioUrl: string, actualDuration: number) => {
|
||||
const newMessage: Message = {
|
||||
id: Date.now(),
|
||||
type: "dog",
|
||||
audioUrl,
|
||||
name: "生无可恋喵",
|
||||
duration: actualDuration,
|
||||
timestamp: Date.now(),
|
||||
isTranslating: true,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, newMessage]);
|
||||
setTimeout(() => {
|
||||
onTranslateAudio(newMessage.id);
|
||||
}, 1000);
|
||||
|
||||
Toast.show("语音已发送");
|
||||
},
|
||||
[messages]
|
||||
);
|
||||
|
||||
//翻译
|
||||
const onTranslateAudio = useCallback(
|
||||
async (messageId: number) => {
|
||||
try {
|
||||
const translatedText = await mockTranslateAudio();
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === messageId
|
||||
? { ...msg, translatedText, isTranslating: false }
|
||||
: msg
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("翻译失败:", error);
|
||||
Toast.show("翻译失败,请重试");
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === messageId
|
||||
? {
|
||||
...msg,
|
||||
isTranslating: false,
|
||||
translatedText: "翻译失败,请重试",
|
||||
}
|
||||
: msg
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
[messages]
|
||||
);
|
||||
|
||||
const onSetIsRecording = (flag: boolean) => {
|
||||
setIsRecording(flag);
|
||||
};
|
||||
|
||||
const onLanguage = (item: FloatMenuItemConfig) => {
|
||||
if (item.type === "add") {
|
||||
setVisible(true);
|
||||
} else {
|
||||
setCurrentLanguage(item);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="translate-container">
|
||||
<MessageCom data={messages} isRecording={isRecording}></MessageCom>
|
||||
<VoiceRecord
|
||||
onRecordingComplete={onRecordingComplete}
|
||||
isRecording={isRecording}
|
||||
onSetIsRecording={onSetIsRecording}
|
||||
/>
|
||||
<FloatingMenu
|
||||
menuItems={menuItems}
|
||||
value={currentLanguage}
|
||||
onChange={onLanguage}
|
||||
/>
|
||||
<XPopup
|
||||
title="选择翻译语种"
|
||||
visible={visible}
|
||||
onClose={() => {
|
||||
setVisible(false);
|
||||
}}
|
||||
>
|
||||
<div className="card">
|
||||
<span>快捷选项</span>
|
||||
<div></div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<span>宠物语种</span>
|
||||
</div>
|
||||
</XPopup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Index;
|
||||
@@ -2,6 +2,12 @@
|
||||
flex: 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
.message-title {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.item {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 12px;
|
||||
@@ -24,6 +30,7 @@
|
||||
border-radius: 8px;
|
||||
margin-top: 8px;
|
||||
padding-right: 12px;
|
||||
width: 100px;
|
||||
.time {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
@@ -35,12 +42,23 @@
|
||||
}
|
||||
}
|
||||
.translate {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
color: #000000e0;
|
||||
border-radius: 8px;
|
||||
margin-top: 12px;
|
||||
max-width: 100%;
|
||||
font-size: 14px;
|
||||
// 超出换行
|
||||
white-space: normal;
|
||||
.expression {
|
||||
position: absolute;
|
||||
right: -25px;
|
||||
top: -30px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Avatar, Divider, Space, SpinLoading, Toast, Image } from "antd-mobile";
|
||||
import { VoiceIcon } from "@workspace/shared";
|
||||
import { Message } from "../../../types";
|
||||
import "./index.less";
|
||||
import { DigitalWatches, Refresh } from "@icon-park/react";
|
||||
import DefAvatar from "@/assets/translate/def-avatar.png";
|
||||
import Emotion1Png from "@/assets/translate/emotion/emotion-1.png";
|
||||
import Emotion2Png from "@/assets/translate/emotion/emotion-2.png";
|
||||
import Emotion3Png from "@/assets/translate/emotion/emotion-3.png";
|
||||
import Emotion4Png from "@/assets/translate/emotion/emotion-4.png";
|
||||
import Emotion5Png from "@/assets/translate/emotion/emotion-5.png";
|
||||
import Emotion6Png from "@/assets/translate/emotion/emotion-6.png";
|
||||
import Emotion7Png from "@/assets/translate/emotion/emotion-7.png";
|
||||
import Emotion8Png from "@/assets/translate/emotion/emotion-8.png";
|
||||
import Emotion9Png from "@/assets/translate/emotion/emotion-9.png";
|
||||
import React from "react";
|
||||
|
||||
interface DefinedProps {
|
||||
data: Message[];
|
||||
isRecording: boolean;
|
||||
onRefresh: (formData: FormData, messageId: number) => void;
|
||||
}
|
||||
|
||||
function Index(props: DefinedProps) {
|
||||
const { data, isRecording } = props;
|
||||
const audioRefs = useRef<{ [key: string]: HTMLAudioElement }>({});
|
||||
const [currentPlayingId, setCurrentPlayingId] = useState<number>();
|
||||
const emotionList = [
|
||||
Emotion1Png,
|
||||
Emotion4Png,
|
||||
Emotion7Png,
|
||||
Emotion2Png,
|
||||
Emotion5Png,
|
||||
Emotion8Png,
|
||||
Emotion3Png,
|
||||
Emotion6Png,
|
||||
Emotion9Png,
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (isRecording && currentPlayingId) {
|
||||
audioRefs.current[currentPlayingId].pause();
|
||||
audioRefs.current[currentPlayingId].currentTime = 0;
|
||||
setCurrentPlayingId(undefined);
|
||||
}
|
||||
}, [isRecording, currentPlayingId]);
|
||||
|
||||
const playAudio = async (id: number, audioUrl: string) => {
|
||||
if (isRecording) {
|
||||
Toast.show("录音中,无法播放音频");
|
||||
return;
|
||||
}
|
||||
if (currentPlayingId && audioRefs.current[currentPlayingId]) {
|
||||
audioRefs.current[currentPlayingId].pause();
|
||||
audioRefs.current[currentPlayingId].currentTime = 0;
|
||||
}
|
||||
if (currentPlayingId !== id) {
|
||||
console.log(id);
|
||||
|
||||
setCurrentPlayingId(id);
|
||||
// console.log(audioUrl, "audioUrl");
|
||||
|
||||
// const response = await fetch(audioUrl);
|
||||
// console.log("response", response);
|
||||
|
||||
// const arrayBuffer = await response.arrayBuffer();
|
||||
// console.log("arrayBuffer", arrayBuffer);
|
||||
|
||||
// // 创建ID3Writer实例并移除ID3标签
|
||||
// const writer = new ID3Writer(new Uint8Array(arrayBuffer));
|
||||
// writer.removeTag(); // 移除 ID3 标签
|
||||
|
||||
// writer.addTag(); // 必须调用 addTag 才能生成带标签的音频数据
|
||||
// const blob = writer.getBlob(); // 使用 getBlob 获取正确的 Blob 数据
|
||||
|
||||
// const url = URL.createObjectURL(blob);
|
||||
|
||||
// console.log("url", url);
|
||||
|
||||
audioRefs.current[id] = new Audio(audioUrl);
|
||||
audioRefs.current[id].play();
|
||||
audioRefs.current[id].onended = () => {
|
||||
setCurrentPlayingId(undefined);
|
||||
};
|
||||
} else {
|
||||
audioRefs.current[id].pause();
|
||||
audioRefs.current[id].currentTime = 0;
|
||||
setCurrentPlayingId(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const renderAvatar = (item: Message) => {
|
||||
return (
|
||||
<Avatar
|
||||
src={item.petAvatar || DefAvatar}
|
||||
style={{ "--border-radius": "32px", flexShrink: "0" }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const refreshMessage = async (item: Message, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
console.log("item", item);
|
||||
|
||||
if (!item.file || !item.dialogId || !item.contentText) {
|
||||
Toast.show("文件不存在,无法重新翻译");
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("file", item.file);
|
||||
formData.append("dialogId", String(item.dialogId));
|
||||
formData.append("contentDuration", String(item.contentDuration));
|
||||
|
||||
props.onRefresh(formData, item.id);
|
||||
};
|
||||
|
||||
const renderTranslateResult = (item: Message) => {
|
||||
if (item.isTranslating) {
|
||||
return (
|
||||
<div className="translate">
|
||||
<SpinLoading color="default" style={{ "--size": "12px" }} />
|
||||
<span>翻译中...</span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
if (item.transStatus === 1) {
|
||||
return item.transResult?.length ? (
|
||||
<div className="translate" style={{ verticalAlign: "middle", width: "240px" }}>
|
||||
<span>{item.transResult}</span>
|
||||
<div className="expression">
|
||||
{item.emotion !== undefined &&
|
||||
item.emotion > 0 &&
|
||||
item.emotion <= emotionList.length && (
|
||||
<Image width={"40px"} height={"40px"} src={emotionList[item.emotion - 1]} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Space justify={"between"} className="translate" style={{ verticalAlign: "middle" }}>
|
||||
<span>未知语句</span>
|
||||
<Refresh onClick={(e) => refreshMessage(item, e)} size="12" fill="#333" />
|
||||
</Space>
|
||||
);
|
||||
} else {
|
||||
console.log("item.transResult", item);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="translate"
|
||||
style={{ verticalAlign: "middle" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<span>{item.transResult}</span>
|
||||
<Refresh onClick={(e) => refreshMessage(item, e)} size="12" fill="#333" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="message">
|
||||
{data.map((item, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{!(item.isTranslating && !item.isRefresh) && (
|
||||
<div className="message-title">
|
||||
<Space
|
||||
style={{
|
||||
fontSize: "10px",
|
||||
color: "#BFBFBF",
|
||||
"--gap": "8px",
|
||||
}}
|
||||
>
|
||||
<div>{item.createTime}</div>
|
||||
<Divider style={{ margin: "0px" }} direction="vertical" />
|
||||
<DigitalWatches />
|
||||
<div>{item.terminal ?? "来自App"}</div>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="item" key={index}>
|
||||
{renderAvatar(item)}
|
||||
<div className="rig">
|
||||
<div>
|
||||
<span className="name">
|
||||
{item.isTranslating && !item.isRefresh ? "" : item.petName ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="voice-container" onClick={() => playAudio(item.id, item.contentText)}>
|
||||
<VoiceIcon
|
||||
// onChange={onVoiceChange}
|
||||
isPlaying={currentPlayingId === item.id}
|
||||
/>
|
||||
<div className="time">{item.contentDuration}''</div>
|
||||
</div>
|
||||
{renderTranslateResult(item)}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div style={{ height: "130px", width: "100%" }}></div>
|
||||
....
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Index;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Calendar, Dropdown, type DropdownRef, Radio, SearchBar, Space } from "antd-mobile";
|
||||
import { RadioValue } from "antd-mobile/es/components/radio";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
interface PropsConfig {
|
||||
handleAllAni: () => void;
|
||||
value?: string;
|
||||
}
|
||||
const allAni = ["全部宠物", "丑丑", "胖胖", "可可"];
|
||||
function SearchCom(props: PropsConfig) {
|
||||
const { value } = props;
|
||||
const [aniName, setAniName] = useState<string>("全部宠物");
|
||||
const animenuRef = useRef<DropdownRef>(null);
|
||||
const handleAniSelect = (val: RadioValue) => {
|
||||
setAniName(allAni[val as number]);
|
||||
animenuRef.current?.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="search">
|
||||
<SearchBar
|
||||
placeholder="请输入翻译内容"
|
||||
style={{
|
||||
"--border-radius": "6px",
|
||||
"--height": "32px",
|
||||
"--padding-left": "12px",
|
||||
}}
|
||||
defaultValue={value}
|
||||
/>
|
||||
<Dropdown key="ani" className="all" ref={animenuRef}>
|
||||
<Dropdown.Item key="ani" title={aniName}>
|
||||
<div style={{ padding: 12 }}>
|
||||
<Radio.Group defaultValue="default" onChange={handleAniSelect}>
|
||||
<Space direction="vertical" block>
|
||||
<Radio block value="0">
|
||||
全部宠物
|
||||
</Radio>
|
||||
<Radio block value="1">
|
||||
丑丑
|
||||
</Radio>
|
||||
<Radio block value="2">
|
||||
胖胖
|
||||
</Radio>
|
||||
<Radio block value="3">
|
||||
可可
|
||||
</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item key="time" title="时间 ">
|
||||
<div style={{ padding: 12 }}>
|
||||
<Calendar
|
||||
prevMonthButton={<span>上一月</span>}
|
||||
nextMonthButton={<span>下一月</span>}
|
||||
prevYearButton={<span>上一年</span>}
|
||||
nextYearButton={<span>下一年</span>}
|
||||
/>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item key="device" title="设备 ">
|
||||
<div style={{ padding: 12 }}>
|
||||
<Radio.Group defaultValue="default">
|
||||
<Space direction="vertical" block>
|
||||
<Radio block value="0">
|
||||
AXR智能语音项圈
|
||||
</Radio>
|
||||
<Radio block value="1">
|
||||
XSWL宠物手机
|
||||
</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item key="mood" title="情绪">
|
||||
<div style={{ padding: 12 }}>
|
||||
<Radio.Group defaultValue="default">
|
||||
<Space direction="vertical" block>
|
||||
<Radio block value="0">
|
||||
兴奋/玩耍
|
||||
</Radio>
|
||||
<Radio block value="1">
|
||||
愉快/满足
|
||||
</Radio>
|
||||
<Radio block value="2">
|
||||
问候/好奇
|
||||
</Radio>
|
||||
<Radio block value="3">
|
||||
平静/放松
|
||||
</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchCom;
|
||||
@@ -1,11 +1,19 @@
|
||||
.voice-record {
|
||||
position: relative;
|
||||
position: fixed;
|
||||
bottom: 60px;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
padding: 12px 0px;
|
||||
box-shadow: 1px 2px 4px 3px #eee;
|
||||
// box-shadow: 1px 2px 4px 3px #eee;
|
||||
// 不被挤压
|
||||
flex-shrink: 0;
|
||||
min-height: 100px; /* 添加 min-height 防止被压缩 */
|
||||
height: 100px; /* 保持原始高度 */
|
||||
flex-basis: 100px; /* 明确指定基础大小,防止 flex 缩放影响 */
|
||||
.adm-progress-circle-info {
|
||||
height: 32px;
|
||||
}
|
||||
@@ -1,20 +1,19 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AudioRecorder, useAudioRecorder } from "react-audio-voice-recorder";
|
||||
import { Button, Dialog, Image, ProgressCircle, Toast } from "antd-mobile";
|
||||
import microphoneSvg from "@/assets/translate/microphone.svg";
|
||||
import microphoneDisabledSvg from "@/assets/translate/microphoneDisabledSvg.svg";
|
||||
import { createStartRecordSound, createSendSound } from "@/utils/voice";
|
||||
import VConsole from "vconsole";
|
||||
import "./index.less";
|
||||
import { Message } from "../../types";
|
||||
import { CloseCircleOutline } from "antd-mobile-icons";
|
||||
|
||||
interface DefinedProps {
|
||||
onRecordingComplete: (url: string, finalDuration: number) => void;
|
||||
onRecordingComplete: (url: string, finalDuration: number, formData: FormData) => void;
|
||||
isRecording: boolean;
|
||||
onSetIsRecording: (flag: boolean) => void;
|
||||
dialogId: number;
|
||||
}
|
||||
function Index(props: DefinedProps) {
|
||||
const { isRecording } = props;
|
||||
const { isRecording, dialogId } = props;
|
||||
const [hasPermission, setHasPermission] = useState<boolean>(false); //是否有权限
|
||||
const [isPermissioning, setIsPermissioning] = useState<boolean>(true); //获取权限中
|
||||
const [recordingDuration, setRecordingDuration] = useState<number>(0); //录音时长进度
|
||||
@@ -25,17 +24,23 @@ function Index(props: DefinedProps) {
|
||||
// 音效相关
|
||||
const sendSoundRef = useRef<HTMLAudioElement | null>(null);
|
||||
const startRecordSoundRef = useRef<HTMLAudioElement | null>(null);
|
||||
useEffect(() => {
|
||||
// 只在开发环境启用
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
new VConsole();
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
initializeSounds();
|
||||
checkMicrophonePermission();
|
||||
}, [hasPermission]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRecording) {
|
||||
recorderControls.startRecording();
|
||||
} else {
|
||||
}
|
||||
}, [isRecording]);
|
||||
// useEffect(() => {
|
||||
// if (isRecording) {
|
||||
// recorderControls.startRecording();
|
||||
// } else {
|
||||
// }
|
||||
// }, [isRecording]);
|
||||
|
||||
//重置状态
|
||||
const onResetRecordingState = () => {
|
||||
@@ -61,8 +66,35 @@ function Index(props: DefinedProps) {
|
||||
console.error("音效初始化失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const renderBtn = useCallback(() => {
|
||||
//开始录音
|
||||
const onStartRecording = () => {
|
||||
isCancelledRef.current = false;
|
||||
if (recordingTimerRef.current) {
|
||||
clearInterval(recordingTimerRef.current);
|
||||
recordingTimerRef.current = undefined;
|
||||
}
|
||||
props.onSetIsRecording(true);
|
||||
recorderControls.startRecording();
|
||||
recordingStartTimeRef.current = Date.now();
|
||||
// 立即开始计时
|
||||
recordingTimerRef.current = setInterval(() => {
|
||||
setRecordingDuration((prev) => prev + 1);
|
||||
}, 1000);
|
||||
};
|
||||
// 使用 useMemo 缓存 Image 组件
|
||||
const MicrophoneImage = useMemo(
|
||||
() => (
|
||||
<Image
|
||||
height={80}
|
||||
width={80}
|
||||
src={microphoneSvg}
|
||||
onClick={onStartRecording}
|
||||
placeholder={<div style={{ width: 80, height: 80 }} />} // 添加占位符
|
||||
/>
|
||||
),
|
||||
[microphoneSvg, onStartRecording]
|
||||
);
|
||||
const renderBtn = () => {
|
||||
if (!hasPermission) {
|
||||
//没有权限
|
||||
return (
|
||||
@@ -80,10 +112,7 @@ function Index(props: DefinedProps) {
|
||||
//正在录音中
|
||||
return (
|
||||
<div onClick={onStopRecording} className="isRecording">
|
||||
<ProgressCircle
|
||||
percent={recordingDuration}
|
||||
style={{ "--size": "80px" }}
|
||||
>
|
||||
<ProgressCircle percent={recordingDuration} style={{ "--size": "80px" }}>
|
||||
<div className="recording-dot">
|
||||
<span className="circle"></span>
|
||||
</div>
|
||||
@@ -94,17 +123,9 @@ function Index(props: DefinedProps) {
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
//麦克风状态
|
||||
return (
|
||||
<Image
|
||||
height={80}
|
||||
width={80}
|
||||
src={microphoneSvg}
|
||||
onClick={onStartRecording}
|
||||
/>
|
||||
);
|
||||
return MicrophoneImage;
|
||||
}
|
||||
}, [hasPermission, isRecording, recordingDuration]);
|
||||
};
|
||||
const checkMicrophonePermission = useCallback(async () => {
|
||||
try {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
@@ -200,53 +221,113 @@ function Index(props: DefinedProps) {
|
||||
}
|
||||
};
|
||||
|
||||
//开始录音
|
||||
const onStartRecording = () => {
|
||||
isCancelledRef.current = false;
|
||||
if (recordingTimerRef.current) {
|
||||
clearInterval(recordingTimerRef.current);
|
||||
recordingTimerRef.current = undefined;
|
||||
}
|
||||
props.onSetIsRecording(true);
|
||||
// recorderControls.startRecording();
|
||||
recordingStartTimeRef.current = Date.now();
|
||||
// 立即开始计时
|
||||
recordingTimerRef.current = setInterval(() => {
|
||||
setRecordingDuration((prev) => prev + 1);
|
||||
}, 1000);
|
||||
// 添加音频转换函数
|
||||
const convertToWav = async (blob: Blob): Promise<Blob> => {
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
|
||||
// 转换为 WAV 格式
|
||||
const wavBuffer = audioBufferToWav(audioBuffer);
|
||||
return new Blob([wavBuffer], { type: "audio/wav" });
|
||||
};
|
||||
|
||||
// WAV 转换辅助函数
|
||||
const audioBufferToWav = (buffer: AudioBuffer): ArrayBuffer => {
|
||||
const length = buffer.length;
|
||||
const numberOfChannels = buffer.numberOfChannels;
|
||||
const sampleRate = buffer.sampleRate;
|
||||
const bitsPerSample = 16;
|
||||
const byteRate = (sampleRate * numberOfChannels * bitsPerSample) / 8;
|
||||
const blockAlign = (numberOfChannels * bitsPerSample) / 8;
|
||||
const dataSize = length * numberOfChannels * (bitsPerSample / 8);
|
||||
const bufferLength = 44 + dataSize;
|
||||
const arrayBuffer = new ArrayBuffer(bufferLength);
|
||||
const view = new DataView(arrayBuffer);
|
||||
// RIFF identifier
|
||||
writeString(view, 0, "RIFF");
|
||||
// file length
|
||||
view.setUint32(4, 36 + dataSize, true);
|
||||
// RIFF type
|
||||
writeString(view, 8, "WAVE");
|
||||
// format chunk identifier
|
||||
writeString(view, 12, "fmt ");
|
||||
// format chunk length
|
||||
view.setUint32(16, 16, true);
|
||||
// sample format (raw)
|
||||
view.setUint16(20, 1, true);
|
||||
// channel count
|
||||
view.setUint16(22, numberOfChannels, true);
|
||||
// sample rate
|
||||
view.setUint32(24, sampleRate, true);
|
||||
// byte rate (sample rate * block align)
|
||||
view.setUint32(28, byteRate, true);
|
||||
// block align (channel count * bytes per sample)
|
||||
view.setUint16(32, blockAlign, true);
|
||||
// bits per sample
|
||||
view.setUint16(34, bitsPerSample, true);
|
||||
// data chunk identifier
|
||||
writeString(view, 36, "data");
|
||||
// data chunk length
|
||||
view.setUint32(40, dataSize, true);
|
||||
|
||||
// write the PCM samples
|
||||
let offset = 44;
|
||||
for (let i = 0; i < length; i++) {
|
||||
for (let channel = 0; channel < numberOfChannels; channel++) {
|
||||
const sample = Math.max(-1, Math.min(1, buffer.getChannelData(channel)[i]));
|
||||
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
|
||||
offset += 2;
|
||||
}
|
||||
}
|
||||
return arrayBuffer;
|
||||
};
|
||||
|
||||
const writeString = (view: DataView, offset: number, string: string) => {
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
};
|
||||
|
||||
const onStopRecording = useCallback(() => {
|
||||
recorderControls.stopRecording();
|
||||
onResetRecordingState();
|
||||
}, [recorderControls, recordingDuration]);
|
||||
|
||||
//录音完成
|
||||
// 在发送时检查录音时长
|
||||
const onRecordingComplete = useCallback(
|
||||
(blob: Blob) => {
|
||||
async (blob: Blob) => {
|
||||
if (isCancelledRef.current) {
|
||||
Toast.show("已取消");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查blob有效性
|
||||
if (!blob || blob.size === 0) {
|
||||
Toast.show("录音数据无效,请重新录音");
|
||||
|
||||
return;
|
||||
}
|
||||
const audioUrl = URL.createObjectURL(blob);
|
||||
const audio = new Audio();
|
||||
audio.src = audioUrl;
|
||||
// 计算实际录音时长
|
||||
|
||||
audio.addEventListener("loadedmetadata", () => {
|
||||
if (audio.duration < 1) {
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
const accurateDuration = audioBuffer.duration;
|
||||
if (accurateDuration < 1) {
|
||||
Toast.show("录音时间太短,请重新录音");
|
||||
return;
|
||||
}
|
||||
alert(audio.duration);
|
||||
// 转换为 WAV 格式以获得最佳兼容性
|
||||
const wavBlob = await convertToWav(blob);
|
||||
const formData: FormData = new FormData();
|
||||
formData.append("file", wavBlob, new Date().getTime() + ".wav");
|
||||
formData.append("dialogId", `${dialogId}`);
|
||||
const audioUrl = URL.createObjectURL(blob);
|
||||
const audio = new Audio();
|
||||
audio.src = audioUrl;
|
||||
playSound(sendSoundRef);
|
||||
props.onRecordingComplete?.(audioUrl, Math.floor(audio.duration));
|
||||
});
|
||||
const contentDuration = Math.round(accurateDuration);
|
||||
formData.append("contentDuration", `${contentDuration}`);
|
||||
props.onRecordingComplete?.(audioUrl, contentDuration, formData);
|
||||
},
|
||||
[isCancelledRef, isRecording, sendSoundRef]
|
||||
);
|
||||
259
projects/translate-h5/src/view/home/translate/index.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Image, Toast } from "antd-mobile";
|
||||
import MessageCom from "./component/message";
|
||||
import VoiceRecord from "./component/voice";
|
||||
import { XPopup, type FloatMenuItemConfig } from "@workspace/shared";
|
||||
import type { Message } from "../types";
|
||||
import { useGetDialog } from "@/api/getDialog";
|
||||
import { useUploadAudio } from "@/api/translate";
|
||||
|
||||
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 {
|
||||
searchVisible: boolean;
|
||||
}
|
||||
|
||||
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" />,
|
||||
type: "add",
|
||||
},
|
||||
];
|
||||
function Index(props: DefinedProps) {
|
||||
const { searchVisible } = props;
|
||||
const { loading: _uploadLoading, error: _uploadError, getDialog } = useGetDialog();
|
||||
const { loading: _audioLoading, error: _audioError, uploadAudio } = useUploadAudio();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isRecording, setIsRecording] = useState(false); //是否录音中
|
||||
const [_currentLanguage, setCurrentLanguage] = useState<FloatMenuItemConfig>();
|
||||
const [visible, setVisible] = useState<boolean>(false);
|
||||
const [dialogId, setDialogId] = useState<number>(0);
|
||||
|
||||
// 创建稳定化的消息字符串(排除 isTranslating 字段变化的影响)
|
||||
const stableMessagesString = useMemo(() => {
|
||||
const stableMessages = messages.map((msg) => {
|
||||
const { isTranslating, isRefresh, ...rest } = msg;
|
||||
return rest;
|
||||
});
|
||||
return JSON.stringify(stableMessages);
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentLanguage(menuItems[0]);
|
||||
fetchInitialMessages();
|
||||
}, []);
|
||||
|
||||
// 滚动到底部
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const container = document.querySelector(".message");
|
||||
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
console.log("container", container.scrollHeight);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 添加初始化数据的逻辑
|
||||
const fetchInitialMessages = async () => {
|
||||
try {
|
||||
const messages = JSON.parse(localStorage.getItem("messageList") ?? "[]");
|
||||
console.log("messages", messages);
|
||||
|
||||
// 这里替换为实际的API调用
|
||||
// const response = await fetch('/api/messages');
|
||||
const response = await getDialog({
|
||||
pageNo: 1,
|
||||
pageSize: 99,
|
||||
});
|
||||
const initialMessages: Message[] = response.data?.data?.messages.list || [];
|
||||
|
||||
setDialogId(response.data?.data?.dialogId);
|
||||
if (messages.length > 0) {
|
||||
setMessages(messages);
|
||||
return;
|
||||
}
|
||||
setMessages(initialMessages);
|
||||
localStorage.setItem("messageList", JSON.stringify(initialMessages));
|
||||
} catch (error) {
|
||||
console.error("获取初始化数据失败:", error);
|
||||
Toast.show("获取消息失败");
|
||||
// 失败时设置为空数组
|
||||
setMessages([]);
|
||||
localStorage.setItem("messageList", JSON.stringify([]));
|
||||
}
|
||||
};
|
||||
|
||||
// 监听消息变化,自动滚动到底部
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
scrollToBottom();
|
||||
});
|
||||
}
|
||||
}, [stableMessagesString, scrollToBottom]);
|
||||
|
||||
//完成录音
|
||||
const onRecordingComplete = useCallback(
|
||||
async (audioUrl: string, actualDuration: number, formData: FormData) => {
|
||||
console.log(audioUrl, "audioUrl");
|
||||
const newMessage: Message = {
|
||||
id: Date.now(),
|
||||
contentText: audioUrl,
|
||||
contentDuration: actualDuration,
|
||||
isTranslating: true,
|
||||
terminal: "",
|
||||
file: formData.get("file") as Blob,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, newMessage]);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
scrollToBottom();
|
||||
});
|
||||
await onTranslateAudio(formData, newMessage.id);
|
||||
requestAnimationFrame(() => {
|
||||
scrollToBottom();
|
||||
});
|
||||
Toast.show("语音已发送");
|
||||
},
|
||||
[messages]
|
||||
);
|
||||
|
||||
//翻译
|
||||
const onTranslateAudio = useCallback(
|
||||
async (formData: FormData, id: number) => {
|
||||
try {
|
||||
const response = await uploadAudio(formData);
|
||||
const translatedData = response.data;
|
||||
|
||||
if (translatedData.data.transStatus) {
|
||||
setMessages((prev) => {
|
||||
const newMessages = prev.map((msg) =>
|
||||
msg.id === id
|
||||
? {
|
||||
...msg,
|
||||
...translatedData.data,
|
||||
id: id,
|
||||
isTranslating: false,
|
||||
isRefresh: false,
|
||||
}
|
||||
: msg
|
||||
);
|
||||
localStorage.setItem("messageList", JSON.stringify(newMessages));
|
||||
return newMessages;
|
||||
});
|
||||
} else {
|
||||
console.log(translatedData);
|
||||
|
||||
setMessages((prev) => {
|
||||
const newMessages = prev.map((msg) =>
|
||||
msg.id === id
|
||||
? {
|
||||
...msg,
|
||||
...translatedData.data,
|
||||
id: id,
|
||||
isTranslating: false,
|
||||
isRefresh: false,
|
||||
}
|
||||
: msg
|
||||
);
|
||||
localStorage.setItem("messageList", JSON.stringify(newMessages));
|
||||
return newMessages;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("翻译失败:", error);
|
||||
Toast.show("翻译失败,请重试");
|
||||
setMessages((prev) => {
|
||||
const newMessages = prev.map((msg) =>
|
||||
msg.id === id
|
||||
? {
|
||||
...msg,
|
||||
id: id,
|
||||
isTranslating: false,
|
||||
isRefresh: false,
|
||||
}
|
||||
: msg
|
||||
);
|
||||
localStorage.setItem("messageList", JSON.stringify(newMessages));
|
||||
return newMessages;
|
||||
});
|
||||
}
|
||||
},
|
||||
[messages]
|
||||
);
|
||||
|
||||
const refreshMessages = (formData: FormData, messageId: number) => {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === messageId
|
||||
? {
|
||||
...msg,
|
||||
isTranslating: true,
|
||||
isRefresh: true,
|
||||
}
|
||||
: msg
|
||||
)
|
||||
);
|
||||
onTranslateAudio(formData, messageId);
|
||||
};
|
||||
|
||||
const onSetIsRecording = (flag: boolean) => {
|
||||
setIsRecording(flag);
|
||||
};
|
||||
|
||||
// const onLanguage = (item: FloatMenuItemConfig) => {
|
||||
// if (item.type === "add") {
|
||||
// setVisible(true);
|
||||
// } else {
|
||||
// setCurrentLanguage(item);
|
||||
// }
|
||||
// };
|
||||
|
||||
return (
|
||||
<div className="translate-container">
|
||||
{searchVisible && (
|
||||
<div className="header">
|
||||
<SearchCom handleAllAni={() => {}} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MessageCom
|
||||
data={messages}
|
||||
isRecording={isRecording}
|
||||
onRefresh={refreshMessages}
|
||||
></MessageCom>
|
||||
|
||||
<VoiceRecord
|
||||
dialogId={dialogId}
|
||||
onRecordingComplete={onRecordingComplete}
|
||||
isRecording={isRecording}
|
||||
onSetIsRecording={onSetIsRecording}
|
||||
/>
|
||||
{/* <FloatingMenu menuItems={menuItems} value={currentLanguage} onChange={onLanguage} /> */}
|
||||
<XPopup
|
||||
title="选择翻译语种"
|
||||
visible={visible}
|
||||
onClose={() => {
|
||||
setVisible(false);
|
||||
}}
|
||||
>
|
||||
<div className="card">
|
||||
<span>快捷选项</span>
|
||||
<div></div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<span>宠物语种</span>
|
||||
</div>
|
||||
</XPopup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Index;
|
||||
@@ -1,12 +1,19 @@
|
||||
export interface Message {
|
||||
id: number;
|
||||
type?: "dog" | "cat" | "pig";
|
||||
audioUrl: string;
|
||||
name: string; //名字
|
||||
duration: number; //时长
|
||||
timestamp: number; //时间
|
||||
translatedText?: string;
|
||||
petType?: "dog" | "cat" | "pig" | "";
|
||||
contentText: string;
|
||||
petName?: string; //名字
|
||||
contentDuration: number; //时长
|
||||
createTime?: number; //时间
|
||||
transResult?: string;
|
||||
isTranslating?: boolean;
|
||||
avatar?: string;
|
||||
petAvatar?: string;
|
||||
isPlaying?: boolean;
|
||||
messageStatus?: 0 | 1;
|
||||
transStatus?: 0 | 1;
|
||||
isRefresh?: boolean;
|
||||
emotion?: number;
|
||||
terminal: string;
|
||||
file?: Blob;
|
||||
dialogId?: number;
|
||||
}
|
||||
|
||||
41
projects/translate-h5/src/view/mood/index.module.less
Normal file
@@ -0,0 +1,41 @@
|
||||
.mood {
|
||||
overflow-x: hidden; // 阻止横向滚动
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
.mood-title {
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.mood-abstract-button {
|
||||
color: #1677ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
.mood-filter {
|
||||
position: relative;
|
||||
}
|
||||
.mood-mask-1 {
|
||||
position: absolute;
|
||||
height: 54%;
|
||||
width: 30%;
|
||||
top: 10%;
|
||||
left: 6%;
|
||||
}
|
||||
}
|
||||
|
||||
.homeHeader {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
background: #fff;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 99;
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
64
projects/translate-h5/src/view/mood/index.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import MainLayout from "@/layout/main/mainLayout";
|
||||
import styles from "./index.module.less";
|
||||
// import moodFilterPng from "@/assets/translate/mood-filter.png";
|
||||
// import moodChartPng from "@/assets/translate/mood-chart.png";
|
||||
// import moodAbstractPng from "@/assets/translate/mood-abstract.png";
|
||||
// import moodPopupPng from "@/assets/translate/mood-popup.png";
|
||||
import { Image, Space } from "antd-mobile";
|
||||
import { Switch, Filter, More } from "@icon-park/react";
|
||||
import { useState } from "react";
|
||||
import { XPopup } from "@workspace/shared";
|
||||
|
||||
const Moods = () => {
|
||||
const moodFilterPng = "http://qiniu.bydj.tashowz.com/assets/translate/mood-filter.png";
|
||||
const moodChartPng = "http://qiniu.bydj.tashowz.com/assets/translate/mood-chart.png";
|
||||
const moodAbstractPng = "http://qiniu.bydj.tashowz.com/assets/translate/mood-abstract.png";
|
||||
const moodPopupPng = "http://qiniu.bydj.tashowz.com/assets/translate/mood-popup.png";
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<div className={styles.homeHeader}>
|
||||
<h3>情绪监控</h3>
|
||||
<Space style={{ fontSize: "20px", "--gap": "16px" }}>
|
||||
<Filter fill="#333" />
|
||||
<More fill="#333" />
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div className={styles.mood}>
|
||||
<div className={styles["mood-filter"]}>
|
||||
<div onClick={() => setVisible(true)} className={styles["mood-mask-1"]}></div>
|
||||
<Image src={moodFilterPng}></Image>
|
||||
</div>
|
||||
<Image src={moodChartPng}></Image>
|
||||
<div className={styles["mood-title"]}>
|
||||
<h2>情绪摘要</h2>
|
||||
<div className={styles["mood-abstract-button"]}>
|
||||
<Switch theme="outline" fill="#1677FF" />
|
||||
<span style={{ marginLeft: "4px" }}>饼状视图</span>
|
||||
</div>
|
||||
</div>
|
||||
<Image src={moodAbstractPng}></Image>
|
||||
<div
|
||||
style={{
|
||||
height: "20px",
|
||||
width: "100%",
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<XPopup
|
||||
title="宠物筛选"
|
||||
visible={visible}
|
||||
onClose={() => {
|
||||
setVisible(false);
|
||||
}}
|
||||
>
|
||||
<Image src={moodPopupPng}></Image>
|
||||
</XPopup>
|
||||
</MainLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Moods;
|
||||
32
projects/translate-h5/src/view/order/index.less
Normal file
@@ -0,0 +1,32 @@
|
||||
.order {
|
||||
background-color: #f5f5f5;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
.tab-box {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
// 隐藏滚动条
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// 对于 antd-mobile 的 Image 组件包装器
|
||||
.adm-image {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
|
||||
// 图片自适应高度
|
||||
img {
|
||||
height: auto;
|
||||
width: auto;
|
||||
max-width: none; // 允许图片超过容器宽度
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
projects/translate-h5/src/view/order/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import MainLayout from "@/layout/main/mainLayout";
|
||||
import "./index.less";
|
||||
import { Image } from "antd-mobile";
|
||||
// import tabsSvg from "@/assets/order/tabs.svg";
|
||||
// import contant from "@/assets/order/contant.svg";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
function Order() {
|
||||
const tabsSvg = "http://qiniu.bydj.tashowz.com/assets/order/tabs.svg";
|
||||
const contant = "http://qiniu.bydj.tashowz.com/assets/order/contant.svg";
|
||||
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<MainLayout isShowNavBar={true} title="我的订单">
|
||||
<div className="order">
|
||||
<div className="tab-box">
|
||||
<Image alt="tabs" draggable={false} src={tabsSvg}></Image>
|
||||
</div>
|
||||
<Image onClick={() => navigate("/order/detail")} src={contant}></Image>
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default Order;
|
||||
14
projects/translate-h5/src/view/orderDetail/index.less
Normal file
@@ -0,0 +1,14 @@
|
||||
.order-detail {
|
||||
background-color: #f5f5f5;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
.order-contant {
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
.order-bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
29
projects/translate-h5/src/view/orderDetail/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import MainLayout from "@/layout/main/mainLayout";
|
||||
import "./index.less";
|
||||
import { Image } from "antd-mobile";
|
||||
// import gdSvg from "@/assets/order/gd.svg";
|
||||
// import gdPng from "@/assets/order/gd.png";
|
||||
// import bottomSvg from "@/assets/order/bottom.svg";
|
||||
|
||||
function OrderDetail() {
|
||||
const gdSvg = "http://qiniu.bydj.tashowz.com/assets/order/gd.svg";
|
||||
const gdPng = "http://qiniu.bydj.tashowz.com/assets/order/gd.png";
|
||||
const bottomSvg = "http://qiniu.bydj.tashowz.com/assets/order/bottom.svg";
|
||||
|
||||
return (
|
||||
<MainLayout isShowNavBar={true} title="订单详情">
|
||||
<div className="order-detail">
|
||||
<div className="order-contant">
|
||||
<Image src={gdPng}></Image>
|
||||
<Image src={gdSvg}></Image>
|
||||
</div>
|
||||
|
||||
<div className="order-bottom">
|
||||
<Image src={bottomSvg}></Image>
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrderDetail;
|
||||
64
projects/translate-h5/src/view/payment/index.less
Normal file
@@ -0,0 +1,64 @@
|
||||
.payment {
|
||||
background-color: #f5f5f5;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
.payment-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.payment-content {
|
||||
padding: 12px;
|
||||
padding-bottom: 160px;
|
||||
.payment-cart {
|
||||
position: relative;
|
||||
margin-bottom: 10px;
|
||||
.mask {
|
||||
position: absolute;
|
||||
z-index: 99;
|
||||
width: 100%;
|
||||
height: 45.5%;
|
||||
top: 21%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.payment-bottom {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.popup-bottom {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
// 设置中间间隔
|
||||
.popup-button {
|
||||
flex: 1;
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
.cancel-button {
|
||||
--background-color: #fff;
|
||||
--border-color: #e9e9e9;
|
||||
--text-color: #000000;
|
||||
}
|
||||
.confirm-button {
|
||||
--background-color: #f5222d;
|
||||
--border-color: #f5222d;
|
||||
--text-color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
106
projects/translate-h5/src/view/payment/index.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import MainLayout from "@/layout/main/mainLayout";
|
||||
import "./index.less";
|
||||
import { Button, Image, Popup } from "antd-mobile";
|
||||
// import HeaderSvg from "@/assets/payment/header.svg";
|
||||
// import Cart1Svg from "@/assets/payment/cart-1.svg";
|
||||
// import Cart2Svg from "@/assets/payment/cart-2.svg";
|
||||
// import Cart3Png from "@/assets/payment/cart-3.png";
|
||||
// import BottomPng from "@/assets/payment/bottom.png";
|
||||
// import PopupPng from "@/assets/payment/popup.png";
|
||||
import { CloseOutline } from "antd-mobile-icons";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface DefinedProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
showBottom?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
function MyPopup(props: DefinedProps) {
|
||||
const { visible, title, children, onClose, showBottom = false } = props;
|
||||
return (
|
||||
<Popup visible={visible} closeOnMaskClick={true} className="xpopup" onMaskClick={onClose}>
|
||||
<div className="popup-header">
|
||||
<h3>{title}</h3>
|
||||
<span onClick={onClose}>
|
||||
<CloseOutline style={{ fontSize: "16px" }} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="popup-content">{children}</div>
|
||||
|
||||
{showBottom && (
|
||||
<div className="popup-bottom">
|
||||
<div className="popup-button">
|
||||
<Button onClick={onClose} block size="large" shape="rounded" className="confirm-button">
|
||||
确 定
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
function Payment() {
|
||||
const HeaderSvg = "http://qiniu.bydj.tashowz.com/assets/payment/header.svg";
|
||||
const Cart1Svg = "http://qiniu.bydj.tashowz.com/assets/payment/cart-1.svg";
|
||||
const Cart2Svg = "http://qiniu.bydj.tashowz.com/assets/payment/cart-2.svg";
|
||||
const Cart3Png = "http://qiniu.bydj.tashowz.com/assets/payment/cart-3.png";
|
||||
const BottomPng = "http://qiniu.bydj.tashowz.com/assets/payment/bottom.png";
|
||||
const PopupPng = "http://qiniu.bydj.tashowz.com/assets/payment/popup.png";
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onLink = (link: string) => {
|
||||
navigate(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<MainLayout isShowNavBar={true} title="选择服务">
|
||||
<div className="payment">
|
||||
<div className="payment-header">
|
||||
<Image src={HeaderSvg} />
|
||||
</div>
|
||||
<div className="payment-content">
|
||||
<div
|
||||
onClick={() => {
|
||||
setVisible(true);
|
||||
}}
|
||||
className="payment-cart"
|
||||
>
|
||||
<div className="mask"></div>
|
||||
<Image src={Cart1Svg}></Image>
|
||||
</div>
|
||||
<div className="payment-cart">
|
||||
<Image src={Cart2Svg}></Image>
|
||||
</div>
|
||||
<div className="payment-cart">
|
||||
<Image src={Cart3Png}></Image>
|
||||
</div>
|
||||
</div>
|
||||
<div className="payment-bottom">
|
||||
<Image onClick={() => onLink("/result")} src={BottomPng}></Image>
|
||||
</div>
|
||||
|
||||
<MyPopup
|
||||
title="选择品种"
|
||||
visible={visible}
|
||||
onClose={() => {
|
||||
setVisible(false);
|
||||
}}
|
||||
showBottom={true}
|
||||
>
|
||||
<div className="popup-content">
|
||||
<Image src={PopupPng}></Image>
|
||||
</div>
|
||||
</MyPopup>
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default Payment;
|
||||
13
projects/translate-h5/src/view/result/index.less
Normal file
@@ -0,0 +1,13 @@
|
||||
.result {
|
||||
min-height: 100%;
|
||||
padding: 0 24px;
|
||||
padding-top: 92px;
|
||||
|
||||
.bottom {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
34
projects/translate-h5/src/view/result/index.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import MainLayout from "@/layout/main/mainLayout";
|
||||
import "./index.less";
|
||||
import { Image } from "antd-mobile";
|
||||
|
||||
// import TitlePng from "@/assets/result/title.png";
|
||||
// import ContentSvg from "@/assets/result/content.svg";
|
||||
// import BottomSvg from "@/assets/result/bottom.svg";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
function Result() {
|
||||
const TitlePng = "http://qiniu.bydj.tashowz.com/assets/result/title.png";
|
||||
const ContentSvg = "http://qiniu.bydj.tashowz.com/assets/result/content.svg";
|
||||
const BottomSvg = "http://qiniu.bydj.tashowz.com/assets/result/bottom.svg";
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onLink = (link: string) => {
|
||||
navigate(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<div className="result">
|
||||
<Image src={TitlePng} className="title" />
|
||||
<Image style={{ marginTop: "30px" }} src={ContentSvg} className="content" />
|
||||
<div className="bottom">
|
||||
<Image onClick={() => onLink("/service")} src={BottomSvg} />
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default Result;
|
||||
29
projects/translate-h5/src/view/service/index.module.less
Normal file
@@ -0,0 +1,29 @@
|
||||
.service {
|
||||
background-color: #f5f5f5;
|
||||
padding: 12px 0;
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.homeHeader {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
background: #f5f5f5;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 99;
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.headerItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
p {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
44
projects/translate-h5/src/view/service/index.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import MainLayout from "@/layout/main/mainLayout";
|
||||
import styles from "./index.module.less";
|
||||
import { Space, Image } from "antd-mobile";
|
||||
import { HeadsetOne, TextMessage, TransactionOrder } from "@icon-park/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
function Service() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const content = "http://qiniu.bydj.tashowz.com/assets/service/content.png";
|
||||
|
||||
const onLink = (link: string) => {
|
||||
navigate(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<div className={styles.homeHeader}>
|
||||
<h3>宠物服务</h3>
|
||||
<Space style={{ fontSize: "20px", "--gap": "16px" }}>
|
||||
<div className={styles.headerItem}>
|
||||
<HeadsetOne fill="#333" />
|
||||
<p>客服</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.headerItem}>
|
||||
<TextMessage fill="#333" />
|
||||
<p>消息</p>
|
||||
</div>
|
||||
|
||||
<div onClick={() => onLink("/order")} className={styles.headerItem}>
|
||||
<TransactionOrder fill="#333" />
|
||||
<p>订单</p>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
<div className={styles.service}>
|
||||
<Image onClick={() => onLink("/service/detail")} src={content}></Image>
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default Service;
|
||||
64
projects/translate-h5/src/view/serviceDetail/index.less
Normal file
@@ -0,0 +1,64 @@
|
||||
.service-detail {
|
||||
background-color: #f5f5f5;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
.swipers {
|
||||
padding-top: 20px;
|
||||
box-sizing: border-box;
|
||||
.swiper-item {
|
||||
margin: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 8px;
|
||||
|
||||
padding-bottom: 92px;
|
||||
.service-item {
|
||||
background-color: #fff;
|
||||
border-radius: 16px;
|
||||
margin-top: 12px;
|
||||
&:first-child {
|
||||
padding: 16px;
|
||||
.tabs {
|
||||
margin: 12px 0;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
// 隐藏滚动条
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// 对于 antd-mobile 的 Image 组件包装器
|
||||
.adm-image {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
|
||||
// 图片自适应高度
|
||||
img {
|
||||
height: auto;
|
||||
width: auto;
|
||||
max-width: none; // 允许图片超过容器宽度
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bottom {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
padding: 0 16px;
|
||||
padding-top: 16px;
|
||||
padding-bottom: 36px;
|
||||
}
|
||||
}
|
||||
93
projects/translate-h5/src/view/serviceDetail/index.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import MainLayout from "@/layout/main/mainLayout";
|
||||
import "./index.less";
|
||||
import { Image, Swiper } from "antd-mobile";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
function ServiceDetail() {
|
||||
const Swiper1Png = "http://qiniu.bydj.tashowz.com/assets/service/swiper-1.png";
|
||||
const Swiper2Png = "http://qiniu.bydj.tashowz.com/assets/service/swiper-2.png";
|
||||
const Swiper3Png = "http://qiniu.bydj.tashowz.com/assets/service/swiper-3.png";
|
||||
const DetailPricePng = "http://qiniu.bydj.tashowz.com/assets/service/detail-price.png";
|
||||
const DetailTapsSvg = "http://qiniu.bydj.tashowz.com/assets/service/detail-taps.svg";
|
||||
const DetailTitlePng = "http://qiniu.bydj.tashowz.com/assets/service/detail-title.png";
|
||||
const SzzPng = "http://qiniu.bydj.tashowz.com/assets/service/szz.png";
|
||||
const SzSvg = "http://qiniu.bydj.tashowz.com/assets/service/sz.svg";
|
||||
const NoBoxSvg = "http://qiniu.bydj.tashowz.com/assets/service/noBox.svg";
|
||||
const BottomLSvg = "http://qiniu.bydj.tashowz.com/assets/service/bottom-l.svg";
|
||||
const BottomRSvg = "http://qiniu.bydj.tashowz.com/assets/service/bottom-r.svg";
|
||||
|
||||
// 批量导入
|
||||
// const images = import.meta.glob("@/assets/service/detail/*.png", { eager: true, as: "url" });
|
||||
|
||||
const images = Array.from({ length: 19 }, (_, index) => {
|
||||
const num = index + 1;
|
||||
return `http://qiniu.bydj.tashowz.com/assets/service/detail/${num}.png`;
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onLink = (link: string) => {
|
||||
navigate(link);
|
||||
};
|
||||
|
||||
const swipers = [{ src: Swiper1Png }, { src: Swiper2Png }, { src: Swiper3Png }];
|
||||
|
||||
return (
|
||||
<MainLayout isShowNavBar={true} title="服务详情">
|
||||
<div className="service-detail">
|
||||
<div className="swipers">
|
||||
<Swiper
|
||||
trackOffset={5}
|
||||
stuckAtBoundary={false}
|
||||
slideSize={90}
|
||||
total={3}
|
||||
style={{
|
||||
"--border-radius": "8px",
|
||||
}}
|
||||
defaultIndex={1}
|
||||
>
|
||||
{swipers.map((item, index) => {
|
||||
return (
|
||||
<Swiper.Item key={index}>
|
||||
<div className="swiper-item">
|
||||
<Image src={item.src} />
|
||||
</div>
|
||||
</Swiper.Item>
|
||||
);
|
||||
})}
|
||||
</Swiper>
|
||||
</div>
|
||||
<div className="content">
|
||||
<div className="service-item">
|
||||
<Image src={DetailPricePng}></Image>
|
||||
<div className="tabs">
|
||||
<Image src={DetailTapsSvg}></Image>
|
||||
</div>
|
||||
<Image src={DetailTitlePng}></Image>
|
||||
</div>
|
||||
|
||||
<div className="service-item">
|
||||
<Image src={SzSvg}></Image>
|
||||
</div>
|
||||
|
||||
<div className="service-item">
|
||||
<Image src={SzzPng}></Image>
|
||||
</div>
|
||||
|
||||
<div className="service-item">
|
||||
{images.map((src, index) => {
|
||||
return <Image key={index} src={src} />;
|
||||
})}
|
||||
</div>
|
||||
<Image src={NoBoxSvg}></Image>
|
||||
</div>
|
||||
<div className="bottom">
|
||||
<Image src={BottomLSvg}></Image>
|
||||
<Image onClick={() => onLink("/payment")} src={BottomRSvg}></Image>
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceDetail;
|
||||
@@ -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;
|
||||
30
projects/translate-h5/src/view/user/index.module.less
Normal file
@@ -0,0 +1,30 @@
|
||||
.user {
|
||||
background-color: #f5f5f5;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.homeHeader {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
padding-left: 0;
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
background: #f5f5f5;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 99;
|
||||
h3 {
|
||||
font-size: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.headerItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
p {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||