Compare commits
4
Commits
v0.2.1
..
b8fda4322f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8fda4322f | ||
|
|
0cfd617922 | ||
|
|
4801b3de08 | ||
|
|
25c76aebf2 |
@@ -1,3 +0,0 @@
|
||||
frontend/node_modules
|
||||
dist
|
||||
frontend/dist
|
||||
@@ -1,59 +0,0 @@
|
||||
run-name: build ushare
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build ushare:
|
||||
runs-on: tencent-sg
|
||||
steps:
|
||||
- name: prepare enviroment
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: prints date
|
||||
run: date '+%Y-%m-%dT%H:%M:%S'
|
||||
|
||||
- name: print operator
|
||||
run: whoami
|
||||
|
||||
- name: print tag name
|
||||
run: echo "Tag name = ${{ gitea.ref_name }}"
|
||||
|
||||
- name: build prepare config
|
||||
run: |
|
||||
cat << EOF > .docker.config.json
|
||||
${{ secrets.DOCKER_CONFIG }}
|
||||
EOF
|
||||
|
||||
- name: print work dir and files
|
||||
run: pwd & ls -alsh .
|
||||
|
||||
- name: build image by docker build
|
||||
run: docker build -t gitea.loveuer.com/loveuer/build/ushare:${{ gitea.ref_name }} .
|
||||
|
||||
- name: login repository
|
||||
run: echo ${{ secrets.DOCKER_REPOSITORY_PASSWORD }} | docker login --username loveuer --password-stdin gitea.loveuer.com/loveuer
|
||||
|
||||
- name: push image to repository
|
||||
run: docker push gitea.loveuer.com/loveuer/build/ushare:${{ gitea.ref_name }}
|
||||
|
||||
# - name: build by kaniko in docker
|
||||
# run: |
|
||||
# docker run --rm -v $(pwd):/workspace \
|
||||
# -v $(pwd)/.docker.config.json:/kaniko/.docker/config.json:ro \
|
||||
# alpine:latest \
|
||||
# ls -alsh /workspace
|
||||
# gcr.io/kaniko-project/executor:latest \
|
||||
# --dockerfile=/workspace/Dockerfile \
|
||||
# --context=/workspace \
|
||||
# --destination=gitea.loveuer.com/loveuer/build/u-api:${{ gitea.ref_name }} \
|
||||
# --single-snapshot
|
||||
|
||||
clean:
|
||||
if: always()
|
||||
runs-on: tencent-sg
|
||||
steps:
|
||||
- name: clean docker config
|
||||
run: |
|
||||
rm -rf .docker.config.json
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
# 第一阶段:构建前端
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
RUN npm install -g pnpm --registry=https://registry.npmmirror.com
|
||||
COPY frontend /app/frontend
|
||||
WORKDIR /app/frontend
|
||||
RUN pnpm install --registry=https://registry.npmmirror.com
|
||||
RUN pnpm run build
|
||||
|
||||
# 第二阶段:构建 Golang 后端
|
||||
FROM golang:alpine AS backend-builder
|
||||
WORKDIR /app
|
||||
ENV CGO_ENABLED=0
|
||||
ENV GOOS=linux
|
||||
ENV GOPROXY=https://goproxy.cn
|
||||
COPY go.mod /app/go.mod
|
||||
COPY go.sum /app/go.sum
|
||||
RUN go mod download
|
||||
COPY main.go /app/main.go
|
||||
COPY internal /app/internal
|
||||
RUN go build -ldflags '-s -w' -o ushare .
|
||||
|
||||
# 第三阶段:生成最终镜像
|
||||
FROM nginx:alpine
|
||||
COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html
|
||||
COPY --from=backend-builder /app/ushare /usr/local/bin/ushare
|
||||
|
||||
# 配置 Nginx
|
||||
COPY deployment/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY deployment/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
# 开放端口
|
||||
EXPOSE 80
|
||||
|
||||
# 启动服务
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# 启动后端服务(带参数后台运行)
|
||||
echo "启动后端服务,参数: $@"
|
||||
ushare "$@" &
|
||||
BACKEND_PID=$!
|
||||
|
||||
# 定义优雅关闭函数
|
||||
graceful_shutdown() {
|
||||
echo "接收到终止信号,开始优雅关闭..."
|
||||
|
||||
# 先关闭 Nginx
|
||||
echo "关闭 Nginx..."
|
||||
nginx -s quit 2>/dev/null || kill -TERM $NGINX_PID 2>/dev/null
|
||||
|
||||
# 关闭后端服务
|
||||
echo "关闭后端服务 PID $BACKEND_PID..."
|
||||
kill -TERM $BACKEND_PID 2>/dev/null
|
||||
|
||||
# 等待进程终止
|
||||
wait $BACKEND_PID $NGINX_PID 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
|
||||
# 捕获系统信号
|
||||
trap 'graceful_shutdown' SIGTERM SIGINT
|
||||
|
||||
# 启动 Nginx(前台运行)
|
||||
echo "启动 Nginx..."
|
||||
nginx -g "daemon off;" &
|
||||
NGINX_PID=$!
|
||||
|
||||
# 等待所有后台进程
|
||||
wait $BACKEND_PID $NGINX_PID
|
||||
@@ -1,50 +0,0 @@
|
||||
user root;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
|
||||
keepalive_timeout 65;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://localhost:9119;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location /ushare {
|
||||
proxy_pass http://localhost:9119;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_max_temp_file_size 0;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
client_max_body_size 5M;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-12
@@ -2,19 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- Emoji 图标方案 -->
|
||||
<link rel="icon"
|
||||
href="data:image/svg+xml,
|
||||
<svg xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 100 100'>
|
||||
<text y='.9em'
|
||||
font-size='90'
|
||||
style='font-family: Arial, sans-serif'>
|
||||
🗃️
|
||||
</text>
|
||||
</svg>">
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>UShare</title>
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-jss": "^10.10.0",
|
||||
"react-router-dom": "^7.5.3",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Generated
-51
@@ -17,9 +17,6 @@ importers:
|
||||
react-jss:
|
||||
specifier: ^10.10.0
|
||||
version: 10.10.0(react@19.1.0)
|
||||
react-router-dom:
|
||||
specifier: ^7.5.3
|
||||
version: 7.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
zustand:
|
||||
specifier: ^5.0.3
|
||||
version: 5.0.3(@types/react@19.1.2)(react@19.1.0)
|
||||
@@ -643,10 +640,6 @@ packages:
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
cookie@1.0.2:
|
||||
resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1127,23 +1120,6 @@ packages:
|
||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
react-router-dom@7.5.3:
|
||||
resolution: {integrity: sha512-cK0jSaTyW4jV9SRKAItMIQfWZ/D6WEZafgHuuCb9g+SjhLolY78qc+De4w/Cz9ybjvLzShAmaIMEXt8iF1Cm+A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
|
||||
react-router@7.5.3:
|
||||
resolution: {integrity: sha512-3iUDM4/fZCQ89SXlDa+Ph3MevBrozBAI655OAfWQlTm9nBR0IKlrmNwFow5lPHttbwvITZfkeeeZFP6zt3F7pw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
react@19.1.0:
|
||||
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1179,9 +1155,6 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
set-cookie-parser@2.7.1:
|
||||
resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==}
|
||||
|
||||
shallow-equal@1.2.1:
|
||||
resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==}
|
||||
|
||||
@@ -1232,9 +1205,6 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4'
|
||||
|
||||
turbo-stream@2.4.0:
|
||||
resolution: {integrity: sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -1863,8 +1833,6 @@ snapshots:
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
cookie@1.0.2: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -2376,21 +2344,6 @@ snapshots:
|
||||
|
||||
react-refresh@0.17.0: {}
|
||||
|
||||
react-router-dom@7.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
react-router: 7.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
|
||||
react-router@7.5.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
cookie: 1.0.2
|
||||
react: 19.1.0
|
||||
set-cookie-parser: 2.7.1
|
||||
turbo-stream: 2.4.0
|
||||
optionalDependencies:
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
|
||||
react@19.1.0: {}
|
||||
|
||||
regenerator-runtime@0.14.1: {}
|
||||
@@ -2435,8 +2388,6 @@ snapshots:
|
||||
|
||||
semver@7.7.1: {}
|
||||
|
||||
set-cookie-parser@2.7.1: {}
|
||||
|
||||
shallow-equal@1.2.1: {}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
@@ -2478,8 +2429,6 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.7.3
|
||||
|
||||
turbo-stream@2.4.0: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import {FileSharing} from "./page/share.tsx";
|
||||
|
||||
export function App() {
|
||||
return <FileSharing />;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import {useState} from "react";
|
||||
import {message} from "../hook/message/u-message.tsx";
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
login_at: number;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const useAuth = () => {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
let res = await fetch("/api/uauth/login", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({username: username, password: password}),
|
||||
})
|
||||
|
||||
if(res.status !== 200) {
|
||||
message.error("账号或密码错误")
|
||||
throw new Error(await res.text())
|
||||
}
|
||||
|
||||
let jes = await res.json() as User;
|
||||
|
||||
setUser(jes)
|
||||
}
|
||||
|
||||
return {user, login}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
|
||||
interface UploadRes {
|
||||
code: string
|
||||
}
|
||||
|
||||
export const useFileUpload = () => {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const uploadFile = async (file: File): Promise<string> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
|
||||
try {
|
||||
const url = `/api/ushare/${file.name}`;
|
||||
|
||||
// 1. 初始化上传
|
||||
const res1 = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: {"X-File-Size": file.size.toString()}
|
||||
});
|
||||
|
||||
if (!res1.ok) {
|
||||
console.log(`[W] upload: put file not ok, status = ${res1.status}, res = ${await res1.text()}`)
|
||||
if (res1.status === 401) {
|
||||
window.location.href = "/login?next=/share"
|
||||
return ""
|
||||
}
|
||||
|
||||
throw new Error("上传失败<1>");
|
||||
}
|
||||
|
||||
const j1 = await res1.json() as UploadRes;
|
||||
if (!j1.code) {
|
||||
throw new Error("上传失败<2>");
|
||||
}
|
||||
|
||||
// 2. 准备分片上传
|
||||
const CHUNK_SIZE = 1024 * 1024;
|
||||
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
||||
const code = j1.code;
|
||||
|
||||
// 3. 上传分片
|
||||
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||
const start = chunkIndex * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
|
||||
const res = await fetch(`/api/ushare/${code}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Range": `bytes=${start}-${end - 1}`,
|
||||
"Content-Type": "application/octet-stream"
|
||||
},
|
||||
body: chunk
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`上传失败<3>: ${err}`);
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
// const currentProgress = Number(((chunkIndex + 1) / totalChunks * 100).toFixed(2)); // 小数
|
||||
const currentProgress = Math.round(((chunkIndex + 1) / totalChunks) * 100); // 整数 0-100
|
||||
setProgress(currentProgress);
|
||||
}
|
||||
|
||||
return code;
|
||||
} catch (err) {
|
||||
throw err; // 将错误继续抛出以便组件处理
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { uploadFile, progress, loading, error };
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {ReactNode} from 'react';
|
||||
import {createUseStyles} from 'react-jss';
|
||||
import {createUseStyles} from "react-jss";
|
||||
|
||||
const useStyle = createUseStyles({
|
||||
ubutton: {
|
||||
@@ -10,8 +10,6 @@ const useStyle = createUseStyles({
|
||||
borderRadius: "5px",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 0.3s",
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
"&:hover": {
|
||||
backgroundColor: "#45a049",
|
||||
},
|
||||
@@ -19,84 +17,17 @@ const useStyle = createUseStyles({
|
||||
backgroundColor: "#a5d6a7",
|
||||
cursor: "not-allowed",
|
||||
},
|
||||
},
|
||||
loadingContent: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
},
|
||||
spinner: {
|
||||
animation: '$spin 1s linear infinite',
|
||||
border: '2px solid white',
|
||||
borderTopColor: 'transparent',
|
||||
borderRadius: '50%',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
},
|
||||
'@keyframes spin': {
|
||||
'0%': {transform: 'rotate(0deg)'},
|
||||
'100%': {transform: 'rotate(360deg)'},
|
||||
},
|
||||
progressBar: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
height: '3px',
|
||||
backgroundColor: 'rgba(255,255,255,0.5)',
|
||||
width: '100%',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
backgroundColor: 'white',
|
||||
transition: 'width 0.3s ease',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
})
|
||||
type Props = {
|
||||
onClick?: () => void;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
loading?: boolean;
|
||||
process?: number;
|
||||
style?: React.CSSProperties | undefined;
|
||||
};
|
||||
|
||||
export const UButton: React.FC<Props> = ({
|
||||
onClick,
|
||||
children,
|
||||
disabled = false,
|
||||
style,
|
||||
loading,
|
||||
process
|
||||
}) => {
|
||||
const classes = useStyle();
|
||||
|
||||
return (
|
||||
<button
|
||||
style={style}
|
||||
className={classes.ubutton}
|
||||
disabled={disabled || loading}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* 显示加载状态或默认内容 */}
|
||||
{loading ? (
|
||||
<div className={classes.loadingContent}>
|
||||
<span className={classes.spinner}/>
|
||||
export const UButton: React.FC<Props> = ({onClick, children, style,disabled = false}) => {
|
||||
const classes= useStyle()
|
||||
return <button style={style} className={classes.ubutton} disabled={disabled} onClick={onClick}>
|
||||
{children}
|
||||
{process && `(${process}%)`}
|
||||
</div>
|
||||
) : children}
|
||||
|
||||
{/* 显示进度条 */}
|
||||
{process !== undefined && (
|
||||
<div className={classes.progressBar}>
|
||||
<div
|
||||
className={classes.progressFill}
|
||||
style={{width: `${Math.min(Math.max(process, 0), 100)}%`}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import React, { useEffect, useRef, ReactNode } from 'react';
|
||||
import {createUseStyles} from "react-jss";
|
||||
|
||||
const useClass = createUseStyles({
|
||||
dialog: {
|
||||
border: "none",
|
||||
borderRadius: "8px",
|
||||
padding: "2rem 3rem",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
|
||||
maxWidth: "90%",
|
||||
width: "500px",
|
||||
opacity: 0,
|
||||
transform: "translateY(-20px)",
|
||||
transition: "opacity 0.3s ease-out, transform 0.3s ease-out",
|
||||
"&[open]": { opacity: 1, transform: "translateY(0)" },
|
||||
"&::backdrop": {
|
||||
background: "rgba(0, 0, 0, 0.5)",
|
||||
backdropFilter: "blur(2px)"
|
||||
},
|
||||
background: "rgba(212,212,212,0.85)",
|
||||
backdropFilter: "blur(8px)",
|
||||
},
|
||||
dialog_content: {
|
||||
padding: "1.5rem",
|
||||
display: "flex",
|
||||
flexDirection: "column"
|
||||
},
|
||||
dialog_header: {
|
||||
fontSize: "1.5rem",
|
||||
fontWeight: 600,
|
||||
marginBottom: "1rem",
|
||||
paddingBottom: "0.5rem",
|
||||
borderBottom: "1px solid #eee"
|
||||
},
|
||||
dialog_body: { marginBottom: "1.5rem" },
|
||||
dialog_footer: { display: "flex", justifyContent: "flex-end" },
|
||||
close_button: {
|
||||
padding: "8px 16px",
|
||||
background: "#007aff",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "1rem",
|
||||
"&:hover": { background: "#0062cc" }
|
||||
},
|
||||
})
|
||||
|
||||
export interface DialogProps {
|
||||
/** 对话框是否打开 */
|
||||
open: boolean;
|
||||
/** 对话框标题 */
|
||||
title?: string;
|
||||
/** 对话框内容 */
|
||||
children: ReactNode;
|
||||
/** 关闭对话框时的回调 */
|
||||
onClose: () => void;
|
||||
/** 自定义样式类名 */
|
||||
className?: string;
|
||||
/** 是否显示底部footer(关闭按钮) */
|
||||
footer?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 HTML 原生 dialog 元素的模态对话框组件
|
||||
*/
|
||||
export const Dialog: React.FC<DialogProps> = ({
|
||||
open,
|
||||
title,
|
||||
children,
|
||||
onClose,
|
||||
className = '',
|
||||
footer = true,
|
||||
}) => {
|
||||
const classes = useClass();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ESC 键关闭
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
// 遮罩点击关闭
|
||||
const handleMaskClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === dialogRef.current) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={className}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
zIndex: 1100,
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
backdropFilter: 'blur(2px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onClick={handleMaskClick}
|
||||
>
|
||||
<article className={classes.dialog} style={{opacity: 1, transform: 'translateY(0)'}}>
|
||||
{title && <header className={classes.dialog_header}>{title}</header>}
|
||||
<div className={classes.dialog_body}>
|
||||
{children}
|
||||
</div>
|
||||
{footer !== false && (
|
||||
<footer className={classes.dialog_footer}>
|
||||
<button
|
||||
className={classes.close_button}
|
||||
onClick={onClose}
|
||||
aria-label="关闭对话框"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</footer>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,84 +0,0 @@
|
||||
import React, {useEffect} from 'react';
|
||||
import {createUseStyles} from "react-jss"; // 使用 CSS Modules
|
||||
|
||||
const useClass = createUseStyles({
|
||||
backdrop: {
|
||||
position: "fixed",
|
||||
top: "0",
|
||||
left: "0",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
maxHeight: '100%',
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "flex-end",
|
||||
opacity: 0,
|
||||
transition: "opacity 0.3s ease-in-out",
|
||||
pointerEvents: "none",
|
||||
zIndex: 1000,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
visible: {opacity: 1, pointerEvents: "auto"},
|
||||
drawer_content: {
|
||||
background: "white",
|
||||
borderRadius: "8px 8px 0 0",
|
||||
transition: "transform 0.3s ease-in-out",
|
||||
overflow: "auto",
|
||||
boxShadow: "0 -2px 8px rgba(0, 0, 0, 0.1)"
|
||||
}
|
||||
})
|
||||
|
||||
export interface DrawerProps {
|
||||
isOpen: boolean;
|
||||
close: () => void;
|
||||
onClose?: () => void;
|
||||
height?: string;
|
||||
width?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Drawer: React.FC<DrawerProps> = ({
|
||||
isOpen,
|
||||
close,
|
||||
onClose,
|
||||
height = '300px',
|
||||
width = '100%',
|
||||
children
|
||||
}) => {
|
||||
const classes = useClass();
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
close()
|
||||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${classes.backdrop} ${isOpen ? classes.visible : ''}`}
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
className={classes.drawer_content}
|
||||
style={{
|
||||
height,
|
||||
width,
|
||||
transform: isOpen ? 'translateY(0)' : 'translateY(100%)'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,159 +0,0 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
export const CloudBackground = () => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// 完整可配置参数
|
||||
const config = {
|
||||
cloudNum: 8, // 云朵数量
|
||||
maxSpeed: 3.0, // 最大水平速度
|
||||
cloudSize: 100, // 基础云朵尺寸 (新增)
|
||||
sizeVariation: 0.5, // 尺寸随机变化率 (0-1)
|
||||
colorVariation: 20, // 色相变化范围
|
||||
verticalOscillation: 0.5, // 垂直浮动幅度
|
||||
shapeComplexity: 5, // 形状复杂度(组成圆形数量)
|
||||
boundaryOffset: 3 // 边界偏移倍数
|
||||
};
|
||||
|
||||
type Cloud = {
|
||||
x: number;
|
||||
y: number;
|
||||
speed: number;
|
||||
circles: CloudCircle[];
|
||||
color: string;
|
||||
maxRadius: number; // 记录云朵最大半径
|
||||
};
|
||||
|
||||
type CloudCircle = {
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
radius: number;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current!;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
};
|
||||
resize();
|
||||
|
||||
// 生成云朵形状(基于配置参数)
|
||||
const createCloudShape = () => {
|
||||
const circles: CloudCircle[] = [];
|
||||
const circleCount = 4 + Math.floor(Math.random() * config.shapeComplexity);
|
||||
|
||||
for(let i = 0; i < circleCount; i++) {
|
||||
circles.push({
|
||||
offsetX: (Math.random() - 0.5) * config.cloudSize * 1.5,
|
||||
offsetY: (Math.random() - 0.5) * config.cloudSize * 0.8,
|
||||
radius: config.cloudSize * (1 - config.sizeVariation + Math.random() * config.sizeVariation)
|
||||
});
|
||||
}
|
||||
return circles;
|
||||
};
|
||||
|
||||
let clouds: Cloud[] = [];
|
||||
const createClouds = () => {
|
||||
clouds = Array.from({ length: config.cloudNum }).map(() => {
|
||||
const shape = createCloudShape();
|
||||
return {
|
||||
x: Math.random() * canvas.width,
|
||||
y: canvas.height * (0.2 + Math.random() * 0.6),
|
||||
speed: (Math.random() * 0.5 + 0.5) * config.maxSpeed,
|
||||
circles: shape,
|
||||
color: `hsla(210, 30%, 95%, ${0.8 + Math.random() * 0.2})`,
|
||||
maxRadius: Math.max(...shape.map(c => c.radius)) // 计算最大半径
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const drawCloud = (cloud: Cloud) => {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
|
||||
cloud.circles.forEach(circle => {
|
||||
ctx.moveTo(cloud.x + circle.offsetX, cloud.y + circle.offsetY);
|
||||
ctx.arc(
|
||||
cloud.x + circle.offsetX,
|
||||
cloud.y + circle.offsetY,
|
||||
circle.radius,
|
||||
0,
|
||||
Math.PI * 2
|
||||
);
|
||||
});
|
||||
|
||||
const gradient = ctx.createRadialGradient(
|
||||
cloud.x, cloud.y, 0,
|
||||
cloud.x, cloud.y, config.cloudSize * 2
|
||||
);
|
||||
gradient.addColorStop(0, cloud.color);
|
||||
gradient.addColorStop(1, `hsla(210, 50%, 98%, 0.3)`);
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.filter = `blur(${config.cloudSize * 0.2}px)`; // 模糊与尺寸关联
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
let animationFrameId: number;
|
||||
const animate = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 天空渐变背景
|
||||
const skyGradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
|
||||
skyGradient.addColorStop(0, '#e6f3ff');
|
||||
skyGradient.addColorStop(1, '#d1e8ff');
|
||||
ctx.fillStyle = skyGradient;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
clouds.forEach(cloud => {
|
||||
cloud.x += cloud.speed;
|
||||
cloud.y += Math.sin(Date.now() / 1000 + cloud.x) * config.verticalOscillation;
|
||||
|
||||
// 基于实际最大半径的边界检测
|
||||
const resetPosition = cloud.x > canvas.width + (cloud.maxRadius * config.boundaryOffset);
|
||||
if (resetPosition) {
|
||||
cloud.x = -cloud.maxRadius * config.boundaryOffset;
|
||||
// 重置时重新生成形状
|
||||
const newShape = createCloudShape();
|
||||
cloud.circles = newShape;
|
||||
cloud.maxRadius = Math.max(...newShape.map(c => c.radius));
|
||||
}
|
||||
|
||||
drawCloud(cloud);
|
||||
});
|
||||
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
createClouds();
|
||||
animate();
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
resize();
|
||||
createClouds();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize);
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: -1,
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
export const AnimatedBackground = () => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// 粒子配置
|
||||
const config = {
|
||||
particleNum: 100,
|
||||
maxSpeed: 1.5,
|
||||
particleRadius: 2,
|
||||
lineWidth: 1.5,
|
||||
lineDistance: 100
|
||||
};
|
||||
|
||||
type Particle = {
|
||||
x: number;
|
||||
y: number;
|
||||
speedX: number;
|
||||
speedY: number;
|
||||
color: string;
|
||||
radius: number;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current!;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
// 设置canvas尺寸
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
};
|
||||
resize();
|
||||
|
||||
// 创建粒子数组
|
||||
let particles: Particle[] = [];
|
||||
const createParticles = () => {
|
||||
particles = Array.from({ length: config.particleNum }).map(() => ({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
speedX: (Math.random() - 0.5) * config.maxSpeed,
|
||||
speedY: (Math.random() - 0.5) * config.maxSpeed,
|
||||
color: `hsl(${Math.random() * 360}, 70%, 60%)`,
|
||||
radius: Math.random() * config.particleRadius + 1
|
||||
}));
|
||||
};
|
||||
|
||||
// 绘制连线
|
||||
const drawLine = (p1: Particle, p2: Particle) => {
|
||||
const dx = p1.x - p2.x;
|
||||
const dy = p1.y - p2.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist < config.lineDistance) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = p1.color;
|
||||
ctx.lineWidth = config.lineWidth * (1 - dist / config.lineDistance);
|
||||
ctx.moveTo(p1.x, p1.y);
|
||||
ctx.lineTo(p2.x, p2.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
};
|
||||
|
||||
// 动画循环
|
||||
let animationFrameId: number;
|
||||
const animate = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 更新粒子位置
|
||||
particles.forEach(particle => {
|
||||
particle.x += particle.speedX;
|
||||
particle.y += particle.speedY;
|
||||
|
||||
// 边界反弹
|
||||
if (particle.x < 0 || particle.x > canvas.width) particle.speedX *= -1;
|
||||
if (particle.y < 0 || particle.y > canvas.height) particle.speedY *= -1;
|
||||
|
||||
// 绘制粒子
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = particle.color;
|
||||
ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
// 绘制粒子间的连线
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
drawLine(particles[i], particles[j]);
|
||||
}
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
// 初始化
|
||||
createParticles();
|
||||
animate();
|
||||
|
||||
// 窗口resize处理
|
||||
window.addEventListener('resize', () => {
|
||||
resize();
|
||||
createParticles();
|
||||
});
|
||||
|
||||
// 清理
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize);
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: -1,
|
||||
backgroundColor: '#1a1a1a'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
// MessageContext.tsx
|
||||
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import './Message.css';
|
||||
|
||||
export interface Message {
|
||||
id: number;
|
||||
content: string;
|
||||
type: 'info' | 'success' | 'warning' | 'error';
|
||||
}
|
||||
|
||||
export type MessageType = Message['type'];
|
||||
|
||||
const MessageContext = createContext<{
|
||||
addMessage: (content: string, type?: MessageType, duration?: number) => void;
|
||||
removeMessage: (id: number) => void;
|
||||
}>({
|
||||
addMessage: () => {},
|
||||
removeMessage: () => {},
|
||||
});
|
||||
|
||||
export const MessageProvider: React.FC = ({ children }) => {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const timerRef = useRef<Record<string, NodeJS.Timeout>>({});
|
||||
|
||||
const addMessage = useCallback((content: string, type: MessageType = 'info', duration: number = 3000) => {
|
||||
const id = Date.now();
|
||||
setMessages(prev => [...prev, { id, content, type }]);
|
||||
|
||||
timerRef.current[id] = setTimeout(() => {
|
||||
removeMessage(id);
|
||||
}, duration);
|
||||
}, []);
|
||||
|
||||
const removeMessage = useCallback((id: number) => {
|
||||
setMessages(prev => prev.filter(msg => msg.id !== id));
|
||||
clearTimeout(timerRef.current[id]);
|
||||
delete timerRef.current[id];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MessageContext.Provider value={{ addMessage, removeMessage }}>
|
||||
{children}
|
||||
{ReactDOM.createPortal(
|
||||
<div className="message-container">
|
||||
{messages.map(({ id, content, type }) => (
|
||||
<MessageItem
|
||||
key={id}
|
||||
id={id}
|
||||
content={content}
|
||||
type={type}
|
||||
onClose={() => removeMessage(id)}
|
||||
/>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</MessageContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
interface MessageItemProps {
|
||||
id: number;
|
||||
content: string;
|
||||
type: MessageType;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const MessageItem: React.FC<MessageItemProps> = ({ id, content, type, onClose }) => {
|
||||
return (
|
||||
<div className={`message-item ${type}`}>
|
||||
{content}
|
||||
<button onClick={onClose}>×</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const useMessage = (): ReturnType<typeof MessageContext> => {
|
||||
const context = useContext(MessageContext);
|
||||
if (!context) {
|
||||
throw new Error('useMessage must be used within a MessageProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,126 +0,0 @@
|
||||
import {createRoot} from "react-dom/client";
|
||||
import {useState} from "react";
|
||||
import {createUseStyles} from "react-jss";
|
||||
|
||||
const useStyle = createUseStyles({
|
||||
container: {
|
||||
position: 'fixed',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
zIndex: 10000,
|
||||
top: '20px',
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
},
|
||||
message: {
|
||||
width: '100%',
|
||||
maxWidth: '300px',
|
||||
background: '#fafafa', // 浅灰背景与其他状态统一
|
||||
borderRadius: '8px',
|
||||
padding: '10px 10px 10px 20px', // 统一左侧20px留白
|
||||
|
||||
borderLeft: '4px solid #e0e0e0', // 加粗为4px与其他状态一致
|
||||
color: '#757575', // 中性灰文字
|
||||
|
||||
boxShadow: '0 2px 6px rgba(224, 224, 224, 0.15)', // 灰色投影
|
||||
transition: 'all 0.3s ease-in-out', // 补全时间单位
|
||||
|
||||
marginBottom: '20px',
|
||||
|
||||
'&.success': {
|
||||
color: '#2e7d32',
|
||||
backgroundColor: '#f0f9eb',
|
||||
borderLeft: '4px solid #4CAF50',
|
||||
paddingLeft: '20px',
|
||||
boxShadow: '0 2px 6px rgba(76, 175, 80, 0.15)'
|
||||
},
|
||||
'&.warning': {
|
||||
color: '#faad14', // 警告文字色
|
||||
backgroundColor: '#fffbe6', // 浅黄色背景
|
||||
borderLeft: '4px solid #ffc53d', // 琥珀色左侧标识
|
||||
paddingLeft: '20px',
|
||||
boxShadow: '0 2px 6px rgba(255, 197, 61, 0.15)' // 金色投影
|
||||
},
|
||||
'&.error': {
|
||||
color: '#f5222d', // 错误文字色
|
||||
backgroundColor: '#fff1f0', // 浅红色背景
|
||||
borderLeft: '4px solid #ff4d4f', // 品红色左侧标识
|
||||
paddingLeft: '20px',
|
||||
boxShadow: '0 2px 6px rgba(255, 77, 79, 0.15)' // 红色投影
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let el = document.querySelector("#u-message")
|
||||
if (!el) {
|
||||
el = document.createElement('div')
|
||||
el.className = 'u-message'
|
||||
el.id = 'u-message'
|
||||
document.body.append(el)
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: number
|
||||
content: string
|
||||
duration: number
|
||||
type: 'info' | 'success' | 'warning' | 'error'
|
||||
}
|
||||
|
||||
export interface MessageApi {
|
||||
info: (content: string, duration?: number) => void;
|
||||
warning: (content: string, duration?: number) => void;
|
||||
success: (content: string, duration?: number) => void;
|
||||
error: (content: string, duration?: number) => void;
|
||||
}
|
||||
|
||||
const default_duration = 3000
|
||||
|
||||
|
||||
let add: (msg: Message) => void;
|
||||
|
||||
const MessageContainer: React.FC = () => {
|
||||
const classes = useStyle()
|
||||
const [msgs, setMsgs] = useState<Message[]>([]);
|
||||
|
||||
const remove = (id: number) => {
|
||||
setMsgs(prevMsgs => prevMsgs.filter(v => v.id !== id));
|
||||
}
|
||||
|
||||
add = (msg: Message) => {
|
||||
const id = Date.now();
|
||||
setMsgs(prevMsgs => {
|
||||
const newMsgs = [{...msg, id}, ...prevMsgs];
|
||||
// 直接限制数组长度为5,移除最旧的消息(最后一项)
|
||||
if (newMsgs.length > 5) {
|
||||
newMsgs.pop();
|
||||
}
|
||||
return newMsgs;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
remove(id);
|
||||
}, msg.duration ?? default_duration);
|
||||
}
|
||||
|
||||
return <div className={classes.container}>
|
||||
{msgs.map(m => <div key={m.id} className={`${classes.message} ${m.type}`}>{m.content}</div>)}
|
||||
</div>
|
||||
}
|
||||
|
||||
createRoot(el).render(<MessageContainer/>)
|
||||
|
||||
export const message: MessageApi = {
|
||||
info: function (content: string, duration?: number): void {
|
||||
add({content: content, duration: duration, type: "info"} as Message)
|
||||
},
|
||||
warning: function (content: string, duration?: number): void {
|
||||
add({content: content, duration: duration, type: "warning"} as Message)
|
||||
},
|
||||
success: function (content: string, duration?: number): void {
|
||||
add({content: content, duration: duration, type: "success"} as Message)
|
||||
},
|
||||
error: function (content: string, duration?: number): void {
|
||||
add({content: content, duration: duration, type: "error"} as Message)
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
export interface Prop {
|
||||
/** 事件处理函数(可选) */
|
||||
fn?: (event: MessageEvent) => Promise<void>;
|
||||
/** 最大重试次数(可选) */
|
||||
retry?: number;
|
||||
}
|
||||
|
||||
export const useWebsocket = (prop?: Prop) => {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const retryCountRef = useRef(0);
|
||||
const reconnectTimerRef = useRef<number>(0);
|
||||
const currentPropRef = useRef(prop);
|
||||
|
||||
// 更新最新 prop
|
||||
currentPropRef.current = prop;
|
||||
|
||||
const connect = useCallback((url: string, connectProp?: Prop) => {
|
||||
// 合并 prop 优先级:connectProp > hook prop
|
||||
const mergedProp = { ...currentPropRef.current, ...connectProp };
|
||||
|
||||
// 清理现有连接
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
|
||||
const createConnection = () => {
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
retryCountRef.current = 0;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
mergedProp?.fn?.(event).catch(error => {
|
||||
console.error('WebSocket message handler error:', error);
|
||||
});
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
const maxRetries = mergedProp?.retry ?? 0;
|
||||
|
||||
if (!event.wasClean && retryCountRef.current < maxRetries) {
|
||||
retryCountRef.current += 1;
|
||||
const retryDelay = Math.pow(2, retryCountRef.current) * 1000;
|
||||
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
createConnection();
|
||||
}, retryDelay);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
ws.close();
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
};
|
||||
|
||||
createConnection();
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
wsRef.current?.close();
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
retryCountRef.current = currentPropRef.current?.retry || 0;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
connect,
|
||||
close
|
||||
};
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
export interface Resp<T>{
|
||||
status: number;
|
||||
msg: string;
|
||||
err: string;
|
||||
data: T;
|
||||
}
|
||||
+6
-19
@@ -1,23 +1,10 @@
|
||||
// import { StrictMode } from 'react'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import {createBrowserRouter, RouterProvider} from "react-router-dom";
|
||||
import {Login} from "./page/login.tsx";
|
||||
import {FileSharing} from "./page/share/share.tsx";
|
||||
import {LocalSharing} from "./page/local/local.tsx";
|
||||
import {TestPage} from "./page/test/test.tsx";
|
||||
import {App} from './App.tsx'
|
||||
|
||||
const container = document.getElementById('root')
|
||||
const root = createRoot(container!)
|
||||
const router = createBrowserRouter([
|
||||
{path: "/login", element: <Login />},
|
||||
{path: "/share", element: <FileSharing />},
|
||||
{path: "/test", element: <TestPage />},
|
||||
{path: "*", element: <LocalSharing />},
|
||||
])
|
||||
|
||||
root.render(
|
||||
// <StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
// </StrictMode>,
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import {createUseStyles} from "react-jss";
|
||||
import {UButton} from "../../component/button/u-button.tsx";
|
||||
import React from "react";
|
||||
import {useStore} from "../../store/share.ts";
|
||||
|
||||
const useStyle = createUseStyles({
|
||||
container: {
|
||||
backgroundColor: "#e3f2fd",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
form: {
|
||||
backgroundColor: "#C8E6C9",
|
||||
boxShadow: "inset 0 0 15px rgba(56, 142, 60, 0.15)",
|
||||
padding: "30px",
|
||||
borderRadius: "15px",
|
||||
width: "70%",
|
||||
margin: "20px 60px 20px 0",
|
||||
/*todo margin 不用 px*/
|
||||
},
|
||||
title: {
|
||||
color: "#2c9678"
|
||||
},
|
||||
file: {
|
||||
display: 'none',
|
||||
},
|
||||
preview: {
|
||||
marginTop: '10px',
|
||||
display: 'flex',
|
||||
},
|
||||
name: {
|
||||
color: "#2c9678",
|
||||
marginLeft: '10px'
|
||||
},
|
||||
clean: {
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {}
|
||||
}
|
||||
})
|
||||
export const PanelLeft = () => {
|
||||
const style = useStyle()
|
||||
const {file, setFile} = useStore()
|
||||
|
||||
function onFileSelect() {
|
||||
// @ts-ignore
|
||||
document.querySelector('#real-file-input').click();
|
||||
}
|
||||
|
||||
function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
console.log('[D] onFileChange: e =', e)
|
||||
setFile(e.currentTarget.files ? e.currentTarget.files[0] : null)
|
||||
}
|
||||
|
||||
function onFileUpload() {
|
||||
console.log(`[D] onFileUpload: upload file = ${file?.name}, size = ${file?.size}`, file)
|
||||
}
|
||||
|
||||
function onFileClean() {
|
||||
setFile(null)
|
||||
}
|
||||
|
||||
return <div className={style.container}>
|
||||
<div className={style.form}>
|
||||
<h2 className={style.title}>上传文件</h2>
|
||||
{
|
||||
!file &&
|
||||
<UButton onClick={onFileSelect}>选择文件</UButton>
|
||||
}
|
||||
{
|
||||
file &&
|
||||
<UButton onClick={onFileUpload}>上传文件</UButton>
|
||||
}
|
||||
<input type="file" className={style.file} id="real-file-input" onChange={onFileChange}/>
|
||||
{
|
||||
file &&
|
||||
<div className={style.preview}>
|
||||
<div className={style.clean} onClick={onFileClean}>×</div>
|
||||
<div className={style.name}>{file.name}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
-1
@@ -2,7 +2,6 @@ import {createUseStyles} from "react-jss";
|
||||
|
||||
const useStyle = createUseStyles({
|
||||
container: {
|
||||
backgroundColor: 'lightgray',
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
},
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
import {createUseStyles} from "react-jss";
|
||||
import {UButton} from "../../../component/button/u-button.tsx";
|
||||
import {useStore} from "../../../store/share.ts";
|
||||
import {UButton} from "../../component/button/u-button.tsx";
|
||||
import {useStore} from "../../store/share.ts";
|
||||
|
||||
const useStyle = createUseStyles({
|
||||
container: {
|
||||
@@ -41,7 +41,8 @@ export const PanelRight = () => {
|
||||
}
|
||||
|
||||
async function onFetchFile() {
|
||||
const url = `/ushare/${code}`
|
||||
const url = `/api/share/fetch?code=${code}`
|
||||
console.log('[D] onFetchFile: url =', url)
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
document.body.appendChild(link);
|
||||
@@ -1,75 +0,0 @@
|
||||
import {Bubble, Client} from "./types.ts";
|
||||
|
||||
// 生成随机颜色
|
||||
export const generateColor = () => {
|
||||
const hue = Math.random() * 360;
|
||||
return `hsla(${hue},
|
||||
${Math.random() * 30 + 40}%,
|
||||
${Math.random() * 10 + 75}%, 0.9)`;
|
||||
};
|
||||
|
||||
// 防碰撞位置生成
|
||||
export const generateBubbles = (clients: Client[], currentUserId: string): Bubble[] => {
|
||||
if (!clients) return [];
|
||||
|
||||
const BUBBLE_SIZE = 100;
|
||||
const centerX = window.innerWidth / 2;
|
||||
const centerY = window.innerHeight / 2;
|
||||
|
||||
const bubbles: Bubble[] = [];
|
||||
let currentRadius = 0;
|
||||
let angleStep = (2 * Math.PI) / 6; // 初始6个位置
|
||||
|
||||
for (let index = 0; index < clients.length; index++) {
|
||||
let attempt = 0;
|
||||
let validPosition = false;
|
||||
|
||||
if (clients[index].id === currentUserId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
while (!validPosition && attempt < 100) {
|
||||
// 螺旋布局算法
|
||||
const angle = angleStep * (index + attempt);
|
||||
const radius = currentRadius + (attempt * BUBBLE_SIZE * 0.8);
|
||||
|
||||
// 极坐标转笛卡尔坐标
|
||||
const x = centerX + radius * Math.cos(angle);
|
||||
const y = centerY + radius * Math.sin(angle);
|
||||
|
||||
// 边界检测
|
||||
const inBounds = x >= 0 && x <= window.innerWidth - BUBBLE_SIZE &&
|
||||
y >= 0 && y <= window.innerHeight - BUBBLE_SIZE;
|
||||
|
||||
// 碰撞检测
|
||||
const collision = bubbles.some(pos => {
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(pos.x - x, 2) +
|
||||
Math.pow(pos.y - y, 2)
|
||||
);
|
||||
return distance < BUBBLE_SIZE * 1.5;
|
||||
});
|
||||
|
||||
if (inBounds && !collision) {
|
||||
bubbles.push({
|
||||
id: clients[index].id,
|
||||
name: clients[index].name,
|
||||
x: x,
|
||||
y: y,
|
||||
color: generateColor(),
|
||||
radius: 0,
|
||||
angle: 0
|
||||
});
|
||||
|
||||
// 动态调整布局参数
|
||||
currentRadius = Math.max(currentRadius, radius);
|
||||
angleStep = (2 * Math.PI) / Math.max(6, bubbles.length * 0.7);
|
||||
validPosition = true;
|
||||
}
|
||||
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
|
||||
return bubbles;
|
||||
};
|
||||
@@ -1,163 +0,0 @@
|
||||
import React, {useState} from 'react';
|
||||
import {Dialog} from "../../../component/dialog/dialog.tsx";
|
||||
import {ReceivedMessage} from "./types.ts";
|
||||
import {createUseStyles} from "react-jss";
|
||||
|
||||
const useStyles = createUseStyles({
|
||||
root: {
|
||||
marginBottom: '1rem',
|
||||
},
|
||||
sender: {
|
||||
margin: '0 0 0.5rem 0',
|
||||
fontSize: '0.9rem',
|
||||
color: '#666',
|
||||
},
|
||||
msgBox: {
|
||||
background: 'rgba(255,255,255,0.96)',
|
||||
color: '#222',
|
||||
padding: '1rem',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #ddd',
|
||||
maxHeight: '400px',
|
||||
minHeight: '80px',
|
||||
overflowY: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
},
|
||||
downloadLink: {
|
||||
color: '#007aff',
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
btnRow: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: '0.5rem',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
marginTop: '8px',
|
||||
},
|
||||
copySuccess: {
|
||||
color: '#28a745',
|
||||
fontSize: '0.9rem',
|
||||
marginRight: '0.5rem',
|
||||
animation: 'fadeIn 0.3s ease-in',
|
||||
},
|
||||
copyBtn: {
|
||||
padding: '8px 16px',
|
||||
background: '#007aff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
background: '#0056b3',
|
||||
},
|
||||
},
|
||||
copyBtnSuccess: {
|
||||
background: '#28a745',
|
||||
},
|
||||
closeBtn: {
|
||||
padding: '8px 16px',
|
||||
background: '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
progressBar: {
|
||||
width: '100%',
|
||||
height: 8,
|
||||
background: '#eee',
|
||||
borderRadius: 4,
|
||||
margin: '12px 0',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressInner: {
|
||||
height: '100%',
|
||||
background: '#4dabf7',
|
||||
transition: 'width 0.3s',
|
||||
},
|
||||
});
|
||||
|
||||
interface MessageDialogProps {
|
||||
open: boolean;
|
||||
message: ReceivedMessage | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const MessageDialog: React.FC<MessageDialogProps> = ({open, message, onClose}) => {
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
const classes = useStyles();
|
||||
|
||||
const handleCopyMessage = () => {
|
||||
if (message) {
|
||||
navigator.clipboard.writeText(message.text || '').then(() => {
|
||||
setCopySuccess(true);
|
||||
setTimeout(() => setCopySuccess(false), 2000);
|
||||
}).catch(() => {
|
||||
alert('复制失败,请手动复制');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title="收到新消息"
|
||||
onClose={onClose}
|
||||
footer={false}
|
||||
>
|
||||
<div className={classes.root}>
|
||||
<p className={classes.sender}>
|
||||
来自: {message?.sender}
|
||||
</p>
|
||||
<div className={classes.msgBox}>
|
||||
{message?.isFile ? (
|
||||
<>
|
||||
<div>📎 文件: {message.fileName} ({message.fileSize ? (message.fileSize/1024).toFixed(1) : ''} KB)</div>
|
||||
{message.receiving ? (
|
||||
<>
|
||||
<div style={{fontSize: '0.95em', color: '#888'}}>正在接收... {Math.round((message.progress||0)*100)}%</div>
|
||||
<div className={classes.progressBar}>
|
||||
<div className={classes.progressInner} style={{width: `${Math.round((message.progress||0)*100)}%`}} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<a href={message.fileBlobUrl} download={message.fileName} className={classes.downloadLink}>
|
||||
点击下载
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
message?.text
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.btnRow}>
|
||||
{copySuccess && (
|
||||
<span className={classes.copySuccess}>
|
||||
✓ 已复制
|
||||
</span>
|
||||
)}
|
||||
{!message?.isFile && (
|
||||
<button
|
||||
onClick={handleCopyMessage}
|
||||
className={copySuccess ? `${classes.copyBtn} ${classes.copyBtnSuccess}` : classes.copyBtn}
|
||||
>
|
||||
{copySuccess ? '已复制' : '复制消息'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={classes.closeBtn}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,323 +0,0 @@
|
||||
import { WSMessage, ReceivedMessage} from "./types.ts";
|
||||
import {useLocalStore} from "../../../store/local.ts";
|
||||
|
||||
// 文件接收缓存
|
||||
const fileReceiveCache: Record<string, {chunks: ArrayBuffer[], total: number, received: number, name: string, size: number, sender: string, timestamp: number}> = {};
|
||||
|
||||
export const handleFileChunk = (chunk: any, onFileReceived: (msg: import("./types").ReceivedMessage) => void) => {
|
||||
if (chunk.type === 'file') {
|
||||
// 文件元信息,初始化缓存
|
||||
fileReceiveCache[chunk.name + '_' + chunk.timestamp] = {
|
||||
chunks: new Array(chunk.totalChunks),
|
||||
total: chunk.totalChunks,
|
||||
received: 0,
|
||||
name: chunk.name,
|
||||
size: chunk.size,
|
||||
sender: chunk.sender,
|
||||
timestamp: chunk.timestamp
|
||||
};
|
||||
// 首次弹出进度
|
||||
onFileReceived({
|
||||
sender: chunk.sender,
|
||||
timestamp: chunk.timestamp,
|
||||
fileName: chunk.name,
|
||||
fileSize: chunk.size,
|
||||
isFile: true,
|
||||
progress: 0,
|
||||
receiving: true
|
||||
});
|
||||
} else if (chunk.type === 'file-chunk') {
|
||||
const key = chunk.name + '_' + chunk.timestamp;
|
||||
const cache = fileReceiveCache[key];
|
||||
if (cache) {
|
||||
// 将 data 数组还原为 ArrayBuffer
|
||||
const uint8 = new Uint8Array(chunk.data);
|
||||
cache.chunks[chunk.chunkIndex] = uint8.buffer;
|
||||
cache.received++;
|
||||
// 实时回调进度
|
||||
if (cache.received < cache.total) {
|
||||
onFileReceived({
|
||||
sender: cache.sender,
|
||||
timestamp: cache.timestamp,
|
||||
fileName: cache.name,
|
||||
fileSize: cache.size,
|
||||
isFile: true,
|
||||
progress: cache.received / cache.total,
|
||||
receiving: true
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (chunk.type === 'file-end') {
|
||||
const key = chunk.name + '_' + chunk.timestamp;
|
||||
const cache = fileReceiveCache[key];
|
||||
if (cache && cache.received === cache.total) {
|
||||
const blob = new Blob(cache.chunks);
|
||||
const url = URL.createObjectURL(blob);
|
||||
onFileReceived({
|
||||
sender: cache.sender,
|
||||
timestamp: cache.timestamp,
|
||||
fileName: cache.name,
|
||||
fileSize: cache.size,
|
||||
fileBlobUrl: url,
|
||||
isFile: true,
|
||||
progress: 1,
|
||||
receiving: false
|
||||
});
|
||||
delete fileReceiveCache[key];
|
||||
} else if (cache) {
|
||||
// 分块未齐全,提示异常
|
||||
onFileReceived({
|
||||
sender: cache.sender,
|
||||
timestamp: cache.timestamp,
|
||||
fileName: cache.name,
|
||||
fileSize: cache.size,
|
||||
isFile: true,
|
||||
progress: cache.received / cache.total,
|
||||
receiving: true,
|
||||
text: '文件分块未齐全,接收失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export interface RTCHandlerCallbacks {
|
||||
onChannelOpen: (type: 'sender' | 'receiver') => void;
|
||||
onMessageReceived: (message: ReceivedMessage) => void;
|
||||
onChannelClose: () => void;
|
||||
}
|
||||
|
||||
export class RTCHandler {
|
||||
private rtcRef: React.MutableRefObject<RTCPeerConnection | null>;
|
||||
private callbacks: RTCHandlerCallbacks;
|
||||
|
||||
constructor(rtcRef: React.MutableRefObject<RTCPeerConnection | null>, callbacks: RTCHandlerCallbacks) {
|
||||
this.rtcRef = rtcRef;
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
// 更新回调函数的方法
|
||||
updateCallbacks = (newCallbacks: RTCHandlerCallbacks) => {
|
||||
this.callbacks = newCallbacks;
|
||||
};
|
||||
|
||||
setupDataChannel = async (ch: RTCDataChannel, type: 'sender' | 'receiver') => {
|
||||
ch.onopen = () => {
|
||||
console.log(`[D] 通道已打开!类型: ${type}`);
|
||||
this.callbacks.onChannelOpen(type);
|
||||
useLocalStore.getState().setChannel(ch);
|
||||
};
|
||||
|
||||
ch.onmessage = (e) => {
|
||||
// console.log('[D] Received message:', e.data);
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'message') {
|
||||
// 处理文本消息
|
||||
const message: ReceivedMessage = {
|
||||
text: data.content,
|
||||
timestamp: Date.now(),
|
||||
sender: data.sender || '未知用户'
|
||||
};
|
||||
this.callbacks.onMessageReceived(message);
|
||||
} else if (data.type === 'file' || data.type === 'file-chunk' || data.type === 'file-end') {
|
||||
// 处理文件相关消息
|
||||
handleFileChunk(data, this.callbacks.onMessageReceived);
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果不是JSON格式,当作普通文本处理
|
||||
const message: ReceivedMessage = {
|
||||
text: e.data,
|
||||
timestamp: Date.now(),
|
||||
sender: '未知用户'
|
||||
};
|
||||
this.callbacks.onMessageReceived(message);
|
||||
}
|
||||
};
|
||||
|
||||
ch.onclose = () => {
|
||||
console.log('[D] 通道关闭');
|
||||
this.callbacks.onChannelClose();
|
||||
useLocalStore.getState().setChannel();
|
||||
};
|
||||
};
|
||||
|
||||
handleBubbleClick = async (bubbleId: string, currentUserId: string) => {
|
||||
const current_rtc = this.rtcRef.current;
|
||||
if (!current_rtc) return;
|
||||
|
||||
current_rtc.onnegotiationneeded = async () => {
|
||||
const offer = await current_rtc.createOffer();
|
||||
await current_rtc.setLocalDescription(offer);
|
||||
const data = {
|
||||
id: bubbleId,
|
||||
from: currentUserId,
|
||||
offer: offer,
|
||||
};
|
||||
await fetch('/api/ulocal/offer', {
|
||||
method: 'POST',
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
};
|
||||
|
||||
current_rtc.onicecandidate = async (e) => {
|
||||
await fetch('/api/ulocal/candidate', {
|
||||
method: 'POST',
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({candidate: e.candidate, id: currentUserId})
|
||||
});
|
||||
};
|
||||
|
||||
const ch = current_rtc.createDataChannel('local', {ordered: true});
|
||||
await this.setupDataChannel(ch, 'sender');
|
||||
};
|
||||
|
||||
handleWSEvent = async (e: MessageEvent) => {
|
||||
let current_id: string;
|
||||
let current_rtc: RTCPeerConnection | null;
|
||||
const msg = JSON.parse(e.data) as WSMessage;
|
||||
// console.log('[D] ws event msg =', msg);
|
||||
|
||||
switch (msg.type) {
|
||||
case "enter":
|
||||
case "leave":
|
||||
// 这些事件由父组件处理
|
||||
return;
|
||||
case "offer":
|
||||
const offer_data = msg.data as { id: string; from: string; offer: RTCSessionDescriptionInit };
|
||||
current_id = useLocalStore.getState().id;
|
||||
if (offer_data.id !== current_id) {
|
||||
console.warn(`[W] wrong offer id, want = ${current_id}, got = ${offer_data.id}, data =`, offer_data);
|
||||
return;
|
||||
}
|
||||
|
||||
current_rtc = this.rtcRef.current;
|
||||
if (!current_rtc) {
|
||||
console.warn('[W] rtc undefined');
|
||||
return;
|
||||
}
|
||||
|
||||
await current_rtc.setRemoteDescription(offer_data.offer);
|
||||
const answer = await current_rtc.createAnswer();
|
||||
if (!answer) {
|
||||
console.log('[W] answer undefined');
|
||||
return;
|
||||
}
|
||||
|
||||
await current_rtc.setLocalDescription(answer);
|
||||
|
||||
current_rtc.ondatachannel = (e) => {
|
||||
this.setupDataChannel(e.channel, 'receiver');
|
||||
};
|
||||
|
||||
await fetch('/api/ulocal/answer', {
|
||||
method: 'POST',
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({id: offer_data.from, answer: answer})
|
||||
});
|
||||
return;
|
||||
|
||||
case "answer":
|
||||
const answer_data = msg.data as { answer: RTCSessionDescriptionInit; id: string };
|
||||
current_id = useLocalStore.getState().id;
|
||||
if (answer_data.id !== current_id) {
|
||||
console.warn(`[W] wrong answer id, want = ${current_id}, got = ${answer_data.id}, data =`, answer_data);
|
||||
}
|
||||
|
||||
current_rtc = this.rtcRef.current;
|
||||
if (!current_rtc) {
|
||||
console.warn('[W] rtc undefined');
|
||||
return;
|
||||
}
|
||||
|
||||
await current_rtc.setRemoteDescription(answer_data.answer);
|
||||
return;
|
||||
|
||||
case "candidate":
|
||||
const candidate_data = msg.data as { candidate: RTCIceCandidateInit };
|
||||
current_rtc = this.rtcRef.current;
|
||||
if (!current_rtc) {
|
||||
console.warn('[W] rtc undefined');
|
||||
return;
|
||||
}
|
||||
if (!candidate_data.candidate) {
|
||||
return;
|
||||
}
|
||||
await current_rtc.addIceCandidate(candidate_data.candidate);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
sendMessage = (msg: string, files: File[], senderName: string) => {
|
||||
const ch = useLocalStore.getState().channel;
|
||||
const CHUNK_SIZE = 64 * 1024; // 64KB
|
||||
const BUFFERED_AMOUNT_THRESHOLD = 1 * 1024 * 1024; // 1MB
|
||||
if (ch && ch.readyState === 'open') {
|
||||
if (msg.trim()) {
|
||||
// 发送文本消息
|
||||
const messageData = {
|
||||
type: 'message',
|
||||
content: msg,
|
||||
sender: senderName,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
ch.send(JSON.stringify(messageData));
|
||||
}
|
||||
|
||||
if (files && files.length > 0) {
|
||||
files.forEach(file => {
|
||||
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
||||
const timestamp = Date.now();
|
||||
// 先发送文件元信息
|
||||
const fileData = {
|
||||
type: 'file',
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
sender: senderName,
|
||||
timestamp,
|
||||
totalChunks
|
||||
};
|
||||
ch.send(JSON.stringify(fileData));
|
||||
|
||||
let offset = 0;
|
||||
let chunkIndex = 0;
|
||||
const reader = new FileReader();
|
||||
|
||||
const sendNextChunk = () => {
|
||||
if (offset >= file.size) {
|
||||
// 分块全部发送完毕,发送 file-end
|
||||
ch.send(JSON.stringify({
|
||||
type: 'file-end',
|
||||
name: file.name,
|
||||
timestamp,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (ch.bufferedAmount > BUFFERED_AMOUNT_THRESHOLD) {
|
||||
ch.addEventListener('bufferedamountlow', sendNextChunk, { once: true });
|
||||
return;
|
||||
}
|
||||
const slice = file.slice(offset, offset + CHUNK_SIZE);
|
||||
reader.onload = (e) => {
|
||||
if (e.target?.result) {
|
||||
ch.send(JSON.stringify({
|
||||
type: 'file-chunk',
|
||||
name: file.name,
|
||||
timestamp,
|
||||
chunkIndex,
|
||||
totalChunks,
|
||||
data: Array.from(new Uint8Array(e.target.result as ArrayBuffer)),
|
||||
}));
|
||||
offset += CHUNK_SIZE;
|
||||
chunkIndex++;
|
||||
sendNextChunk();
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(slice);
|
||||
};
|
||||
sendNextChunk();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import React from 'react';
|
||||
import {Dialog} from '../../../component/dialog/dialog.tsx';
|
||||
import {Sender} from './sender.tsx';
|
||||
|
||||
interface SendDialogProps {
|
||||
open: boolean;
|
||||
onSend: (msg: string, files: File[]) => void;
|
||||
onClose: () => void;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const SendDialog: React.FC<SendDialogProps> = ({open, onSend, onClose, name}) => {
|
||||
return (
|
||||
<Dialog open={open} title={`发送消息给${name}`} onClose={onClose}>
|
||||
<Sender onSend={onSend} />
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,161 +0,0 @@
|
||||
import React, {useRef, useState} from 'react';
|
||||
import {createUseStyles} from "react-jss";
|
||||
import { message } from '../../../hook/message/u-message.tsx';
|
||||
|
||||
const useClass = createUseStyles({
|
||||
container: {
|
||||
width: "100%",
|
||||
minHeight: "260px",
|
||||
margin: "0",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "8px",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
background: "#fff",
|
||||
boxSizing: "border-box",
|
||||
padding: "20px 16px 60px 16px"
|
||||
},
|
||||
input_box: {
|
||||
width: "100%",
|
||||
minHeight: "180px",
|
||||
padding: "12px",
|
||||
boxSizing: "border-box",
|
||||
border: "none",
|
||||
resize: "vertical",
|
||||
outline: "none",
|
||||
fontFamily: "Arial, sans-serif",
|
||||
fontSize: "1.1rem",
|
||||
transition: "padding 0.3s ease",
|
||||
background: "#f8fafd",
|
||||
borderRadius: "6px"
|
||||
},
|
||||
input_has_files: {
|
||||
paddingTop: '50px',
|
||||
},
|
||||
file_list: {
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
left: "0",
|
||||
right: "0",
|
||||
padding: "8px 10px",
|
||||
background: "rgba(255, 255, 255, 0.95)",
|
||||
borderBottom: "1px solid #eee",
|
||||
display: "none",
|
||||
flexWrap: "wrap",
|
||||
gap: "6px",
|
||||
maxHeight: "60px",
|
||||
overflowY: "auto",
|
||||
},
|
||||
list_has_files: {
|
||||
display: 'flex'
|
||||
},
|
||||
"@keyframes slideIn": {
|
||||
from: {transform: "translateY(-10px)", opacity: 0},
|
||||
to: {transform: "translateY(0)", opacity: 1}
|
||||
},
|
||||
file_item: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
background: "#f0f6ff",
|
||||
color: '#1661ab',
|
||||
border: "1px solid #c2d9ff",
|
||||
borderRadius: "15px",
|
||||
padding: "4px 12px",
|
||||
fontSize: "13px",
|
||||
animation: "$slideIn 0.2s ease"
|
||||
},
|
||||
delete_btn: {
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
border: "none",
|
||||
background: "#ff6b6b",
|
||||
color: "white",
|
||||
borderRadius: "50%",
|
||||
marginLeft: "8px",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "12px",
|
||||
"&:hover": {background: "#ff5252"},
|
||||
},
|
||||
buttons: {
|
||||
position: "absolute",
|
||||
bottom: "16px",
|
||||
right: "16px",
|
||||
display: "flex",
|
||||
gap: "12px"
|
||||
},
|
||||
action_btn: {
|
||||
padding: "10px 22px",
|
||||
background: "#4dabf7",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "20px",
|
||||
cursor: "pointer",
|
||||
fontSize: "1rem",
|
||||
transition: "all 0.2s",
|
||||
"&:hover": {background: "#339af0", transform: "translateY(-1px)"}
|
||||
},
|
||||
})
|
||||
|
||||
export interface SenderProps {
|
||||
onSend: (message: string, files: File[]) => void;
|
||||
}
|
||||
|
||||
export const Sender: React.FC<SenderProps> = ({onSend}) => {
|
||||
const classes = useClass();
|
||||
const [inputMessage, setInputMessage] = useState('');
|
||||
const [files, setFiles] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleTextInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setInputMessage(e.target.value)
|
||||
}
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
setFiles(e.target.files[0]);
|
||||
} else {
|
||||
message.warning('未能选择文件');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!(inputMessage.trim() || files)) {
|
||||
message.warning('请输入内容或选择文件');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
onSend(inputMessage, files ? [files] : []);
|
||||
setInputMessage('');
|
||||
setFiles(null);
|
||||
} catch (e) {
|
||||
message.error('发送失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<textarea className={files?`${classes.input_box} ${classes.input_has_files}`:`${classes.input_box}`} placeholder="开始输入..."
|
||||
onChange={handleTextInput} value={inputMessage}></textarea>
|
||||
<div className={files? `${classes.file_list} ${classes.list_has_files}` : `${classes.file_list}`}>
|
||||
{files && <div className={classes.file_item}>
|
||||
<span>{files.name}</span>
|
||||
<button className={classes.delete_btn} onClick={() => {
|
||||
setFiles(null)
|
||||
}}>×
|
||||
</button>
|
||||
</div>}
|
||||
</div>
|
||||
<div className={classes.buttons}>
|
||||
<input type="file" ref={fileInputRef} hidden onChange={handleFileSelect}/>
|
||||
<button className={classes.action_btn} onClick={() => {
|
||||
fileInputRef.current && fileInputRef.current.click()
|
||||
}}>📁 选择文件
|
||||
</button>
|
||||
<button className={classes.action_btn} onClick={handleSubmit}>✈️ 发送</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
export interface Bubble {
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
color: string;
|
||||
radius: number;
|
||||
angle: number;
|
||||
}
|
||||
|
||||
export interface WSMessage {
|
||||
data: any;
|
||||
time: number;
|
||||
type: "register" | "enter" | "leave" | "offer" | "answer" | "candidate"
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
client_type: 'desktop' | 'mobile' | 'tablet';
|
||||
app_type: 'web';
|
||||
ip: number;
|
||||
name: string;
|
||||
id: string;
|
||||
register_at: string;
|
||||
offer: RTCSessionDescription;
|
||||
candidate: RTCIceCandidateInit;
|
||||
}
|
||||
|
||||
export interface ReceivedMessage {
|
||||
text?: string;
|
||||
timestamp: number;
|
||||
sender: string;
|
||||
fileName?: string;
|
||||
fileSize?: number;
|
||||
fileBlobUrl?: string;
|
||||
isFile?: boolean;
|
||||
progress?: number; // 0-1
|
||||
receiving?: boolean; // 是否正在接收
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import React from 'react';
|
||||
import {createUseStyles} from "react-jss";
|
||||
import {Bubble} from "./types.ts";
|
||||
|
||||
const useClass = createUseStyles({
|
||||
bubble: {
|
||||
position: "absolute",
|
||||
width: "100px",
|
||||
height: "100px",
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
textAlign: "center",
|
||||
cursor: "pointer",
|
||||
fontFamily: "'Microsoft Yahei', sans-serif",
|
||||
fontSize: "14px",
|
||||
color: "rgba(255, 255, 255, 0.9)",
|
||||
textShadow: "1px 1px 3px rgba(0,0,0,0.3)",
|
||||
transition: "transform 0.3s ease",
|
||||
transform: 'translate(-50%, -50%)',
|
||||
animation: 'emerge 0.5s ease-out forwards,float 6s 0.5s ease-in-out infinite',
|
||||
background: "radial-gradient(circle at 30% 30%,rgba(255, 255, 255, 0.8) 10%,rgba(255, 255, 255, 0.3) 50%,transparent 100%)",
|
||||
border: "2px solid rgba(255, 255, 255, 0.5)",
|
||||
boxShadow: "inset 0 -5px 15px rgba(255,255,255,0.3),0 5px 15px rgba(0,0,0,0.1)",
|
||||
}
|
||||
});
|
||||
|
||||
interface UserBubbleProps {
|
||||
bubble: Bubble;
|
||||
onClick: (bubble: Bubble) => void;
|
||||
}
|
||||
|
||||
export const UserBubble: React.FC<UserBubbleProps> = ({bubble, onClick}) => {
|
||||
const classes = useClass();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes.bubble}
|
||||
style={{
|
||||
left: bubble.x,
|
||||
top: bubble.y,
|
||||
backgroundColor: bubble.color,
|
||||
animationDelay: `${Math.random() * 0.5}s, ${0.5 + Math.random() * 2}s`
|
||||
}}
|
||||
onClick={() => onClick(bubble)}
|
||||
>
|
||||
{bubble.name}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,242 +0,0 @@
|
||||
import {CloudBackground} from "../../component/fluid/cloud.tsx";
|
||||
import {useEffect, useRef, useState, useCallback} from "react";
|
||||
import {createUseStyles} from "react-jss";
|
||||
import {useWebsocket} from "../../hook/websocket/u-ws.tsx";
|
||||
import {Resp} from "../../interface/response.ts";
|
||||
import {useLocalStore} from "../../store/local.ts";
|
||||
import {UserBubble} from "./component/user-bubble.tsx";
|
||||
import {MessageDialog} from "./component/message-dialog.tsx";
|
||||
import {RTCHandler, RTCHandlerCallbacks} from "./component/rtc-handler.ts";
|
||||
import {generateBubbles} from "./component/bubble-layout.ts";
|
||||
import {Client, ReceivedMessage} from "./component/types.ts";
|
||||
import {SendDialog} from "./component/send-dialog.tsx";
|
||||
import {message} from "../../hook/message/u-message.tsx";
|
||||
|
||||
const useClass = createUseStyles({
|
||||
'@global': {
|
||||
'@keyframes emerge': {
|
||||
'0%': {
|
||||
transform: 'scale(0) translate(-50%, -50%)',
|
||||
opacity: 0
|
||||
},
|
||||
'80%': {
|
||||
transform: 'scale(1.1) translate(-50%, -50%)',
|
||||
opacity: 1
|
||||
},
|
||||
'100%': {
|
||||
transform: 'scale(1) translate(-50%, -50%)',
|
||||
opacity: 1
|
||||
}
|
||||
},
|
||||
'@keyframes fadeIn': {
|
||||
'0%': {
|
||||
opacity: 0,
|
||||
transform: 'translateY(-10px)'
|
||||
},
|
||||
'100%': {
|
||||
opacity: 1,
|
||||
transform: 'translateY(0)'
|
||||
}
|
||||
}
|
||||
},
|
||||
container: {
|
||||
margin: "0",
|
||||
height: "100vh",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
},
|
||||
title: {
|
||||
width: '100%',
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
color: '#1661ab',
|
||||
}
|
||||
});
|
||||
|
||||
export const LocalSharing: React.FC = () => {
|
||||
const classes = useClass();
|
||||
const {id, name, set, } = useLocalStore();
|
||||
const [_rtc, setRTC] = useState<RTCPeerConnection>();
|
||||
const rtcRef = useRef<RTCPeerConnection | null>(null);
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const {connect, close} = useWebsocket({});
|
||||
const [open, setOpen] = useState<{ send: boolean; receive: boolean }>({send: false, receive: false});
|
||||
const [receivedMessage, setReceivedMessage] = useState<ReceivedMessage | null>(null);
|
||||
const [showMessageDialog, setShowMessageDialog] = useState(false);
|
||||
const [receivingFile, setReceivingFile] = useState<ReceivedMessage | null>(null);
|
||||
|
||||
// RTC处理器的回调函数 - 使用useCallback确保稳定性
|
||||
const onChannelOpen = useCallback((type: 'sender' | 'receiver') => {
|
||||
console.log(`[D] Channel opened: ${type}`);
|
||||
setOpen(val => ({...val, [type]: true}));
|
||||
}, []);
|
||||
|
||||
const onMessageReceived = useCallback((message: ReceivedMessage) => {
|
||||
if (message.isFile && message.receiving) {
|
||||
setReceivingFile(message);
|
||||
} else if (message.isFile && !message.receiving) {
|
||||
setReceivingFile(null);
|
||||
setReceivedMessage(message);
|
||||
setShowMessageDialog(true);
|
||||
} else {
|
||||
setReceivedMessage(message);
|
||||
setShowMessageDialog(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onChannelClose = useCallback(() => {
|
||||
console.log('[D] Channel closed');
|
||||
setOpen({send: false, receive: false});
|
||||
}, []);
|
||||
|
||||
const rtcCallbacks: RTCHandlerCallbacks = {
|
||||
onChannelOpen,
|
||||
onMessageReceived,
|
||||
onChannelClose
|
||||
};
|
||||
|
||||
// 创建RTC处理器实例 - 使用useRef确保实例稳定
|
||||
const rtcHandlerRef = useRef<RTCHandler | null>(null);
|
||||
|
||||
// 更新RTC处理器的回调函数
|
||||
useEffect(() => {
|
||||
if (rtcHandlerRef.current) {
|
||||
rtcHandlerRef.current.updateCallbacks(rtcCallbacks);
|
||||
}
|
||||
}, [rtcCallbacks]);
|
||||
|
||||
const updateClients = async () => {
|
||||
setTimeout(async () => {
|
||||
const res = await fetch(`/api/ulocal/clients`);
|
||||
const jes = await res.json() as Resp<Client[]>;
|
||||
setClients(jes.data);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleWSEvent = async (e: MessageEvent) => {
|
||||
const msgData = JSON.parse(e.data);
|
||||
|
||||
if (msgData.type === "enter" || msgData.type === "leave") {
|
||||
await updateClients();
|
||||
return;
|
||||
}
|
||||
|
||||
// 其他RTC相关事件由RTC处理器处理
|
||||
if (rtcHandlerRef.current) {
|
||||
try {
|
||||
await rtcHandlerRef.current.handleWSEvent(e);
|
||||
} catch (err) {
|
||||
message.error('通信异常,请刷新页面');
|
||||
}
|
||||
} else {
|
||||
message.error('内部错误:通信模块未初始化');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBubbleClick = async (bubble: any) => {
|
||||
setOpen({send: true, receive: false});
|
||||
if (rtcHandlerRef.current) {
|
||||
try {
|
||||
await rtcHandlerRef.current.handleBubbleClick(bubble.id, id);
|
||||
} catch (e) {
|
||||
message.error('建立连接失败,请重试');
|
||||
}
|
||||
} else {
|
||||
message.error('内部错误:通信模块未初始化');
|
||||
console.error('[E] RTC handler is null!');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = (msg: string, files: File[]) => {
|
||||
if (rtcHandlerRef.current) {
|
||||
try {
|
||||
rtcHandlerRef.current.sendMessage(msg, files, name);
|
||||
} catch (e) {
|
||||
message.error('发送失败,请重试');
|
||||
}
|
||||
} else {
|
||||
message.error('内部错误:通信模块未初始化');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseMessageDialog = () => {
|
||||
setShowMessageDialog(false);
|
||||
setReceivedMessage(null);
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const fn = async () => {
|
||||
const response = await fetch('/api/ulocal/register', {method: 'POST'});
|
||||
const data = ((await response.json()) as Resp<{ id: string; name: string }>).data;
|
||||
set(data.id, data.name);
|
||||
connect(`/api/ulocal/ws?id=${data.id}`, {fn: handleWSEvent});
|
||||
await updateClients();
|
||||
|
||||
const _rtc = new RTCPeerConnection();
|
||||
rtcRef.current = _rtc;
|
||||
setRTC(_rtc);
|
||||
|
||||
// 在RTC连接创建后立即创建处理器实例
|
||||
rtcHandlerRef.current = new RTCHandler(rtcRef, rtcCallbacks);
|
||||
|
||||
return () => {
|
||||
close();
|
||||
if (rtcRef.current) {
|
||||
rtcRef.current.close();
|
||||
}
|
||||
};
|
||||
};
|
||||
fn();
|
||||
}, []);
|
||||
|
||||
const bubbles = generateBubbles(clients, id);
|
||||
|
||||
useEffect(() => {
|
||||
if (receivingFile && receivingFile.isFile && !receivingFile.receiving) {
|
||||
setReceivingFile(null);
|
||||
setReceivedMessage(receivingFile);
|
||||
setShowMessageDialog(true);
|
||||
}
|
||||
}, [receivingFile]);
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<CloudBackground/>
|
||||
<h1 className={classes.title}>
|
||||
{name}
|
||||
{/* <span> - {id}</span> */}
|
||||
</h1>
|
||||
|
||||
{bubbles.map(bubble => (
|
||||
<UserBubble
|
||||
key={bubble.id}
|
||||
bubble={bubble}
|
||||
onClick={handleBubbleClick}
|
||||
/>
|
||||
))}
|
||||
|
||||
<SendDialog
|
||||
open={open.send}
|
||||
onSend={handleSend}
|
||||
onClose={() => setOpen({send: false, receive: false})}
|
||||
name={name}
|
||||
/>
|
||||
|
||||
{/* 文件接收进度弹窗 */}
|
||||
{receivingFile && (
|
||||
<MessageDialog
|
||||
open={true}
|
||||
message={receivingFile}
|
||||
onClose={() => setReceivingFile(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MessageDialog
|
||||
open={showMessageDialog}
|
||||
message={receivedMessage}
|
||||
onClose={handleCloseMessageDialog}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,183 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { createUseStyles } from "react-jss";
|
||||
import { CloudBackground } from "../component/fluid/cloud.tsx";
|
||||
import {useAuth} from "../api/auth.ts";
|
||||
|
||||
const useClass = createUseStyles({
|
||||
container: {
|
||||
overflow: 'hidden',
|
||||
height: '100vh',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
boxSizing: "border-box",
|
||||
fontFamily: "'Segoe UI', Arial, sans-serif",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
position: 'relative',
|
||||
},
|
||||
login_container: {
|
||||
background: "rgba(255,255,255,.5)",
|
||||
boxShadow: "0 2px 10px rgba(0, 0, 0, 0.1)",
|
||||
width: "350px",
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
left: '70%',
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
form: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexDirection: 'column',
|
||||
color: "#1a73e8",
|
||||
padding: '40px',
|
||||
},
|
||||
input: {
|
||||
width: '100%',
|
||||
marginTop: '20px',
|
||||
"& > input": {
|
||||
width: "calc(100% - 30px)",
|
||||
padding: "12px 15px",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "6px",
|
||||
fontSize: "16px",
|
||||
transition: "border-color 0.3s",
|
||||
|
||||
"&:focus": {
|
||||
outline: "none",
|
||||
borderColor: "#1a73e8",
|
||||
boxShadow: "0 0 0 2px rgba(26, 115, 232, 0.2)",
|
||||
},
|
||||
"&:hover": {
|
||||
borderColor: "#1a73e8",
|
||||
}
|
||||
},
|
||||
},
|
||||
button: {
|
||||
marginTop: '20px',
|
||||
width: '100%',
|
||||
"& > button": {
|
||||
width: "100%",
|
||||
padding: "12px",
|
||||
background: "#1a73e8",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
fontSize: "16px",
|
||||
cursor: "pointer",
|
||||
transition: "background 0.3s",
|
||||
"&:hover": {
|
||||
background: "#1557b0",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
inputContainer: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
marginTop: '20px',
|
||||
},
|
||||
inputField: {
|
||||
width: "calc(100% - 52px)",
|
||||
padding: "12px 35px 12px 15px",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "6px",
|
||||
fontSize: "16px",
|
||||
transition: "border-color 0.3s",
|
||||
"&:focus": {
|
||||
outline: "none",
|
||||
borderColor: "#1a73e8",
|
||||
boxShadow: "0 0 0 2px rgba(26, 115, 232, 0.2)",
|
||||
},
|
||||
"&:hover": {
|
||||
borderColor: "#1a73e8",
|
||||
}
|
||||
},
|
||||
iconButton: {
|
||||
position: 'absolute',
|
||||
right: '10px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: '#666',
|
||||
"&:hover": {
|
||||
color: '#333',
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const Login: React.FC = () => {
|
||||
const classes = useClass()
|
||||
const {login} = useAuth()
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
|
||||
const onLogin = async () => {
|
||||
try {
|
||||
await login(username, password)
|
||||
window.location.href = "/"
|
||||
} catch (_e) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return <div className={classes.container}>
|
||||
<CloudBackground/>
|
||||
<div className={classes.login_container}>
|
||||
<div className={classes.form}>
|
||||
<h2>UShare</h2>
|
||||
|
||||
{/* 用户名输入框 */}
|
||||
<div className={classes.inputContainer}>
|
||||
<input
|
||||
className={classes.inputField}
|
||||
placeholder="请输入账号"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
{username && (
|
||||
<button
|
||||
className={classes.iconButton}
|
||||
onClick={() => setUsername("")}
|
||||
style={{ right: '10px', fontSize: '16px' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 密码输入框 */}
|
||||
<div className={classes.inputContainer}>
|
||||
<input
|
||||
className={classes.inputField}
|
||||
placeholder="请输入密码"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className={classes.iconButton}
|
||||
onMouseDown={() => setShowPassword(true)}
|
||||
onMouseUp={() => setShowPassword(false)}
|
||||
onMouseLeave={() => setShowPassword(false)}
|
||||
style={{ right: '10px', fontSize: '12px' }}
|
||||
>
|
||||
{showPassword ? "👁" : "👁"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={classes.button}>
|
||||
<button onClick={onLogin}>登录</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
import {createUseStyles} from "react-jss";
|
||||
import {UButton} from "../../../component/button/u-button.tsx";
|
||||
import React, {useState} from "react";
|
||||
import {useStore} from "../../../store/share.ts";
|
||||
import {message} from "../../../hook/message/u-message.tsx";
|
||||
import {useFileUpload} from "../../../api/upload.ts";
|
||||
|
||||
const useUploadStyle = createUseStyles({
|
||||
container: {
|
||||
backgroundColor: "#e3f2fd",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
form: {
|
||||
backgroundColor: "#C8E6C9",
|
||||
boxShadow: "inset 0 0 15px rgba(56, 142, 60, 0.15)",
|
||||
padding: "30px",
|
||||
borderRadius: "15px",
|
||||
width: "70%",
|
||||
margin: "20px 60px 20px 0",
|
||||
/*todo margin 不用 px*/
|
||||
},
|
||||
title: {
|
||||
color: "#2c9678"
|
||||
},
|
||||
file: {
|
||||
display: 'none',
|
||||
},
|
||||
preview: {
|
||||
marginTop: '10px',
|
||||
display: 'flex',
|
||||
},
|
||||
name: {
|
||||
color: "#2c9678",
|
||||
marginLeft: '10px'
|
||||
},
|
||||
clean: {
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {}
|
||||
}
|
||||
})
|
||||
|
||||
const useShowStyle = createUseStyles({
|
||||
container: {
|
||||
backgroundColor: "#e3f2fd",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
position: "relative", // 为关闭按钮提供定位基准
|
||||
},
|
||||
title: {
|
||||
color: "#2c9678",
|
||||
marginTop: 0,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
form: {
|
||||
backgroundColor: "#C8E6C9",
|
||||
boxShadow: "inset 0 0 15px rgba(56, 142, 60, 0.15)",
|
||||
padding: "30px",
|
||||
borderRadius: "15px",
|
||||
width: "70%",
|
||||
margin: "20px 60px 20px 0",
|
||||
position: "relative",
|
||||
},
|
||||
closeButton: {
|
||||
position: "absolute",
|
||||
top: "10px",
|
||||
right: "10px",
|
||||
background: "transparent",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "50%",
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
// background: "#cc0000",
|
||||
boxShadow: "20px 20px 60px #fff, -20px -20px 60px #fff",
|
||||
// boxShadow: "20px 20px 60px #eee",
|
||||
},
|
||||
},
|
||||
codeWrapper: {
|
||||
backgroundColor: "rgba(255,255,255,0.8)",
|
||||
padding: "0 15px",
|
||||
borderRadius: "8px",
|
||||
margin: "15px 0",
|
||||
overflowX: "auto",
|
||||
},
|
||||
pre: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
color: 'black',
|
||||
alignItems: 'center',
|
||||
height: '24px',
|
||||
"& > code": {
|
||||
marginLeft: "0",
|
||||
}
|
||||
},
|
||||
copyButton: {
|
||||
marginLeft: 'auto',
|
||||
background: "#2c9678",
|
||||
color: "white",
|
||||
border: "none",
|
||||
padding: "8px 16px",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
transition: "background 0.3s",
|
||||
"&:hover": {
|
||||
background: "#1f6d5a",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const PanelLeft = () => {
|
||||
const [code, set_code] = useState("")
|
||||
|
||||
if (code) {
|
||||
return <PanelLeftShow code={code} set_code={set_code} />
|
||||
}
|
||||
|
||||
return <PanelLeftUpload set_code={set_code}/>
|
||||
};
|
||||
|
||||
const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_code}) => {
|
||||
const style = useUploadStyle()
|
||||
const {file, setFile} = useStore()
|
||||
const {uploadFile, progress, loading} = useFileUpload();
|
||||
|
||||
function onFileSelect() {
|
||||
// @ts-ignore
|
||||
document.querySelector('#real-file-input').click();
|
||||
}
|
||||
|
||||
function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
setFile(e.currentTarget.files ? e.currentTarget.files[0] : null)
|
||||
}
|
||||
|
||||
async function onFileUpload() {
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
const code = await uploadFile(file)
|
||||
set_code(code)
|
||||
}
|
||||
|
||||
function onFileClean() {
|
||||
setFile(null)
|
||||
}
|
||||
|
||||
return <div className={style.container}>
|
||||
<div className={style.form}>
|
||||
<h2 className={style.title}>上传文件</h2>
|
||||
{
|
||||
!file && !loading &&
|
||||
<UButton onClick={onFileSelect}>选择文件</UButton>
|
||||
}
|
||||
{
|
||||
file && !loading &&
|
||||
<UButton onClick={onFileUpload}>上传文件</UButton>
|
||||
}
|
||||
{
|
||||
loading &&
|
||||
<UButton process={progress} loading={loading}>上传中</UButton>
|
||||
}
|
||||
<input type="file" className={style.file} id="real-file-input" onChange={onFileChange}/>
|
||||
{
|
||||
file &&
|
||||
<div className={style.preview}>
|
||||
<div className={style.clean} onClick={onFileClean}>×</div>
|
||||
<div className={style.name}>{file.name}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
const PanelLeftShow: React.FC<{ code: string; set_code: (code: string) => void }> = ({ code, set_code }) => {
|
||||
const classes = useShowStyle();
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
message.success("复制成功!");
|
||||
} catch (err) {
|
||||
message.warning("复制失败,请手动选择文本复制");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
|
||||
<div className={classes.form}>
|
||||
<button
|
||||
className={classes.closeButton}
|
||||
onClick={() => set_code('')}
|
||||
aria-label="关闭"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<h2 className={classes.title}>
|
||||
上传成功!
|
||||
</h2>
|
||||
|
||||
<div className={classes.codeWrapper}>
|
||||
<pre className={classes.pre}>
|
||||
<code>{code}</code>
|
||||
<button className={classes.copyButton} onClick={handleCopy}>
|
||||
一键复制
|
||||
</button>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import {useState} from "react";
|
||||
import {createUseStyles} from "react-jss";
|
||||
|
||||
const useClass = createUseStyles({
|
||||
container: {}
|
||||
})
|
||||
export const TestPage = () => {
|
||||
const classes = useClass()
|
||||
const [_open, setOpen] = useState<boolean>(false)
|
||||
|
||||
const handleOpen = () => {
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
return <div className={classes.container}>
|
||||
<button onClick={handleOpen}>open</button>
|
||||
</div>
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import {create} from 'zustand'
|
||||
|
||||
export interface LocalStore {
|
||||
id: string;
|
||||
name: string;
|
||||
channel?: RTCDataChannel;
|
||||
set: (id: string, name: string) => void;
|
||||
setChannel: (chan?: RTCDataChannel) => void;
|
||||
}
|
||||
|
||||
export const useLocalStore = create<LocalStore>()((_set, _get) => ({
|
||||
id: '',
|
||||
name: '',
|
||||
set: (id: string, name: string) => {
|
||||
_set(state => {
|
||||
return {...state, id: id, name: name};
|
||||
})
|
||||
},
|
||||
setChannel: (ch?: RTCDataChannel) => {
|
||||
_set(state => {
|
||||
return {...state, channel: ch}
|
||||
})
|
||||
}
|
||||
}))
|
||||
@@ -3,8 +3,5 @@
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"allowJs": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-10
@@ -9,16 +9,7 @@ export default defineConfig({
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:9119',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/api/ulocal/ws': {
|
||||
target: 'ws://127.0.0.1:9119',
|
||||
rewriteWsOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
'/ushare': {
|
||||
target: 'http://127.0.0.1:9119',
|
||||
changeOrigin: true
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -23,18 +23,12 @@ require (
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/jedib0t/go-pretty/v6 v6.6.7 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mileusna/useragent v1.3.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/pjbgf/sha1cd v0.3.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sagikazarmark/locafero v0.7.0 // indirect
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/skeema/knownhosts v1.2.2 // indirect
|
||||
@@ -49,9 +43,9 @@ require (
|
||||
golang.org/x/crypto v0.32.0 // indirect
|
||||
golang.org/x/mod v0.17.0 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sync v0.11.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -46,12 +46,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/jedib0t/go-pretty/v6 v6.6.7 h1:m+LbHpm0aIAPLzLbMfn8dc3Ht8MW7lsSO4MPItz/Uuo=
|
||||
github.com/jedib0t/go-pretty/v6 v6.6.7/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
|
||||
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
||||
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
@@ -63,17 +59,11 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/loveuer/nf v0.3.5 h1:DlgTa6Rx8D3/VtH9e0fLGAxmPwSYd7b3nfBltSMuypE=
|
||||
github.com/loveuer/nf v0.3.5/go.mod h1:IAq0K1c/mlNQzLBvUzAD1LCWiVlt2GqTMPdDjej3Ryo=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
|
||||
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
|
||||
github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
|
||||
github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
@@ -84,9 +74,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
|
||||
@@ -146,7 +133,6 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -163,8 +149,6 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||
@@ -181,8 +165,6 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
|
||||
+1
-14
@@ -18,20 +18,7 @@ func Start(ctx context.Context) <-chan struct{} {
|
||||
return c.SendStatus(http.StatusOK)
|
||||
})
|
||||
|
||||
app.Get("/ushare/:code", handler.Fetch())
|
||||
app.Put("/api/ushare/:filename", handler.AuthVerify(), handler.ShareNew()) // 获取上传 code, 分片大小
|
||||
app.Post("/api/ushare/:code", handler.ShareUpload()) // 分片上传接口
|
||||
app.Post("/api/uauth/login", handler.AuthLogin())
|
||||
|
||||
{
|
||||
api := app.Group("/api/ulocal")
|
||||
api.Post("/register", handler.LocalRegister())
|
||||
api.Post("/offer", handler.LocalOffer())
|
||||
api.Post("/answer", handler.LocalAnswer())
|
||||
api.Post("/candidate", handler.LocalCandidate())
|
||||
api.Get("/clients", handler.LocalClients())
|
||||
api.Get("/ws", handler.LocalWS())
|
||||
}
|
||||
app.Get("/api/share/fetch", handler.Fetch())
|
||||
|
||||
ready := make(chan struct{})
|
||||
ln, err := net.Listen("tcp", opt.Cfg.Address)
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/loveuer/nf/nft/log"
|
||||
"github.com/loveuer/ushare/internal/model"
|
||||
"github.com/loveuer/ushare/internal/opt"
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/spf13/viper"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type metaInfo struct {
|
||||
f *os.File
|
||||
name string
|
||||
create time.Time
|
||||
last time.Time
|
||||
size int64
|
||||
cursor int64
|
||||
user string
|
||||
}
|
||||
|
||||
func (m *metaInfo) generateMeta(code string) error {
|
||||
content := fmt.Sprintf("filename=%s\ncreated_at=%d\nsize=%d\nuploader=%s",
|
||||
m.name, m.create.UnixMilli(), m.size, m.user,
|
||||
)
|
||||
|
||||
return os.WriteFile(opt.MetaPath(code), []byte(content), 0644)
|
||||
}
|
||||
|
||||
type meta struct {
|
||||
sync.Mutex
|
||||
ctx context.Context
|
||||
m map[string]*metaInfo
|
||||
}
|
||||
|
||||
var (
|
||||
MetaManager = &meta{m: make(map[string]*metaInfo)}
|
||||
)
|
||||
|
||||
const letters = "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
|
||||
func (m *meta) New(size int64, filename, ip string) (string, error) {
|
||||
now := time.Now()
|
||||
code, err := gonanoid.Generate(letters, opt.CodeLength)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
f, err := os.Create(opt.FilePath(code))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err = f.Truncate(size); err != nil {
|
||||
f.Close()
|
||||
return "", err
|
||||
}
|
||||
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
|
||||
m.m[code] = &metaInfo{f: f, name: filename, last: now, size: size, cursor: 0, create: now, user: ip}
|
||||
|
||||
return code, nil
|
||||
}
|
||||
|
||||
func (m *meta) Write(code string, start, end int64, reader io.Reader) (total, cursor int64, err error) {
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
|
||||
if _, ok := m.m[code]; !ok {
|
||||
return 0, 0, fmt.Errorf("code not exist")
|
||||
}
|
||||
|
||||
w, err := io.CopyN(m.m[code].f, reader, end-start+1)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
m.m[code].cursor += w
|
||||
m.m[code].last = time.Now()
|
||||
|
||||
total = m.m[code].size
|
||||
cursor = m.m[code].cursor
|
||||
|
||||
if m.m[code].cursor == m.m[code].size {
|
||||
defer delete(m.m, code)
|
||||
if err = m.m[code].generateMeta(code); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
}
|
||||
|
||||
return total, cursor, nil
|
||||
}
|
||||
|
||||
func (m *meta) Start(ctx context.Context) {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
m.ctx = ctx
|
||||
|
||||
if err := os.MkdirAll(opt.Cfg.DataPath, 0644); err != nil {
|
||||
log.Fatal("controller.MetaManager.Start: mkdir datapath failed, path = %s, err = %s", opt.Cfg.DataPath, err.Error())
|
||||
}
|
||||
|
||||
// 清理 2 分钟内没有继续上传的 part
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
for code, info := range m.m {
|
||||
if now.Sub(info.last) > 2*time.Minute {
|
||||
m.Lock()
|
||||
if err := info.f.Close(); err != nil {
|
||||
log.Warn("handler.Meta: [timer] close file failed, file = %s, err = %s", opt.FilePath(code), err.Error())
|
||||
}
|
||||
if err := os.RemoveAll(opt.FilePath(code)); err != nil {
|
||||
log.Warn("handler.Meta: [timer] remove file failed, file = %s, err = %s", opt.FilePath(code), err.Error())
|
||||
}
|
||||
delete(m.m, code)
|
||||
m.Unlock()
|
||||
log.Warn("MetaController: code timeout removed, code = %s", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 清理一天前的文件
|
||||
go func() {
|
||||
if opt.Cfg.CleanInterval <= 0 {
|
||||
log.Warn("meta.Clean: no clean interval set, plz clean manual!!!")
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
duration := time.Duration(opt.Cfg.CleanInterval) * time.Hour
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
//log.Debug("meta.Clean: 开始清理过期文件 = %v", duration)
|
||||
_ = filepath.Walk(opt.Cfg.DataPath, func(path string, info os.FileInfo, err error) error {
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
name := filepath.Base(info.Name())
|
||||
if !strings.HasPrefix(name, ".meta.") {
|
||||
return nil
|
||||
}
|
||||
|
||||
viper.SetConfigFile(path)
|
||||
viper.SetConfigType("env")
|
||||
if err = viper.ReadInConfig(); err != nil {
|
||||
// todo log
|
||||
return nil
|
||||
}
|
||||
|
||||
mi := new(model.Meta)
|
||||
|
||||
if err = viper.Unmarshal(mi); err != nil {
|
||||
// todo log
|
||||
return nil
|
||||
}
|
||||
|
||||
code := strings.TrimPrefix(name, ".meta.")
|
||||
|
||||
if now.Sub(time.UnixMilli(mi.CreatedAt)) > duration {
|
||||
|
||||
log.Debug("controller.meta: file out of date, code = %s, user_key = %s", code, mi.Uploader)
|
||||
|
||||
if err = os.RemoveAll(opt.FilePath(code)); err != nil {
|
||||
log.Warn("meta.Clean: remove file failed, file = %s, err = %s", opt.FilePath(code), err.Error())
|
||||
}
|
||||
if err = os.RemoveAll(path); err != nil {
|
||||
log.Warn("meta.Clean: remove file failed, file = %s, err = %s", path, err.Error())
|
||||
}
|
||||
|
||||
m.Lock()
|
||||
delete(m.m, code)
|
||||
m.Unlock()
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
// room controller:
|
||||
// local share websocket room controller
|
||||
// same remote IP as a
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/loveuer/nf/nft/log"
|
||||
"github.com/loveuer/ushare/internal/pkg/tool"
|
||||
"github.com/mileusna/useragent"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RoomClientType string
|
||||
|
||||
const (
|
||||
ClientTypeDesktop RoomClientType = "desktop"
|
||||
ClientTypeMobile RoomClientType = "mobile"
|
||||
ClientTypeTablet RoomClientType = "tablet"
|
||||
)
|
||||
|
||||
type RoomAppType string
|
||||
|
||||
const (
|
||||
RoomAppTypeWeb = "web"
|
||||
)
|
||||
|
||||
type RoomMessageType string
|
||||
|
||||
const (
|
||||
RoomMessageTypeEnter RoomMessageType = "enter"
|
||||
RoomMessageTypeLeave RoomMessageType = "leave"
|
||||
)
|
||||
|
||||
type RoomOffer struct {
|
||||
SDP string `json:"sdp"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type RoomCandidate struct {
|
||||
Candidate string `json:"candidate"`
|
||||
SdpMid string `json:"sdpMid"`
|
||||
SdpMLineIndex int `json:"sdpMLineIndex"`
|
||||
UsernameFragment string `json:"usernameFragment"`
|
||||
}
|
||||
|
||||
type roomClient struct {
|
||||
sync.Mutex
|
||||
controller *roomController
|
||||
conn *websocket.Conn
|
||||
ClientType RoomClientType `json:"client_type"`
|
||||
AppType RoomAppType `json:"app_type"`
|
||||
IP string `json:"ip"`
|
||||
Name string `json:"name"`
|
||||
Id string `json:"id"`
|
||||
RegisterAt time.Time `json:"register_at"`
|
||||
msgChan chan any
|
||||
}
|
||||
|
||||
func (rc *roomClient) start(ctx context.Context) {
|
||||
// start write
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = rc.conn.Close()
|
||||
return
|
||||
case msg, _ := <-rc.msgChan:
|
||||
err := rc.conn.WriteJSON(msg)
|
||||
log.Debug("RoomClient: write json message, IP = %s, Id = %s, Name = %s, err = %v", rc.IP, rc.Id, rc.Name, err)
|
||||
if err != nil {
|
||||
log.Error("RoomClient: write json message failed, IP = %s, Id = %s, Name = %s, err = %s", rc.IP, rc.Id, rc.Name, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// start read
|
||||
go func() {
|
||||
for {
|
||||
mt, bs, err := rc.conn.ReadMessage()
|
||||
if err != nil {
|
||||
log.Error("RoomClient: read message failed, IP = %s, Id = %s, Name = %s, err = %s", rc.IP, rc.Id, rc.Name, err.Error())
|
||||
rc.controller.Unregister(rc)
|
||||
return
|
||||
}
|
||||
|
||||
switch mt {
|
||||
case websocket.PingMessage:
|
||||
rs, _ := json.Marshal(map[string]any{"type": "pong", "time": time.Now().UnixMilli(), "Id": rc.Id, "Name": rc.Name})
|
||||
if err := rc.conn.WriteMessage(websocket.PongMessage, rs); err != nil {
|
||||
log.Error("RoomClient: response ping message failed, IP = %s, Id = %s, Name = %s, err = %s", rc.IP, rc.Id, rc.Name, err.Error())
|
||||
}
|
||||
case websocket.CloseMessage:
|
||||
log.Debug("RoomClient: received close message, unregister IP = %s Id = %s, Name = %s", rc.IP, rc.Id, rc.Name)
|
||||
rc.controller.Unregister(rc)
|
||||
return
|
||||
case websocket.TextMessage:
|
||||
log.Debug("RoomClient: received text message, IP = %s, Id = %s, Name = %s, text = %s", rc.IP, rc.Id, rc.Name, string(bs))
|
||||
case websocket.BinaryMessage:
|
||||
log.Debug("RoomClient: received bytes message, IP = %s, Id = %s, Name = %s, text = %s", rc.IP, rc.Id, rc.Name, string(bs))
|
||||
// todo
|
||||
//msg := new(model.Message)
|
||||
//if err = json.Unmarshal(bs, msg); err != nil {
|
||||
// log.Error("RoomClient: unmarshal message failed, id = %s, name = %s, err = %s", rc.Id, rc.Name, err.Error())
|
||||
// continue
|
||||
//}
|
||||
//
|
||||
//switch msg.Type {
|
||||
//case model.WSMessageTypeOffer:
|
||||
// rc.Lock()
|
||||
// rc.Offer = msg.Body
|
||||
// rc.Unlock()
|
||||
//case model.WSMessageTypeCandidate:
|
||||
// rc.Lock()
|
||||
// rc.Candidate = msg.Body
|
||||
// rc.Unlock()
|
||||
//}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
type roomController struct {
|
||||
sync.Mutex
|
||||
ctx context.Context
|
||||
//rooms map[string]map[string]*roomClient // map[room_id(remote-IP)][Id]
|
||||
pre map[string]*roomClient
|
||||
clients map[string]*roomClient
|
||||
}
|
||||
|
||||
var (
|
||||
RoomController = &roomController{
|
||||
pre: make(map[string]*roomClient),
|
||||
clients: make(map[string]*roomClient),
|
||||
}
|
||||
)
|
||||
|
||||
func (rc *roomController) Start(ctx context.Context) {
|
||||
rc.ctx = ctx
|
||||
}
|
||||
|
||||
func (rc *roomController) Register(ip, userAgent string) *roomClient {
|
||||
nrc := &roomClient{
|
||||
controller: rc,
|
||||
ClientType: ClientTypeDesktop,
|
||||
AppType: RoomAppTypeWeb,
|
||||
IP: ip,
|
||||
Id: uuid.Must(uuid.NewV7()).String(),
|
||||
Name: tool.RandomName(),
|
||||
msgChan: make(chan any, 1),
|
||||
RegisterAt: time.Now(),
|
||||
}
|
||||
|
||||
ua := useragent.Parse(userAgent)
|
||||
switch {
|
||||
case ua.Mobile:
|
||||
nrc.ClientType = ClientTypeMobile
|
||||
case ua.Tablet:
|
||||
nrc.ClientType = ClientTypeTablet
|
||||
}
|
||||
|
||||
rc.Lock()
|
||||
defer rc.Unlock()
|
||||
|
||||
rc.pre[nrc.Id] = nrc
|
||||
|
||||
return nrc
|
||||
}
|
||||
|
||||
func (rc *roomController) Enter(conn *websocket.Conn, id string) *roomClient {
|
||||
log.Debug("controller.room: registry client, id = %s", id)
|
||||
|
||||
rc.Lock()
|
||||
defer rc.Unlock()
|
||||
|
||||
nrc, ok := rc.pre[id]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
nrc.conn = conn
|
||||
nrc.start(rc.ctx)
|
||||
|
||||
rc.Broadcast(map[string]any{"type": "enter", "time": time.Now().UnixMilli(), "body": nrc})
|
||||
|
||||
delete(rc.pre, nrc.Id)
|
||||
rc.clients[nrc.Id] = nrc
|
||||
|
||||
return nrc
|
||||
}
|
||||
|
||||
func (rc *roomController) List() []*roomClient {
|
||||
clientList := make([]*roomClient, 0)
|
||||
|
||||
for _, client := range rc.clients {
|
||||
clientList = append(clientList, client)
|
||||
}
|
||||
|
||||
return clientList
|
||||
}
|
||||
|
||||
func (rc *roomController) Broadcast(msg any) {
|
||||
for _, client := range rc.clients {
|
||||
select {
|
||||
case client.msgChan <- msg:
|
||||
case <-time.After(2 * time.Second):
|
||||
log.Warn("RoomController: broadcast timeout, client Id = %s, IP = %s", client.Id, client.IP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *roomController) Unregister(client *roomClient) {
|
||||
log.Debug("controller.room: unregister client, IP = %s, Id = %s, Name = %s", client.IP, client.Id, client.Name)
|
||||
|
||||
rc.Lock()
|
||||
delete(rc.clients, client.Id)
|
||||
rc.Unlock()
|
||||
|
||||
rc.Broadcast(map[string]any{"type": RoomMessageTypeLeave, "time": time.Now().UnixMilli(), "body": client})
|
||||
}
|
||||
|
||||
func (rc *roomController) Offer(id, from string, offer *RoomOffer) {
|
||||
if _, ok := rc.clients[id]; !ok {
|
||||
return
|
||||
}
|
||||
|
||||
rc.clients[id].msgChan <- map[string]any{
|
||||
"type": "offer",
|
||||
"time": time.Now().UnixMilli(),
|
||||
"data": map[string]any{
|
||||
"id": id,
|
||||
"from": from,
|
||||
"offer": offer,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *roomController) Answer(id string, answer *RoomOffer) {
|
||||
if _, ok := rc.clients[id]; !ok {
|
||||
return
|
||||
}
|
||||
|
||||
rc.clients[id].msgChan <- map[string]any{
|
||||
"type": "answer",
|
||||
"time": time.Now().UnixMilli(),
|
||||
"data": map[string]any{
|
||||
"id": id,
|
||||
"answer": answer,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *roomController) Candidate(id string, candidate *RoomCandidate) {
|
||||
if _, ok := rc.clients[id]; !ok {
|
||||
return
|
||||
}
|
||||
|
||||
for _, client := range rc.clients {
|
||||
if client.Id == id {
|
||||
continue
|
||||
}
|
||||
|
||||
client.msgChan <- map[string]any{
|
||||
"type": "candidate",
|
||||
"time": time.Now().UnixMilli(),
|
||||
"data": map[string]any{
|
||||
"id": client.Id,
|
||||
"candidate": candidate,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/loveuer/ushare/internal/model"
|
||||
"github.com/loveuer/ushare/internal/opt"
|
||||
"github.com/loveuer/ushare/internal/pkg/tool"
|
||||
"github.com/pkg/errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type userManager struct {
|
||||
sync.Mutex
|
||||
ctx context.Context
|
||||
um map[string]*model.User
|
||||
}
|
||||
|
||||
func (um *userManager) Login(username string, password string) (*model.User, error) {
|
||||
var (
|
||||
now = time.Now()
|
||||
)
|
||||
|
||||
if username != "admin" {
|
||||
return nil, errors.New("账号或密码错误")
|
||||
}
|
||||
|
||||
if !tool.ComparePassword(password, opt.Cfg.Auth) {
|
||||
return nil, errors.New("账号或密码错误")
|
||||
}
|
||||
|
||||
op := &model.User{
|
||||
Id: 1,
|
||||
Username: username,
|
||||
LoginAt: now.Unix(),
|
||||
Token: tool.RandomString(32),
|
||||
}
|
||||
|
||||
um.Lock()
|
||||
defer um.Unlock()
|
||||
um.um[op.Token] = op
|
||||
|
||||
return op, nil
|
||||
}
|
||||
|
||||
func (um *userManager) Verify(token string) (*model.User, error) {
|
||||
um.Lock()
|
||||
defer um.Unlock()
|
||||
|
||||
op, ok := um.um[token]
|
||||
if !ok {
|
||||
return nil, errors.New("未登录或凭证已失效, 请重新登录")
|
||||
}
|
||||
|
||||
return op, nil
|
||||
}
|
||||
|
||||
func (um *userManager) Start(ctx context.Context) {
|
||||
um.ctx = ctx
|
||||
|
||||
go func() {
|
||||
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-um.ctx.Done():
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
um.Lock()
|
||||
for _, op := range um.um {
|
||||
if now.Sub(time.UnixMilli(op.LoginAt)) > 8*time.Hour {
|
||||
delete(um.um, op.Token)
|
||||
}
|
||||
}
|
||||
um.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
var (
|
||||
UserManager = &userManager{
|
||||
um: make(map[string]*model.User),
|
||||
}
|
||||
)
|
||||
@@ -1,70 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/loveuer/nf"
|
||||
"github.com/loveuer/ushare/internal/controller"
|
||||
"github.com/loveuer/ushare/internal/model"
|
||||
"github.com/loveuer/ushare/internal/opt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func AuthVerify() nf.HandlerFunc {
|
||||
tokenFn := func(c *nf.Ctx) (token string) {
|
||||
if token = c.Get("Authorization"); token != "" {
|
||||
return
|
||||
}
|
||||
|
||||
token = c.Cookies("ushare")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return func(c *nf.Ctx) error {
|
||||
if opt.Cfg.Auth == "" {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
token := tokenFn(c)
|
||||
if token == "" {
|
||||
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
|
||||
}
|
||||
|
||||
op, err := controller.UserManager.Verify(token)
|
||||
if err != nil {
|
||||
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized", "msg": err.Error()})
|
||||
}
|
||||
|
||||
c.Locals("user", op)
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func AuthLogin() nf.HandlerFunc {
|
||||
return func(c *nf.Ctx) error {
|
||||
type Req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
var (
|
||||
err error
|
||||
req Req
|
||||
op *model.User
|
||||
)
|
||||
|
||||
if err = c.BodyParser(&req); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "错误的用户名或密码<1>"})
|
||||
}
|
||||
|
||||
if op, err = controller.UserManager.Login(req.Username, req.Password); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
|
||||
}
|
||||
|
||||
header := fmt.Sprintf("ushare=%s; Path=/; Max-Age=%d", op.Token, 8*3600)
|
||||
c.SetHeader("Set-Cookie", header)
|
||||
|
||||
return c.Status(http.StatusOK).JSON(map[string]any{"data": op})
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/loveuer/nf"
|
||||
"github.com/loveuer/nf/nft/log"
|
||||
"github.com/loveuer/nf/nft/resp"
|
||||
"github.com/loveuer/ushare/internal/controller"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func LocalRegister() nf.HandlerFunc {
|
||||
return func(c *nf.Ctx) error {
|
||||
var (
|
||||
ip = c.IP(true)
|
||||
ua = c.Get("User-Agent")
|
||||
)
|
||||
|
||||
client := controller.RoomController.Register(ip, ua)
|
||||
|
||||
return resp.Resp200(c, client)
|
||||
}
|
||||
}
|
||||
|
||||
func LocalClients() nf.HandlerFunc {
|
||||
return func(c *nf.Ctx) error {
|
||||
list := controller.RoomController.List()
|
||||
|
||||
return resp.Resp200(c, list)
|
||||
}
|
||||
}
|
||||
|
||||
func LocalWS() nf.HandlerFunc {
|
||||
upgrader := websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
return func(c *nf.Ctx) error {
|
||||
id := c.Query("id")
|
||||
|
||||
if id == "" {
|
||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"error": "id is empty"})
|
||||
}
|
||||
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
log.Error("LocalWS: failed to upgrade websocket connection, err = %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
controller.RoomController.Enter(conn, id)
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func LocalOffer() nf.HandlerFunc {
|
||||
return func(c *nf.Ctx) error {
|
||||
type Req struct {
|
||||
Id string `json:"id"`
|
||||
From string `json:"from"`
|
||||
Offer *controller.RoomOffer `json:"offer"`
|
||||
}
|
||||
|
||||
var (
|
||||
err error
|
||||
req = new(Req)
|
||||
)
|
||||
|
||||
if err = c.BodyParser(req); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"err": err.Error()})
|
||||
}
|
||||
|
||||
controller.RoomController.Offer(req.Id, req.From, req.Offer)
|
||||
|
||||
return resp.Resp200(c, req.Offer)
|
||||
}
|
||||
}
|
||||
|
||||
func LocalAnswer() nf.HandlerFunc {
|
||||
return func(c *nf.Ctx) error {
|
||||
type Req struct {
|
||||
Id string `json:"id"`
|
||||
Answer *controller.RoomOffer `json:"answer"`
|
||||
}
|
||||
|
||||
var (
|
||||
err error
|
||||
req = new(Req)
|
||||
)
|
||||
|
||||
if err = c.BodyParser(req); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"err": err.Error()})
|
||||
}
|
||||
|
||||
controller.RoomController.Answer(req.Id, req.Answer)
|
||||
|
||||
return resp.Resp200(c, req)
|
||||
}
|
||||
}
|
||||
|
||||
func LocalCandidate() nf.HandlerFunc {
|
||||
return func(c *nf.Ctx) error {
|
||||
type Req struct {
|
||||
Id string `json:"id"`
|
||||
Candidate *controller.RoomCandidate `json:"candidate"`
|
||||
}
|
||||
|
||||
var (
|
||||
err error
|
||||
req = new(Req)
|
||||
)
|
||||
|
||||
if err = c.BodyParser(req); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"err": err.Error()})
|
||||
}
|
||||
|
||||
controller.RoomController.Candidate(req.Id, req.Candidate)
|
||||
|
||||
return resp.Resp200(c, req)
|
||||
}
|
||||
}
|
||||
@@ -3,23 +3,17 @@ package handler
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/loveuer/nf"
|
||||
"github.com/loveuer/nf/nft/log"
|
||||
"github.com/loveuer/ushare/internal/controller"
|
||||
"github.com/loveuer/ushare/internal/model"
|
||||
"github.com/loveuer/ushare/internal/opt"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cast"
|
||||
"github.com/spf13/viper"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Fetch() nf.HandlerFunc {
|
||||
return func(c *nf.Ctx) error {
|
||||
code := c.Param("code")
|
||||
log.Debug("handler.Fetch: code = %s", code)
|
||||
code := c.Query("code")
|
||||
info := new(model.Meta)
|
||||
_, err := os.Stat(opt.MetaPath(code))
|
||||
if err != nil {
|
||||
@@ -46,73 +40,3 @@ func Fetch() nf.HandlerFunc {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func ShareNew() nf.HandlerFunc {
|
||||
return func(c *nf.Ctx) error {
|
||||
filename := strings.TrimSpace(c.Param("filename"))
|
||||
if filename == "" {
|
||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "filename required"})
|
||||
}
|
||||
|
||||
size, err := cast.ToInt64E(c.Get(opt.HeaderSize))
|
||||
if err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "miss header: " + opt.HeaderSize})
|
||||
}
|
||||
|
||||
code, err := controller.MetaManager.New(size, filename, c.IP())
|
||||
if err != nil {
|
||||
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": ""})
|
||||
}
|
||||
|
||||
return c.Status(http.StatusOK).JSON(map[string]string{"code": code})
|
||||
}
|
||||
}
|
||||
|
||||
func ShareUpload() nf.HandlerFunc {
|
||||
rangeValidator := regexp.MustCompile(`^bytes=\d+-\d+$`)
|
||||
return func(c *nf.Ctx) error {
|
||||
code := strings.TrimSpace(c.Param("code"))
|
||||
if len(code) != opt.CodeLength {
|
||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "invalid file code"})
|
||||
}
|
||||
|
||||
log.Debug("handler.ShareUpload: code = %s", code)
|
||||
|
||||
ranger := strings.TrimSpace(c.Get("Range"))
|
||||
if ranger == "" {
|
||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "miss header: Range"})
|
||||
}
|
||||
|
||||
log.Debug("handler.ShareUpload: code = %s, ranger = %s", code, ranger)
|
||||
|
||||
if !rangeValidator.MatchString(ranger) {
|
||||
log.Warn("handler.ShareUpload: invalid range, ranger = %s", ranger)
|
||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "Range invalid(1)"})
|
||||
}
|
||||
|
||||
strs := strings.Split(strings.TrimPrefix(ranger, "bytes="), "-")
|
||||
if len(strs) != 2 {
|
||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "Range invalid(2)"})
|
||||
}
|
||||
|
||||
start, err := cast.ToInt64E(strs[0])
|
||||
if err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "Range invalid(3)"})
|
||||
}
|
||||
|
||||
end, err := cast.ToInt64E(strs[1])
|
||||
if err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "Range invalid(4)"})
|
||||
}
|
||||
|
||||
log.Debug("handler.ShareUpload: code = %s, start = %d, end = %d", code, start, end)
|
||||
|
||||
total, cursor, err := controller.MetaManager.Write(code, start, end, c.Request.Body)
|
||||
if err != nil {
|
||||
log.Error("handler.ShareUpload: write error: %s", err)
|
||||
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": ""})
|
||||
}
|
||||
|
||||
return c.Status(http.StatusOK).JSON(map[string]any{"size": total, "cursor": cursor})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@ type Meta struct {
|
||||
Filename string `json:"filename" mapstructure:"filename"`
|
||||
CreatedAt int64 `json:"created_at" mapstructure:"created_at"`
|
||||
Size int64 `json:"size" mapstructure:"size"`
|
||||
Uploader string `json:"uploader" mapstructure:"uploader"`
|
||||
UploaderIp string `json:"uploader_ip" mapstructure:"uploader_ip"`
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
package model
|
||||
|
||||
type User struct {
|
||||
Id int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Key string `json:"key"`
|
||||
Password string `json:"-"`
|
||||
LoginAt int64 `json:"login_at"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package model
|
||||
|
||||
type WSMessageType string
|
||||
|
||||
const (
|
||||
WSMessageTypeOffer WSMessageType = "offer"
|
||||
WSMessageTypeCandidate WSMessageType = "candidate"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Type WSMessageType `json:"type"`
|
||||
Time int64 `json:"time"`
|
||||
Body any `json:"body"`
|
||||
}
|
||||
@@ -1,26 +1,11 @@
|
||||
package opt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/loveuer/nf/nft/log"
|
||||
"github.com/loveuer/ushare/internal/pkg/tool"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
Debug bool
|
||||
Address string
|
||||
DataPath string
|
||||
Auth string
|
||||
CleanInterval int
|
||||
}
|
||||
|
||||
var (
|
||||
Cfg = &config{}
|
||||
)
|
||||
|
||||
func Init(_ context.Context) {
|
||||
if Cfg.Auth != "" {
|
||||
Cfg.Auth = tool.NewPassword(Cfg.Auth)
|
||||
log.Debug("opt.Init: encrypted password = %s", Cfg.Auth)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import "path/filepath"
|
||||
|
||||
const (
|
||||
Meta = ".meta."
|
||||
HeaderSize = "X-File-Size"
|
||||
CodeLength = 8
|
||||
)
|
||||
|
||||
func FilePath(code string) string {
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"au99999/internal/opt"
|
||||
"au99999/pkg/tool"
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var Default *Client
|
||||
|
||||
type DBType string
|
||||
|
||||
const (
|
||||
DBTypeSqlite = "sqlite"
|
||||
DBTypeMysql = "mysql"
|
||||
DBTypePostgres = "postgres"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
ctx context.Context
|
||||
cli *gorm.DB
|
||||
dbType DBType
|
||||
}
|
||||
|
||||
func (c *Client) Type() DBType {
|
||||
return c.dbType
|
||||
}
|
||||
|
||||
func (c *Client) Session(ctxs ...context.Context) *gorm.DB {
|
||||
var ctx context.Context
|
||||
if len(ctxs) > 0 && ctxs[0] != nil {
|
||||
ctx = ctxs[0]
|
||||
} else {
|
||||
ctx = tool.Timeout(30)
|
||||
}
|
||||
|
||||
session := c.cli.Session(&gorm.Session{Context: ctx})
|
||||
|
||||
if opt.Cfg.Debug {
|
||||
session = session.Debug()
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
func (c *Client) Close() {
|
||||
d, _ := c.cli.DB()
|
||||
d.Close()
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const tip = `example:
|
||||
for sqlite -> sqlite::<filepath>
|
||||
sqlite::data.sqlite
|
||||
sqlite::/data/data.db
|
||||
for mysql -> mysql::<gorm_dsn>
|
||||
mysql::user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local
|
||||
for postgres -> postgres::<gorm_dsn>
|
||||
postgres::host=localhost user=gorm password=gorm dbname=gorm port=9920 sslmode=disable TimeZone=Asia/Shanghai
|
||||
`
|
||||
|
||||
func New(ctx context.Context, uri string) (*Client, error) {
|
||||
parts := strings.SplitN(uri, "::", 2)
|
||||
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("db.Init: db uri invalid\n%s", tip)
|
||||
}
|
||||
|
||||
c := &Client{}
|
||||
|
||||
var (
|
||||
err error
|
||||
dsn = parts[1]
|
||||
)
|
||||
|
||||
switch parts[0] {
|
||||
case "sqlite":
|
||||
c.dbType = DBTypeSqlite
|
||||
c.cli, err = gorm.Open(sqlite.Open(dsn))
|
||||
case "mysql":
|
||||
c.dbType = DBTypeMysql
|
||||
c.cli, err = gorm.Open(mysql.Open(dsn))
|
||||
case "postgres":
|
||||
c.dbType = DBTypePostgres
|
||||
c.cli, err = gorm.Open(postgres.Open(dsn))
|
||||
default:
|
||||
return nil, fmt.Errorf("db type only support: [sqlite, mysql, postgres], unsupported db type: %s", parts[0])
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.Init: open %s with dsn:%s, err: %w", parts[0], dsn, err)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func Init(ctx context.Context, uri string) (err error) {
|
||||
if Default, err = New(ctx, uri); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package tool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Timeout(seconds ...int) (ctx context.Context) {
|
||||
var (
|
||||
duration time.Duration
|
||||
)
|
||||
|
||||
if len(seconds) > 0 && seconds[0] > 0 {
|
||||
duration = time.Duration(seconds[0]) * time.Second
|
||||
} else {
|
||||
duration = time.Duration(30) * time.Second
|
||||
}
|
||||
|
||||
ctx, _ = context.WithTimeout(context.Background(), duration)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func TimeoutCtx(ctx context.Context, seconds ...int) context.Context {
|
||||
var (
|
||||
duration time.Duration
|
||||
)
|
||||
|
||||
if len(seconds) > 0 && seconds[0] > 0 {
|
||||
duration = time.Duration(seconds[0]) * time.Second
|
||||
} else {
|
||||
duration = time.Duration(30) * time.Second
|
||||
}
|
||||
|
||||
nctx, _ := context.WithTimeout(ctx, duration)
|
||||
|
||||
return nctx
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package tool
|
||||
|
||||
import "fmt"
|
||||
|
||||
func HumanDuration(nano int64) string {
|
||||
duration := float64(nano)
|
||||
unit := "ns"
|
||||
if duration >= 1000 {
|
||||
duration /= 1000
|
||||
unit = "us"
|
||||
}
|
||||
|
||||
if duration >= 1000 {
|
||||
duration /= 1000
|
||||
unit = "ms"
|
||||
}
|
||||
|
||||
if duration >= 1000 {
|
||||
duration /= 1000
|
||||
unit = " s"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%6.2f%s", duration, unit)
|
||||
}
|
||||
|
||||
func HumanSize(size int64) string {
|
||||
const (
|
||||
_ = iota
|
||||
KB = 1 << (10 * iota) // 1 KB = 1024 bytes
|
||||
MB // 1 MB = 1024 KB
|
||||
GB // 1 GB = 1024 MB
|
||||
TB // 1 TB = 1024 GB
|
||||
PB // 1 PB = 1024 TB
|
||||
)
|
||||
|
||||
switch {
|
||||
case size >= PB:
|
||||
return fmt.Sprintf("%.2f PB", float64(size)/PB)
|
||||
case size >= TB:
|
||||
return fmt.Sprintf("%.2f TB", float64(size)/TB)
|
||||
case size >= GB:
|
||||
return fmt.Sprintf("%.2f GB", float64(size)/GB)
|
||||
case size >= MB:
|
||||
return fmt.Sprintf("%.2f MB", float64(size)/MB)
|
||||
case size >= KB:
|
||||
return fmt.Sprintf("%.2f KB", float64(size)/KB)
|
||||
default:
|
||||
return fmt.Sprintf("%d bytes", size)
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package tool
|
||||
|
||||
import (
|
||||
"net"
|
||||
)
|
||||
|
||||
var (
|
||||
privateIPv4Blocks []*net.IPNet
|
||||
privateIPv6Blocks []*net.IPNet
|
||||
)
|
||||
|
||||
func init() {
|
||||
// IPv4私有地址段
|
||||
for _, cidr := range []string{
|
||||
"10.0.0.0/8", // A类私有地址
|
||||
"172.16.0.0/12", // B类私有地址
|
||||
"192.168.0.0/16", // C类私有地址
|
||||
"169.254.0.0/16", // 链路本地地址
|
||||
"127.0.0.0/8", // 环回地址
|
||||
} {
|
||||
_, block, _ := net.ParseCIDR(cidr)
|
||||
privateIPv4Blocks = append(privateIPv4Blocks, block)
|
||||
}
|
||||
|
||||
// IPv6私有地址段
|
||||
for _, cidr := range []string{
|
||||
"fc00::/7", // 唯一本地地址
|
||||
"fe80::/10", // 链路本地地址
|
||||
"::1/128", // 环回地址
|
||||
} {
|
||||
_, block, _ := net.ParseCIDR(cidr)
|
||||
privateIPv6Blocks = append(privateIPv6Blocks, block)
|
||||
}
|
||||
}
|
||||
|
||||
func IsPrivateIP(ipStr string) bool {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// 处理IPv4和IPv4映射的IPv6地址
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
for _, block := range privateIPv4Blocks {
|
||||
if block.Contains(ip4) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 处理IPv6地址
|
||||
for _, block := range privateIPv6Blocks {
|
||||
if block.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package tool
|
||||
|
||||
import "math"
|
||||
|
||||
func Map[T, R any](vals []T, fn func(item T, index int) R) []R {
|
||||
var result = make([]R, len(vals))
|
||||
for idx, v := range vals {
|
||||
result[idx] = fn(v, idx)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func Chunk[T any](vals []T, size int) [][]T {
|
||||
if size <= 0 {
|
||||
panic("Second parameter must be greater than 0")
|
||||
}
|
||||
|
||||
chunksNum := len(vals) / size
|
||||
if len(vals)%size != 0 {
|
||||
chunksNum += 1
|
||||
}
|
||||
|
||||
result := make([][]T, 0, chunksNum)
|
||||
|
||||
for i := 0; i < chunksNum; i++ {
|
||||
last := (i + 1) * size
|
||||
if last > len(vals) {
|
||||
last = len(vals)
|
||||
}
|
||||
result = append(result, vals[i*size:last:last])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// 对 vals 取样 x 个
|
||||
func Sample[T any](vals []T, x int) []T {
|
||||
if x < 0 {
|
||||
panic("Second parameter can't be negative")
|
||||
}
|
||||
|
||||
n := len(vals)
|
||||
if n == 0 {
|
||||
return []T{}
|
||||
}
|
||||
|
||||
if x >= n {
|
||||
return vals
|
||||
}
|
||||
|
||||
// 处理x=1的特殊情况
|
||||
if x == 1 {
|
||||
return []T{vals[(n-1)/2]}
|
||||
}
|
||||
|
||||
// 计算采样步长并生成结果数组
|
||||
step := float64(n-1) / float64(x-1)
|
||||
result := make([]T, x)
|
||||
|
||||
for i := 0; i < x; i++ {
|
||||
// 计算采样位置并四舍五入
|
||||
pos := float64(i) * step
|
||||
index := int(math.Round(pos))
|
||||
result[i] = vals[index]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package tool
|
||||
|
||||
import "github.com/loveuer/nf/nft/log"
|
||||
|
||||
func Must(errs ...error) {
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
log.Panic(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package tool
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/loveuer/nf/nft/log"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
EncryptHeader string = "pbkdf2:sha256" // 用户密码加密
|
||||
)
|
||||
|
||||
func NewPassword(password string) string {
|
||||
return EncryptPassword(password, RandomString(8), int(RandomInt(50000)+100000))
|
||||
}
|
||||
|
||||
func ComparePassword(in, db string) bool {
|
||||
strs := strings.Split(db, "$")
|
||||
if len(strs) != 3 {
|
||||
log.Error("password in db invalid: %s", db)
|
||||
return false
|
||||
}
|
||||
|
||||
encs := strings.Split(strs[0], ":")
|
||||
if len(encs) != 3 {
|
||||
log.Error("password in db invalid: %s", db)
|
||||
return false
|
||||
}
|
||||
|
||||
encIteration, err := strconv.Atoi(encs[2])
|
||||
if err != nil {
|
||||
log.Error("password in db invalid: %s, convert iter err: %s", db, err)
|
||||
return false
|
||||
}
|
||||
|
||||
return EncryptPassword(in, strs[1], encIteration) == db
|
||||
}
|
||||
|
||||
func EncryptPassword(password, salt string, iter int) string {
|
||||
hash := pbkdf2.Key([]byte(password), []byte(salt), iter, 32, sha256.New)
|
||||
encrypted := hex.EncodeToString(hash)
|
||||
return fmt.Sprintf("%s:%d$%s$%s", EncryptHeader, iter, salt, encrypted)
|
||||
}
|
||||
|
||||
func CheckPassword(password string) error {
|
||||
if len(password) < 8 || len(password) > 32 {
|
||||
return errors.New("密码长度不符合")
|
||||
}
|
||||
|
||||
var (
|
||||
err error
|
||||
match bool
|
||||
patternList = []string{`[0-9]+`, `[a-z]+`, `[A-Z]+`, `[!@#%]+`} //, `[~!@#$%^&*?_-]+`}
|
||||
matchAccount = 0
|
||||
tips = []string{"缺少数字", "缺少小写字母", "缺少大写字母", "缺少'!@#%'"}
|
||||
locktips = make([]string, 0)
|
||||
)
|
||||
|
||||
for idx, pattern := range patternList {
|
||||
match, err = regexp.MatchString(pattern, password)
|
||||
if err != nil {
|
||||
log.Warn("regex match string err, reg_str: %s, err: %v", pattern, err)
|
||||
return errors.New("密码强度不够")
|
||||
}
|
||||
|
||||
if match {
|
||||
matchAccount++
|
||||
} else {
|
||||
locktips = append(locktips, tips[idx])
|
||||
}
|
||||
}
|
||||
|
||||
if matchAccount < 3 {
|
||||
return fmt.Errorf("密码强度不够, 可能 %s", strings.Join(locktips, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package tool
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEncPassword(t *testing.T) {
|
||||
password := "123456"
|
||||
|
||||
result := EncryptPassword(password, RandomString(8), 50000)
|
||||
|
||||
t.Logf("sum => %s", result)
|
||||
}
|
||||
|
||||
func TestPassword(t *testing.T) {
|
||||
p := "wahaha@123"
|
||||
p = NewPassword(p)
|
||||
t.Logf("password => %s", p)
|
||||
|
||||
result := ComparePassword("wahaha@123", p)
|
||||
t.Logf("compare result => %v", result)
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package tool
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"math/big"
|
||||
mrand "math/rand"
|
||||
)
|
||||
|
||||
var (
|
||||
letters = []byte("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
letterNum = []byte("0123456789")
|
||||
letterLow = []byte("abcdefghijklmnopqrstuvwxyz")
|
||||
letterCap = []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
letterSyb = []byte("!@#$%^&*()_+-=")
|
||||
adjectives = []string{
|
||||
"开心的", "灿烂的", "温暖的", "阳光的", "活泼的",
|
||||
"聪明的", "优雅的", "幸运的", "甜蜜的", "勇敢的",
|
||||
"宁静的", "热情的", "温柔的", "幽默的", "坚强的",
|
||||
"迷人的", "神奇的", "快乐的", "健康的", "自由的",
|
||||
"梦幻的", "勤劳的", "真诚的", "浪漫的", "自信的",
|
||||
}
|
||||
|
||||
plants = []string{
|
||||
"苹果", "香蕉", "橘子", "葡萄", "草莓",
|
||||
"西瓜", "樱桃", "菠萝", "柠檬", "蜜桃",
|
||||
"蓝莓", "芒果", "石榴", "甜瓜", "雪梨",
|
||||
"番茄", "南瓜", "土豆", "青椒", "洋葱",
|
||||
"黄瓜", "萝卜", "豌豆", "玉米", "蘑菇",
|
||||
"菠菜", "茄子", "芹菜", "莲藕", "西兰花",
|
||||
}
|
||||
)
|
||||
|
||||
func RandomInt(max int64) int64 {
|
||||
num, _ := rand.Int(rand.Reader, big.NewInt(max))
|
||||
return num.Int64()
|
||||
}
|
||||
|
||||
func RandomString(length int) string {
|
||||
result := make([]byte, length)
|
||||
for i := 0; i < length; i++ {
|
||||
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
|
||||
result[i] = letters[num.Int64()]
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func RandomPassword(length int, withSymbol bool) string {
|
||||
result := make([]byte, length)
|
||||
kind := 3
|
||||
if withSymbol {
|
||||
kind++
|
||||
}
|
||||
|
||||
for i := 0; i < length; i++ {
|
||||
switch i % kind {
|
||||
case 0:
|
||||
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letterNum))))
|
||||
result[i] = letterNum[num.Int64()]
|
||||
case 1:
|
||||
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letterLow))))
|
||||
result[i] = letterLow[num.Int64()]
|
||||
case 2:
|
||||
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letterCap))))
|
||||
result[i] = letterCap[num.Int64()]
|
||||
case 3:
|
||||
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letterSyb))))
|
||||
result[i] = letterSyb[num.Int64()]
|
||||
}
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func RandomName() string {
|
||||
return adjectives[mrand.Intn(len(adjectives))] + plants[mrand.Intn(len(plants))]
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
package tool
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"github.com/loveuer/nf/nft/log"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func TablePrinter(data any, writers ...io.Writer) {
|
||||
var w io.Writer = os.Stdout
|
||||
if len(writers) > 0 && writers[0] != nil {
|
||||
w = writers[0]
|
||||
}
|
||||
|
||||
t := table.NewWriter()
|
||||
structPrinter(t, "", data)
|
||||
_, _ = fmt.Fprintln(w, t.Render())
|
||||
}
|
||||
|
||||
func structPrinter(w table.Writer, prefix string, item any) {
|
||||
Start:
|
||||
rv := reflect.ValueOf(item)
|
||||
if rv.IsZero() {
|
||||
return
|
||||
}
|
||||
|
||||
for rv.Type().Kind() == reflect.Pointer {
|
||||
rv = rv.Elem()
|
||||
}
|
||||
|
||||
switch rv.Type().Kind() {
|
||||
case reflect.Invalid,
|
||||
reflect.Uintptr,
|
||||
reflect.Chan,
|
||||
reflect.Func,
|
||||
reflect.UnsafePointer:
|
||||
case reflect.Bool,
|
||||
reflect.Int,
|
||||
reflect.Int8,
|
||||
reflect.Int16,
|
||||
reflect.Int32,
|
||||
reflect.Int64,
|
||||
reflect.Uint,
|
||||
reflect.Uint8,
|
||||
reflect.Uint16,
|
||||
reflect.Uint32,
|
||||
reflect.Uint64,
|
||||
reflect.Float32,
|
||||
reflect.Float64,
|
||||
reflect.Complex64,
|
||||
reflect.Complex128,
|
||||
reflect.Interface:
|
||||
w.AppendRow(table.Row{strings.TrimPrefix(prefix, "."), rv.Interface()})
|
||||
case reflect.String:
|
||||
val := rv.String()
|
||||
if len(val) <= 160 {
|
||||
w.AppendRow(table.Row{strings.TrimPrefix(prefix, "."), val})
|
||||
return
|
||||
}
|
||||
|
||||
w.AppendRow(table.Row{strings.TrimPrefix(prefix, "."), val[0:64] + "..." + val[len(val)-64:]})
|
||||
case reflect.Array, reflect.Slice:
|
||||
for i := 0; i < rv.Len(); i++ {
|
||||
p := strings.Join([]string{prefix, fmt.Sprintf("[%d]", i)}, ".")
|
||||
structPrinter(w, p, rv.Index(i).Interface())
|
||||
}
|
||||
case reflect.Map:
|
||||
for _, k := range rv.MapKeys() {
|
||||
structPrinter(w, fmt.Sprintf("%s.{%v}", prefix, k), rv.MapIndex(k).Interface())
|
||||
}
|
||||
case reflect.Pointer:
|
||||
goto Start
|
||||
case reflect.Struct:
|
||||
for i := 0; i < rv.NumField(); i++ {
|
||||
p := fmt.Sprintf("%s.%s", prefix, rv.Type().Field(i).Name)
|
||||
field := rv.Field(i)
|
||||
|
||||
//log.Debug("TablePrinter: prefix: %s, field: %v", p, rv.Field(i))
|
||||
|
||||
if !field.CanInterface() {
|
||||
return
|
||||
}
|
||||
|
||||
structPrinter(w, p, field.Interface())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TableMapPrinter(data []byte) {
|
||||
m := make(map[string]any)
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
log.Warn(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
t := table.NewWriter()
|
||||
addRow(t, "", m)
|
||||
fmt.Println(t.Render())
|
||||
}
|
||||
|
||||
func addRow(w table.Writer, prefix string, m any) {
|
||||
rv := reflect.ValueOf(m)
|
||||
switch rv.Type().Kind() {
|
||||
case reflect.Map:
|
||||
for _, k := range rv.MapKeys() {
|
||||
key := k.String()
|
||||
if prefix != "" {
|
||||
key = strings.Join([]string{prefix, k.String()}, ".")
|
||||
}
|
||||
addRow(w, key, rv.MapIndex(k).Interface())
|
||||
}
|
||||
case reflect.Slice, reflect.Array:
|
||||
for i := 0; i < rv.Len(); i++ {
|
||||
addRow(w, fmt.Sprintf("%s[%d]", prefix, i), rv.Index(i).Interface())
|
||||
}
|
||||
default:
|
||||
w.AppendRow(table.Row{prefix, m})
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package tool
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
func Min[T ~int | ~uint | ~int8 | ~uint8 | ~int16 | ~uint16 | ~int32 | ~uint32 | ~int64 | ~uint64 | ~float32 | ~float64](a, b T) T {
|
||||
if a <= b {
|
||||
return a
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func Mins[T ~int | ~uint | ~int8 | ~uint8 | ~int16 | ~uint16 | ~int32 | ~uint32 | ~int64 | ~uint64 | ~float32 | ~float64](vals ...T) T {
|
||||
var val T
|
||||
|
||||
if len(vals) == 0 {
|
||||
return val
|
||||
}
|
||||
|
||||
val = vals[0]
|
||||
|
||||
for _, item := range vals[1:] {
|
||||
if item < val {
|
||||
val = item
|
||||
}
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
func Max[T ~int | ~uint | ~int8 | ~uint8 | ~int16 | ~uint16 | ~int32 | ~uint32 | ~int64 | ~uint64 | ~float32 | ~float64](a, b T) T {
|
||||
if a >= b {
|
||||
return a
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func Maxs[T ~int | ~uint | ~int8 | ~uint8 | ~int16 | ~uint16 | ~int32 | ~uint32 | ~int64 | ~uint64 | ~float32 | ~float64](vals ...T) T {
|
||||
var val T
|
||||
|
||||
if len(vals) == 0 {
|
||||
return val
|
||||
}
|
||||
|
||||
for _, item := range vals {
|
||||
if item > val {
|
||||
val = item
|
||||
}
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
func Sum[T ~int | ~uint | ~int8 | ~uint8 | ~int16 | ~uint16 | ~int32 | ~uint32 | ~int64 | ~uint64 | ~float32 | ~float64](vals ...T) T {
|
||||
var sum T = 0
|
||||
for i := range vals {
|
||||
sum += vals[i]
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
func Percent(val, minVal, maxVal, minPercent, maxPercent float64) string {
|
||||
return fmt.Sprintf(
|
||||
"%d%%",
|
||||
int(math.Round(
|
||||
((val-minVal)/(maxVal-minVal)*(maxPercent-minPercent)+minPercent)*100,
|
||||
)),
|
||||
)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package tool
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPercent(t *testing.T) {
|
||||
type args struct {
|
||||
val float64
|
||||
minVal float64
|
||||
maxVal float64
|
||||
minPercent float64
|
||||
maxPercent float64
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "case 1",
|
||||
args: args{
|
||||
val: 0.5,
|
||||
minVal: 0,
|
||||
maxVal: 1,
|
||||
minPercent: 0,
|
||||
maxPercent: 1,
|
||||
},
|
||||
want: "50%",
|
||||
},
|
||||
{
|
||||
name: "case 2",
|
||||
args: args{
|
||||
val: 0.3,
|
||||
minVal: 0.1,
|
||||
maxVal: 0.6,
|
||||
minPercent: 0,
|
||||
maxPercent: 1,
|
||||
},
|
||||
want: "40%",
|
||||
},
|
||||
{
|
||||
name: "case 3",
|
||||
args: args{
|
||||
val: 700,
|
||||
minVal: 700,
|
||||
maxVal: 766,
|
||||
minPercent: 0.1,
|
||||
maxPercent: 0.7,
|
||||
},
|
||||
want: "10%",
|
||||
},
|
||||
{
|
||||
name: "case 4",
|
||||
args: args{
|
||||
val: 766,
|
||||
minVal: 700,
|
||||
maxVal: 766,
|
||||
minPercent: 0.1,
|
||||
maxPercent: 0.7,
|
||||
},
|
||||
want: "70%",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := Percent(tt.args.val, tt.args.minVal, tt.args.maxVal, tt.args.minPercent, tt.args.maxPercent); got != tt.want {
|
||||
t.Errorf("Percent() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,37 +3,23 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"github.com/loveuer/nf/nft/log"
|
||||
"github.com/loveuer/ushare/internal/api"
|
||||
"github.com/loveuer/ushare/internal/controller"
|
||||
"github.com/loveuer/ushare/internal/opt"
|
||||
"github.com/loveuer/ushare/internal/pkg/tool"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func init() {
|
||||
flag.BoolVar(&opt.Cfg.Debug, "debug", false, "debug mode")
|
||||
flag.StringVar(&opt.Cfg.Address, "address", "0.0.0.0:9119", "")
|
||||
flag.StringVar(&opt.Cfg.Address, "address", "0.0.0.0:80", "")
|
||||
flag.StringVar(&opt.Cfg.DataPath, "data", "/data", "")
|
||||
flag.StringVar(&opt.Cfg.Auth, "auth", "", "auth required(admin, password)")
|
||||
flag.IntVar(&opt.Cfg.CleanInterval, "clean", 24, "清理文件的周期, 单位: 小时, 0 则表示不自动清理")
|
||||
flag.Parse()
|
||||
|
||||
if opt.Cfg.Debug {
|
||||
log.SetLogLevel(log.LogLevelDebug)
|
||||
tool.TablePrinter(opt.Cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
opt.Init(ctx)
|
||||
controller.UserManager.Start(ctx)
|
||||
controller.MetaManager.Start(ctx)
|
||||
controller.RoomController.Start(ctx)
|
||||
api.Start(ctx)
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>柔和气泡</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
background: linear-gradient(45deg, #e6e9f0, #eef1f5);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
position: absolute;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
font-family: 'Microsoft Yahei', sans-serif;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-shadow: 1px 1px 3px rgba(0,0,0,0.3);
|
||||
transition: transform 0.3s ease;
|
||||
animation: float 6s ease-in-out infinite;
|
||||
/* 泡泡细节 */
|
||||
background: radial-gradient(circle at 30% 30%,
|
||||
rgba(255, 255, 255, 0.8) 10%,
|
||||
rgba(255, 255, 255, 0.3) 50%,
|
||||
transparent 100%);
|
||||
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||
box-shadow: inset 0 -5px 15px rgba(255,255,255,0.3),
|
||||
0 5px 15px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* 高光效果 */
|
||||
.bubble::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border-radius: 50%;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.bubble:hover {
|
||||
transform: scale(1.05) rotate(3deg);
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
33% { transform: translateY(-20px) rotate(3deg); }
|
||||
66% { transform: translateY(10px) rotate(-3deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
const prefixes = ['宁静', '温暖', '甜蜜', '柔和', '灿烂', '神秘', '清爽'];
|
||||
const suffixes = ['梦境', '时光', '旋律', '花园', '云端', '海洋', '晨露'];
|
||||
const existingPositions = [];
|
||||
const BUBBLE_SIZE = 100;
|
||||
|
||||
// 检测碰撞
|
||||
function checkCollision(x, y) {
|
||||
return existingPositions.some(pos => {
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(x - pos.x, 2) +
|
||||
Math.pow(y - pos.y, 2)
|
||||
);
|
||||
return distance < BUBBLE_SIZE * 1.2;
|
||||
});
|
||||
}
|
||||
|
||||
function createBubble() {
|
||||
let x, y;
|
||||
let attempts = 0;
|
||||
|
||||
// 生成非重叠位置
|
||||
do {
|
||||
x = Math.random() * (window.innerWidth - BUBBLE_SIZE);
|
||||
y = Math.random() * (window.innerHeight - BUBBLE_SIZE);
|
||||
attempts++;
|
||||
} while (checkCollision(x, y) && attempts < 100);
|
||||
|
||||
if (attempts >= 100) return;
|
||||
|
||||
existingPositions.push({x, y});
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'bubble';
|
||||
|
||||
// 随机颜色
|
||||
const hue = Math.random() * 360;
|
||||
bubble.style.backgroundColor = `hsla(${hue},
|
||||
${Math.random() * 30 + 40}%,
|
||||
${Math.random() * 10 + 75}%, 0.9)`;
|
||||
|
||||
// 随机名称
|
||||
bubble.textContent = `${prefixes[Math.random()*prefixes.length|0]}的${suffixes[Math.random()*suffixes.length|0]}`;
|
||||
|
||||
// 位置和动画
|
||||
bubble.style.left = x + 'px';
|
||||
bubble.style.top = y + 'px';
|
||||
bubble.style.animationDelay = Math.random() * 2 + 's';
|
||||
|
||||
// 点击效果
|
||||
bubble.addEventListener('click', () => {
|
||||
bubble.style.transition = 'transform 0.5s, opacity 0.5s';
|
||||
bubble.style.transform = 'scale(1.5)';
|
||||
bubble.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
bubble.remove();
|
||||
existingPositions.splice(existingPositions.findIndex(
|
||||
pos => pos.x === x && pos.y === y), 1);
|
||||
createBubble();
|
||||
}, 500);
|
||||
});
|
||||
|
||||
document.body.appendChild(bubble);
|
||||
}
|
||||
|
||||
// 初始化15个气泡
|
||||
for (let i = 0; i < 15; i++) createBubble();
|
||||
|
||||
// 自动补充气泡
|
||||
setInterval(() => {
|
||||
if (document.querySelectorAll('.bubble').length < 20) {
|
||||
createBubble();
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
// 窗口大小变化时重置
|
||||
window.addEventListener('resize', () => {
|
||||
existingPositions.length = 0;
|
||||
document.querySelectorAll('.bubble').forEach(bubble => bubble.remove());
|
||||
for (let i = 0; i < 15; i++) createBubble();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
-166
@@ -1,166 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login Page</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f5f5f5;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
color: #333;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="password"]:focus {
|
||||
border-color: #4CAF50;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.remember-me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.forgot-password {
|
||||
color: #4CAF50;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.forgot-password:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
|
||||
.signup-link {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.signup-link a {
|
||||
color: #4CAF50;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.signup-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 480px) {
|
||||
.login-box {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-options {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="login-box">
|
||||
<h2>Welcome Back</h2>
|
||||
<form class="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
<div class="form-options">
|
||||
<label class="remember-me">
|
||||
<input type="checkbox"> Remember me
|
||||
</label>
|
||||
<a href="#" class="forgot-password">Forgot Password?</a>
|
||||
</div>
|
||||
<button type="submit" class="login-button">Log In</button>
|
||||
</form>
|
||||
<p class="signup-link">Don't have an account? <a href="#">Sign up</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,179 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>智能文本区域</title>
|
||||
<style>
|
||||
.container {
|
||||
width: 400px;
|
||||
height: 300px;
|
||||
margin: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-box {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
resize: none;
|
||||
outline: none;
|
||||
font-family: Arial, sans-serif;
|
||||
transition: padding 0.3s ease;
|
||||
}
|
||||
|
||||
.input-box.has-files {
|
||||
padding-top: 50px;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 8px 10px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-bottom: 1px solid #eee;
|
||||
display: none;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-height: 60px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.file-list.has-files {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #f0f6ff;
|
||||
border: 1px solid #c2d9ff;
|
||||
border-radius: 15px;
|
||||
padding: 4px 12px;
|
||||
font-size: 13px;
|
||||
animation: slideIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateY(-10px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: none;
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
margin-left: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #ff5252;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 8px 16px;
|
||||
background: #4dabf7;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #339af0;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<textarea class="input-box" placeholder="开始输入..."></textarea>
|
||||
<div class="file-list" id="fileList"></div>
|
||||
<div class="buttons">
|
||||
<input type="file" id="fileInput" hidden multiple>
|
||||
<button class="action-btn" onclick="document.getElementById('fileInput').click()">📁 选择文件</button>
|
||||
<button class="action-btn" onclick="send()">✈️ 发送</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const inputBox = document.querySelector('.input-box');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const fileList = document.getElementById('fileList');
|
||||
|
||||
// 文件选择事件
|
||||
fileInput.addEventListener('change', function() {
|
||||
updateFileList();
|
||||
adjustInputPadding();
|
||||
});
|
||||
|
||||
// 动态调整输入框间距
|
||||
function adjustInputPadding() {
|
||||
inputBox.classList.toggle('has-files', fileInput.files.length > 0);
|
||||
fileList.classList.toggle('has-files')
|
||||
}
|
||||
|
||||
// 更新文件列表显示
|
||||
function updateFileList() {
|
||||
fileList.innerHTML = '';
|
||||
|
||||
Array.from(fileInput.files).forEach((file, index) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'file-item';
|
||||
item.innerHTML = `
|
||||
<span>${file.name}</span>
|
||||
<button class="delete-btn" onclick="removeFile(${index})">×</button>
|
||||
`;
|
||||
fileList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
// 删除单个文件
|
||||
function removeFile(index) {
|
||||
const dt = new DataTransfer();
|
||||
Array.from(fileInput.files).forEach((file, i) => {
|
||||
if (i !== index) dt.items.add(file);
|
||||
});
|
||||
fileInput.files = dt.files;
|
||||
updateFileList();
|
||||
adjustInputPadding();
|
||||
}
|
||||
|
||||
// 发送功能
|
||||
function send() {
|
||||
const text = inputBox.value;
|
||||
const files = fileInput.files;
|
||||
|
||||
|
||||
// 清空内容
|
||||
inputBox.value = '';
|
||||
fileInput.value = '';
|
||||
fileList.innerHTML = '';
|
||||
adjustInputPadding();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user