feat: init
This commit is contained in:
8
packages/shared/index.ts
Normal file
8
packages/shared/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// 导出所有组件
|
||||
import FloatingMenu, { FloatMenuItemConfig } from "./src/floatingMenu";
|
||||
import XPopup from "./src/xpopup";
|
||||
import QRscanner from "./src/qr-scanner";
|
||||
import VoiceIcon from "./src/voiceIcon";
|
||||
|
||||
export { FloatingMenu, XPopup, QRscanner, VoiceIcon };
|
||||
export type { FloatMenuItemConfig };
|
||||
23
packages/shared/package.json
Normal file
23
packages/shared/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@workspace/shared",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"antd-mobile": "^5.34.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
7
packages/shared/src/carousel/index.less
Normal file
7
packages/shared/src/carousel/index.less
Normal file
@@ -0,0 +1,7 @@
|
||||
.carousel {
|
||||
.carousel-item {
|
||||
.carousel-image {
|
||||
border-radius: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
49
packages/shared/src/carousel/index.tsx
Normal file
49
packages/shared/src/carousel/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import Slider, {Settings} from 'react-slick';
|
||||
import 'slick-carousel/slick/slick.css';
|
||||
import 'slick-carousel/slick/slick-theme.css';
|
||||
import './index.less';
|
||||
|
||||
export interface CarouselComponentProps {
|
||||
images: string[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮播图组件
|
||||
* @param images 图片地址数组
|
||||
* @param height 图片高度
|
||||
* @constructor Carousel
|
||||
*/
|
||||
const Carousel: React.FC<CarouselComponentProps> = ({images, height = 180}) => {
|
||||
const settings: Settings = {
|
||||
dots: false,
|
||||
infinite: true,
|
||||
speed: 3000,
|
||||
slidesToShow: 1,
|
||||
slidesToScroll: 1,
|
||||
autoplay: true,
|
||||
autoplaySpeed: 2000,
|
||||
responsive: [
|
||||
{
|
||||
breakpoint: 768,
|
||||
settings: {
|
||||
arrows: false,
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<Slider {...settings} className="carousel">
|
||||
{images.map((image, index) => (
|
||||
<div key={index} className="carousel-item">
|
||||
<img className="carousel-image" src={image} alt={`Slide ${index}`}
|
||||
style={{width: '100%', height: `${height}px`}}/>
|
||||
</div>
|
||||
))}
|
||||
</Slider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Carousel;
|
||||
42
packages/shared/src/floatingMenu/index.less
Normal file
42
packages/shared/src/floatingMenu/index.less
Normal file
@@ -0,0 +1,42 @@
|
||||
/* FloatingFanMenu.css */
|
||||
|
||||
@keyframes menuItemPop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0) rotate(-180deg);
|
||||
}
|
||||
70% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1) rotate(-10deg);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 悬停效果 */
|
||||
.menu-item:hover {
|
||||
transform: scale(1.1) !important;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3) !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
.adm-floating-bubble-button {
|
||||
z-index: 999;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
overflow: visible;
|
||||
.cat {
|
||||
position: absolute;
|
||||
width: 70px;
|
||||
font-size: 12px;
|
||||
|
||||
bottom: -10px;
|
||||
background: rgba(255, 204, 199, 1);
|
||||
padding: 4px 0px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
184
packages/shared/src/floatingMenu/index.tsx
Normal file
184
packages/shared/src/floatingMenu/index.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React, { useState, useRef } from "react";
|
||||
import { FloatingBubble } from "antd-mobile";
|
||||
import { createPortal } from "react-dom";
|
||||
import "./index.less";
|
||||
|
||||
export interface FloatMenuItemConfig {
|
||||
icon: React.ReactNode;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const FloatingFanMenu: React.FC<{
|
||||
menuItems: FloatMenuItemConfig[];
|
||||
value?: FloatMenuItemConfig;
|
||||
onChange?: (item: FloatMenuItemConfig) => void;
|
||||
}> = (props) => {
|
||||
const { menuItems = [] } = props;
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
|
||||
const bubbleRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 点击时获取FloatingBubble的位置
|
||||
const handleMainClick = () => {
|
||||
if (!visible) {
|
||||
// 显示菜单时,获取当前FloatingBubble的位置
|
||||
if (bubbleRef.current) {
|
||||
const bubble = bubbleRef.current.querySelector(
|
||||
".adm-floating-bubble-button"
|
||||
);
|
||||
if (bubble) {
|
||||
const rect = bubble.getBoundingClientRect();
|
||||
setMenuPosition({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
setVisible(!visible);
|
||||
};
|
||||
|
||||
const handleItemClick = (item: FloatMenuItemConfig) => {
|
||||
props.onChange?.(item);
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
// 计算菜单项位置
|
||||
const getMenuItemPosition = (index: number) => {
|
||||
const positions = [
|
||||
{ x: 0, y: -80 }, // 上方
|
||||
{ x: -60, y: -60 }, // 左上
|
||||
{ x: -80, y: 0 }, // 左方
|
||||
{ x: -60, y: 60 }, // 左下
|
||||
];
|
||||
|
||||
const pos = positions[index] || { x: 0, y: 0 };
|
||||
let x = menuPosition.x + pos.x;
|
||||
let y = menuPosition.y + pos.y;
|
||||
|
||||
// // 边界检测
|
||||
// const itemSize = 48;
|
||||
// const margin = 20;
|
||||
// 边界检测
|
||||
const itemSize = 48;
|
||||
// const margin = 0;
|
||||
|
||||
// x = Math.max(
|
||||
// // margin + itemSize / 2,
|
||||
// Math.min(window.innerWidth - margin - itemSize / 2, x)
|
||||
// );
|
||||
// y = Math.max(
|
||||
// // margin + itemSize / 2,
|
||||
// Math.min(window.innerHeight - margin - itemSize / 2, y)
|
||||
// );
|
||||
|
||||
return {
|
||||
left: x - itemSize / 2,
|
||||
top: y - itemSize / 2,
|
||||
};
|
||||
};
|
||||
|
||||
// 菜单组件
|
||||
const MenuComponent = () => (
|
||||
<>
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.1)",
|
||||
zIndex: 9998,
|
||||
}}
|
||||
onClick={() => setVisible(false)}
|
||||
/>
|
||||
|
||||
{/* 菜单项 */}
|
||||
{menuItems.map((item, index) => {
|
||||
const position = getMenuItemPosition(index);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
position: "fixed",
|
||||
...position,
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "#fff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "white",
|
||||
fontSize: 20,
|
||||
cursor: "pointer",
|
||||
zIndex: 9999,
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.2)",
|
||||
animation: `menuItemPop 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55) forwards`,
|
||||
animationDelay: `${index * 0.1}s`,
|
||||
opacity: 0,
|
||||
transform: "scale(0)",
|
||||
}}
|
||||
onClick={() => handleItemClick(item)}
|
||||
// title={item.label}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 主按钮 */}
|
||||
<div ref={bubbleRef}>
|
||||
<FloatingBubble
|
||||
style={{
|
||||
"--initial-position-bottom": "200px",
|
||||
"--initial-position-right": "24px",
|
||||
"--edge-distance": "24px",
|
||||
}}
|
||||
onClick={handleMainClick}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 24,
|
||||
color: "white",
|
||||
background: visible
|
||||
? "linear-gradient(135deg, #ff4d4f, #ff7875)"
|
||||
: "linear-gradient(135deg, #fff, #fff)",
|
||||
borderRadius: "50%",
|
||||
transition: "all 0.3s ease",
|
||||
transform: visible ? "rotate(45deg)" : "rotate(0deg)",
|
||||
boxShadow: visible
|
||||
? "0 6px 16px rgba(255, 77, 79, 0.4)"
|
||||
: "0 4px 12px #eee",
|
||||
}}
|
||||
>
|
||||
{props.value?.icon}
|
||||
<div className="cat">
|
||||
{/* {!visible && <CheckOutline style={{ marginRight: "3px" }} />} */}
|
||||
切换语言
|
||||
</div>
|
||||
</div>
|
||||
</FloatingBubble>
|
||||
</div>
|
||||
|
||||
{/* 菜单 - 只在有位置信息时渲染 */}
|
||||
{visible &&
|
||||
menuPosition.x > 0 &&
|
||||
menuPosition.y > 0 &&
|
||||
createPortal(<MenuComponent />, document.body)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingFanMenu;
|
||||
88
packages/shared/src/qr-scanner/index.tsx
Normal file
88
packages/shared/src/qr-scanner/index.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, {useRef, useEffect, useState} from 'react';
|
||||
import jsQR from 'jsqr';
|
||||
|
||||
export interface QRScannerProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
onScan: (data: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 二维码扫描组件
|
||||
* @param width 画布宽度
|
||||
* @param height 画布高度
|
||||
* @param onScan 扫描成功的回调函数
|
||||
* @constructor QRScanner
|
||||
*/
|
||||
const QRScanner: React.FC<QRScannerProps> = ({width = 300, height = 300, onScan}) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [isScanning, setIsScanning] = useState(true);
|
||||
const [scanSuccess, setScanSuccess] = useState(false); // 新的状态变量
|
||||
let streamRef: MediaStream | null = null; // 存储摄像头流的引用
|
||||
|
||||
useEffect(() => {
|
||||
navigator.mediaDevices.getUserMedia({video: {facingMode: "environment"}})
|
||||
.then(stream => {
|
||||
streamRef = stream; // 存储对摄像头流的引用
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
videoRef.current.addEventListener('loadedmetadata', () => {
|
||||
videoRef.current?.play().then(() => {
|
||||
requestAnimationFrame(tick);
|
||||
}).catch(err => console.error("Error playing video: ", err));
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error("Error accessing media devices: ", err);
|
||||
setIsScanning(false);
|
||||
});
|
||||
}, []);
|
||||
const tick = () => {
|
||||
// 如果已经成功扫描,就不再继续执行tick
|
||||
if (!isScanning) return;
|
||||
|
||||
if (videoRef.current && canvasRef.current) {
|
||||
if (videoRef.current.readyState === videoRef.current.HAVE_ENOUGH_DATA) {
|
||||
let video = videoRef.current;
|
||||
let canvas = canvasRef.current;
|
||||
let ctx = canvas.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
canvas.height = height;
|
||||
canvas.width = width;
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
let imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
let code = jsQR(imageData.data, imageData.width, imageData.height);
|
||||
|
||||
if (code) {
|
||||
onScan(code.data); // 扫码成功
|
||||
setIsScanning(false); // 更新状态为不再扫描
|
||||
setScanSuccess(true); // 显示扫描成功的蒙层
|
||||
if (streamRef) {
|
||||
let tracks = streamRef.getTracks();
|
||||
tracks.forEach(track => track.stop()); // 关闭摄像头
|
||||
}
|
||||
return; // 直接返回,避免再次调用 requestAnimationFrame
|
||||
}
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<video ref={videoRef} style={{display: 'none'}}></video>
|
||||
<canvas ref={canvasRef}
|
||||
style={{
|
||||
display: isScanning ? 'block' : 'none',
|
||||
width: `${width}px`,
|
||||
height: `${height}px`
|
||||
}}></canvas>
|
||||
{scanSuccess && <div className="scan-success-overlay">识别成功!</div>} {/* 显示识别成功的蒙层 */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QRScanner;
|
||||
69
packages/shared/src/voiceIcon/index.less
Normal file
69
packages/shared/src/voiceIcon/index.less
Normal file
@@ -0,0 +1,69 @@
|
||||
/* VoiceIcon.css */
|
||||
.voice-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
gap: 2px;
|
||||
}
|
||||
.audio-recorder {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.wave {
|
||||
width: 1px;
|
||||
height: 13px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.wave1 {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.wave2 {
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.wave3 {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.wave3 {
|
||||
height: 8px;
|
||||
}
|
||||
.wave4 {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
/* 播放动画 */
|
||||
.voice-icon.playing .wave {
|
||||
animation: voice-wave 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.voice-icon.playing .wave1 {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.voice-icon.playing .wave2 {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.voice-icon.playing .wave3 {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes voice-wave {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(0.3);
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
22
packages/shared/src/voiceIcon/index.tsx
Normal file
22
packages/shared/src/voiceIcon/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import "./index.less";
|
||||
|
||||
const VoiceIcon = (props: { isPlaying: boolean; onChange?: () => void }) => {
|
||||
const { isPlaying = false } = props;
|
||||
const onChange = useCallback(() => {
|
||||
props.onChange?.();
|
||||
}, [isPlaying]);
|
||||
return (
|
||||
<div
|
||||
className={`voice-icon ${isPlaying ? "playing" : ""}`}
|
||||
onClick={onChange}
|
||||
>
|
||||
<div className="wave wave1"></div>
|
||||
<div className="wave wave2"></div>
|
||||
<div className="wave wave3"></div>
|
||||
<div className="wave wave4"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(VoiceIcon);
|
||||
11
packages/shared/src/xpopup/index.less
Normal file
11
packages/shared/src/xpopup/index.less
Normal file
@@ -0,0 +1,11 @@
|
||||
.xpopup {
|
||||
.adm-popup-body {
|
||||
background-color: rgba(245, 245, 245, 1);
|
||||
padding: 16px;
|
||||
.header {
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
packages/shared/src/xpopup/index.tsx
Normal file
28
packages/shared/src/xpopup/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Divider, Popup } from "antd-mobile";
|
||||
import { CloseOutline } from "antd-mobile-icons";
|
||||
import "./index.less";
|
||||
|
||||
interface DefinedProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
}
|
||||
function XPopup(props: DefinedProps) {
|
||||
const { visible, title, children, onClose } = props;
|
||||
|
||||
return (
|
||||
<Popup visible={visible} closeOnMaskClick={true} className="xpopup">
|
||||
<div className="header">
|
||||
<h3 className="title">{title}</h3>
|
||||
<span className="closeIcon" onClick={onClose}>
|
||||
<CloseOutline style={{ fontSize: "16px" }} />
|
||||
</span>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="content">{children}</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
export default XPopup;
|
||||
10
packages/shared/tsconfig.json
Normal file
10
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user