feat: init

This commit is contained in:
2025-09-04 11:29:00 +08:00
parent 2c9059ce2f
commit ddbee614e8
89 changed files with 3476 additions and 349 deletions

View File

@@ -0,0 +1,7 @@
.carousel {
.carousel-item {
.carousel-image {
border-radius: 15px;
}
}
}

View 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;

View 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;
}
}

View 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;

View 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;

View 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;
}
}

View 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);

View 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;
}
}
}

View 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;