Compare commits
2 Commits
v0.6.0
...
a6d97b4a6f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6d97b4a6f | ||
|
|
53ece08ba8 |
59
.gitea/workflows/build.yaml
Normal file
59
.gitea/workflows/build.yaml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
run-name: build ushare docker image
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: debian
|
|
||||||
steps:
|
|
||||||
- name: prepare environment
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: print info
|
|
||||||
run: |
|
|
||||||
date '+%Y-%m-%dT%H:%M:%S'
|
|
||||||
whoami
|
|
||||||
echo "Tag = ${{ gitea.ref_name }}"
|
|
||||||
pwd && ls -alsh .
|
|
||||||
|
|
||||||
- name: build docker image
|
|
||||||
run: docker build -t ushare:${{ gitea.ref_name }} .
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
run-name: release ushare binaries
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: debian
|
|
||||||
steps:
|
|
||||||
- name: prepare environment
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: print info
|
|
||||||
run: |
|
|
||||||
date '+%Y-%m-%dT%H:%M:%S'
|
|
||||||
echo "Tag = ${{ gitea.ref_name }}"
|
|
||||||
echo "Repository = ${{ gitea.repository }}"
|
|
||||||
echo "Server = ${{ gitea.server_url }}"
|
|
||||||
|
|
||||||
- name: build frontend
|
|
||||||
run: |
|
|
||||||
docker run --rm \
|
|
||||||
--network host \
|
|
||||||
-v "$(pwd)/frontend":/app/frontend \
|
|
||||||
-w /app/frontend \
|
|
||||||
node:20-alpine \
|
|
||||||
sh -c "npm install -g pnpm --registry=https://registry.npmmirror.com \
|
|
||||||
&& pnpm install --registry=https://registry.npmmirror.com \
|
|
||||||
&& pnpm run build"
|
|
||||||
mkdir -p internal/static/frontend
|
|
||||||
cp -r frontend/dist internal/static/frontend/dist
|
|
||||||
|
|
||||||
- name: build binaries
|
|
||||||
run: |
|
|
||||||
mkdir -p dist
|
|
||||||
docker run --rm \
|
|
||||||
--network host \
|
|
||||||
-v "$(pwd)":/workspace \
|
|
||||||
-w /workspace \
|
|
||||||
-e GOPROXY=https://goproxy.cn,direct \
|
|
||||||
golang:alpine \
|
|
||||||
sh -c "
|
|
||||||
apk add --no-cache git && \
|
|
||||||
go mod download && \
|
|
||||||
for TARGET in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do
|
|
||||||
OS=\$(echo \$TARGET | cut -d/ -f1)
|
|
||||||
ARCH=\$(echo \$TARGET | cut -d/ -f2)
|
|
||||||
OUTPUT=\"dist/ushare-\${OS}-\${ARCH}\"
|
|
||||||
[ \"\$OS\" = \"windows\" ] && OUTPUT=\"\${OUTPUT}.exe\"
|
|
||||||
echo \">>> Building \${OUTPUT} ...\"
|
|
||||||
CGO_ENABLED=0 GOOS=\$OS GOARCH=\$ARCH \
|
|
||||||
go build -ldflags '-s -w' -o \$OUTPUT .
|
|
||||||
done
|
|
||||||
"
|
|
||||||
ls -lh dist/
|
|
||||||
|
|
||||||
- name: create release
|
|
||||||
run: |
|
|
||||||
apt-get install -y -qq jq
|
|
||||||
|
|
||||||
TAG="${{ gitea.ref_name }}"
|
|
||||||
SERVER="${{ gitea.server_url }}"
|
|
||||||
REPO="${{ gitea.repository }}"
|
|
||||||
TOKEN="${{ secrets.GITEA_TOKEN }}"
|
|
||||||
|
|
||||||
RESPONSE=$(curl -sf -X POST \
|
|
||||||
"${SERVER}/api/v1/repos/${REPO}/releases" \
|
|
||||||
-H "Authorization: token ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}")
|
|
||||||
|
|
||||||
echo "Release created: $(echo $RESPONSE | jq -r '.id')"
|
|
||||||
echo "RELEASE_ID=$(echo $RESPONSE | jq -r '.id')" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: upload assets
|
|
||||||
run: |
|
|
||||||
SERVER="${{ gitea.server_url }}"
|
|
||||||
REPO="${{ gitea.repository }}"
|
|
||||||
TOKEN="${{ secrets.GITEA_TOKEN }}"
|
|
||||||
|
|
||||||
for FILE in dist/ushare-*; do
|
|
||||||
FILENAME=$(basename "$FILE")
|
|
||||||
echo ">>> Uploading ${FILENAME} ..."
|
|
||||||
curl -sf -X POST \
|
|
||||||
"${SERVER}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}" \
|
|
||||||
-H "Authorization: token ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/octet-stream" \
|
|
||||||
--data-binary @"${FILE}"
|
|
||||||
done
|
|
||||||
106
.github/workflows/release.yml
vendored
106
.github/workflows/release.yml
vendored
@@ -1,106 +0,0 @@
|
|||||||
name: Release Binaries
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
name: Build and Release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- goos: linux
|
|
||||||
goarch: amd64
|
|
||||||
suffix: linux-amd64
|
|
||||||
- goos: linux
|
|
||||||
goarch: arm64
|
|
||||||
suffix: linux-arm64
|
|
||||||
- goos: darwin
|
|
||||||
goarch: amd64
|
|
||||||
suffix: darwin-amd64
|
|
||||||
- goos: darwin
|
|
||||||
goarch: arm64
|
|
||||||
suffix: darwin-arm64
|
|
||||||
- goos: windows
|
|
||||||
goarch: amd64
|
|
||||||
suffix: windows-amd64
|
|
||||||
ext: .exe
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.24'
|
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
with:
|
|
||||||
version: 8
|
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
|
||||||
id: pnpm-cache
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Setup pnpm cache
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-pnpm-store-
|
|
||||||
|
|
||||||
- name: Print build info
|
|
||||||
run: |
|
|
||||||
echo "Tag: ${{ github.ref_name }}"
|
|
||||||
echo "GoOS: ${{ matrix.goos }}"
|
|
||||||
echo "GoArch: ${{ matrix.goarch }}"
|
|
||||||
|
|
||||||
- name: Build frontend
|
|
||||||
working-directory: frontend
|
|
||||||
run: |
|
|
||||||
pnpm install --registry=https://registry.npmmirror.com
|
|
||||||
pnpm run build
|
|
||||||
|
|
||||||
- name: Copy frontend dist for embedding
|
|
||||||
run: |
|
|
||||||
mkdir -p internal/static/frontend
|
|
||||||
cp -r frontend/dist internal/static/frontend/
|
|
||||||
|
|
||||||
- name: Build binary
|
|
||||||
env:
|
|
||||||
GOOS: ${{ matrix.goos }}
|
|
||||||
GOARCH: ${{ matrix.goarch }}
|
|
||||||
CGO_ENABLED: 0
|
|
||||||
run: |
|
|
||||||
OUTPUT_NAME="ushare-${{ matrix.suffix }}${{ matrix.ext }}"
|
|
||||||
go build -ldflags '-s -w' -o "$OUTPUT_NAME" .
|
|
||||||
chmod +x "$OUTPUT_NAME"
|
|
||||||
|
|
||||||
- name: Generate checksums
|
|
||||||
run: |
|
|
||||||
OUTPUT_NAME="ushare-${{ matrix.suffix }}${{ matrix.ext }}"
|
|
||||||
sha256sum "$OUTPUT_NAME" > "$OUTPUT_NAME.sha256"
|
|
||||||
|
|
||||||
- name: Upload to release
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
with:
|
|
||||||
files: |
|
|
||||||
ushare-${{ matrix.suffix }}${{ matrix.ext }}
|
|
||||||
ushare-${{ matrix.suffix }}${{ matrix.ext }}.sha256
|
|
||||||
draft: false
|
|
||||||
prerelease: false
|
|
||||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -1,19 +1,5 @@
|
|||||||
# IDE
|
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Build output
|
|
||||||
dist
|
dist
|
||||||
|
xtest
|
||||||
# Data directories
|
|
||||||
data/
|
|
||||||
x-*/
|
|
||||||
|
|
||||||
# Temporary build files
|
|
||||||
internal/static/frontend
|
|
||||||
|
|
||||||
# Compiled binaries
|
|
||||||
ushare
|
|
||||||
250
AGENTS.md
250
AGENTS.md
@@ -1,250 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
## Build Commands
|
|
||||||
|
|
||||||
### Go Backend (Root)
|
|
||||||
```bash
|
|
||||||
# Build the Go backend binary
|
|
||||||
go build -o ushare .
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
go test ./...
|
|
||||||
|
|
||||||
# Run single test
|
|
||||||
go test -run TestFunctionName ./internal/pkg/tool
|
|
||||||
|
|
||||||
# Run tests in specific package
|
|
||||||
go test ./internal/pkg/tool
|
|
||||||
|
|
||||||
# Run tests with verbose output
|
|
||||||
go test -v ./...
|
|
||||||
|
|
||||||
# Run the application
|
|
||||||
./ushare -debug -address 0.0.0.0:9119 -data ./data -auth "admin:password"
|
|
||||||
```
|
|
||||||
|
|
||||||
### TypeScript Frontend (frontend/)
|
|
||||||
```bash
|
|
||||||
# Install dependencies (uses pnpm)
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# Development server
|
|
||||||
pnpm run dev
|
|
||||||
|
|
||||||
# Build for production
|
|
||||||
pnpm run build
|
|
||||||
|
|
||||||
# Lint code
|
|
||||||
pnpm run lint
|
|
||||||
|
|
||||||
# Preview production build
|
|
||||||
pnpm run preview
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker Build
|
|
||||||
```bash
|
|
||||||
# Build the complete Docker image
|
|
||||||
docker build -t ushare:latest .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Code Style Guidelines
|
|
||||||
|
|
||||||
### Go Backend
|
|
||||||
|
|
||||||
#### Imports
|
|
||||||
- Group imports in three sections: standard library, third-party, internal
|
|
||||||
- Keep one import per line for readability
|
|
||||||
- Example:
|
|
||||||
```go
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
|
|
||||||
"github.com/loveuer/ushare/internal/model"
|
|
||||||
"github.com/loveuer/ushare/internal/opt"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Naming Conventions
|
|
||||||
- Exported functions/types: PascalCase (e.g., `UserManager`, `NewPassword`)
|
|
||||||
- Private functions/types: camelCase (e.g., `generateMeta`, `tokenFn`)
|
|
||||||
- Variables: camelCase (e.g., `filename`, `totalChunks`)
|
|
||||||
- Constants: PascalCase (e.g., `Meta`, `HeaderSize`, `CodeLength`)
|
|
||||||
- Interfaces: Usually implied, not explicitly declared unless needed
|
|
||||||
- Receiver names: Short, 1-2 letters (e.g., `m`, `um`, `c`)
|
|
||||||
|
|
||||||
#### Error Handling
|
|
||||||
- Use `github.com/pkg/errors` for error wrapping
|
|
||||||
- Check errors immediately after function calls
|
|
||||||
- Return errors for functions that can fail
|
|
||||||
- Use `errors.New()` for simple error messages
|
|
||||||
- Wrap errors with context when propagating up:
|
|
||||||
```go
|
|
||||||
if err != nil {
|
|
||||||
return errors.New("invalid file code")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Struct Tags
|
|
||||||
- Use `json` tags for JSON serialization
|
|
||||||
- Use `mapstructure` tags for config parsing (viper)
|
|
||||||
- Use `-` for fields to exclude from JSON:
|
|
||||||
```go
|
|
||||||
type User struct {
|
|
||||||
Id int `json:"id"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
Password string `json:"-"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Concurrency
|
|
||||||
- Use `sync.Mutex` for protecting shared state
|
|
||||||
- Always use `defer mutex.Unlock()` after `mutex.Lock()`
|
|
||||||
- Use goroutines with select statements for graceful shutdown
|
|
||||||
|
|
||||||
#### Testing
|
|
||||||
- Use standard `testing` package
|
|
||||||
- Test functions named `Test<FunctionName>`
|
|
||||||
- Table-driven tests are preferred:
|
|
||||||
```go
|
|
||||||
func TestFunction(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
arg ArgType
|
|
||||||
want ReturnType
|
|
||||||
}{
|
|
||||||
{"case 1", arg1, want1},
|
|
||||||
{"case 2", arg2, want2},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if got := Function(tt.arg); got != tt.want {
|
|
||||||
t.Errorf("Function() = %v, want %v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Generics
|
|
||||||
- Use generics for utility functions with type parameters:
|
|
||||||
```go
|
|
||||||
func Min[T ~int | ~uint | ~float64](a, b T) T
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Context
|
|
||||||
- Use `context.Context` for cancellation signals
|
|
||||||
- Always check `ctx.Done()` in long-running goroutines
|
|
||||||
|
|
||||||
### TypeScript Frontend
|
|
||||||
|
|
||||||
#### Imports
|
|
||||||
- Use ES6 imports
|
|
||||||
- Third-party imports first, then relative imports:
|
|
||||||
```typescript
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { createUseStyles } from 'react-jss';
|
|
||||||
import { CloudBackground } from "../component/fluid/cloud.tsx";
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Naming Conventions
|
|
||||||
- Components: PascalCase (e.g., `UButton`, `Login`, `FileSharing`)
|
|
||||||
- Functions/hooks: camelCase (e.g., `useFileUpload`, `onLogin`)
|
|
||||||
- Variables: camelCase (e.g., `progress`, `loading`, `error`)
|
|
||||||
- Constants: UPPER_SNAKE_CASE (rarely used)
|
|
||||||
- Types/Interfaces: PascalCase (e.g., `UploadRes`, `LocalStore`)
|
|
||||||
|
|
||||||
#### Types
|
|
||||||
- Explicitly type function parameters and return values
|
|
||||||
- Use interfaces for object shapes:
|
|
||||||
```typescript
|
|
||||||
interface UploadRes {
|
|
||||||
code: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LocalStore {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
channel?: RTCDataChannel;
|
|
||||||
set: (id: string, name: string) => void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Components
|
|
||||||
- Use functional components with hooks
|
|
||||||
- Props as interface at top of component:
|
|
||||||
```typescript
|
|
||||||
type Props = {
|
|
||||||
onClick?: () => void;
|
|
||||||
children: ReactNode;
|
|
||||||
disabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const UButton: React.FC<Props> = ({ onClick, children, disabled }) => { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Styling
|
|
||||||
- Use `react-jss` with `createUseStyles` for component styles
|
|
||||||
- Define styles object with camelCase properties:
|
|
||||||
```typescript
|
|
||||||
const useStyle = createUseStyles({
|
|
||||||
container: {
|
|
||||||
display: "flex",
|
|
||||||
"&:hover": { backgroundColor: "#45a049" }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### State Management
|
|
||||||
- Use `useState` for local component state
|
|
||||||
- Use `zustand` for global state (defined in `store/` directory)
|
|
||||||
- Always destructure from store hooks:
|
|
||||||
```typescript
|
|
||||||
export const useLocalStore = create<LocalStore>()((_set) => ({
|
|
||||||
id: '',
|
|
||||||
set: (id: string) => _set({ id })
|
|
||||||
}))
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Async Patterns
|
|
||||||
- Use async/await for API calls
|
|
||||||
- Handle errors with try-catch:
|
|
||||||
```typescript
|
|
||||||
try {
|
|
||||||
const result = await uploadFile(file);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Configuration
|
|
||||||
- Vite dev server proxies `/api` and `/ushare` to Go backend at `http://127.0.0.1:9119`
|
|
||||||
- WebSocket proxy configured for `/api/ulocal/ws`
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
ushare/
|
|
||||||
├── internal/
|
|
||||||
│ ├── api/ # HTTP API setup and routes
|
|
||||||
│ ├── controller/ # Business logic (user, meta, room management)
|
|
||||||
│ ├── handler/ # HTTP request handlers
|
|
||||||
│ ├── model/ # Data models (User, Meta, WS)
|
|
||||||
│ ├── opt/ # Configuration and constants
|
|
||||||
│ └── pkg/
|
|
||||||
│ ├── db/ # Database utilities
|
|
||||||
│ └── tool/ # Utility functions (password, random, etc.)
|
|
||||||
├── frontend/
|
|
||||||
│ └── src/
|
|
||||||
│ ├── api/ # API calls (auth, upload)
|
|
||||||
│ ├── component/ # Reusable UI components
|
|
||||||
│ ├── hook/ # Custom hooks (websocket, message)
|
|
||||||
│ ├── page/ # Page components (login, share, local)
|
|
||||||
│ ├── store/ # Zustand state stores
|
|
||||||
│ └── interface/ # TypeScript interfaces
|
|
||||||
└── deployment/ # Docker and nginx configs
|
|
||||||
```
|
|
||||||
@@ -17,8 +17,6 @@ COPY go.sum /app/go.sum
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY main.go /app/main.go
|
COPY main.go /app/main.go
|
||||||
COPY internal /app/internal
|
COPY internal /app/internal
|
||||||
# 将前端构建产物复制到 go:embed 所需的路径
|
|
||||||
COPY --from=frontend-builder /app/frontend/dist /app/internal/static/frontend/dist
|
|
||||||
RUN go build -ldflags '-s -w' -o ushare .
|
RUN go build -ldflags '-s -w' -o ushare .
|
||||||
|
|
||||||
# 第三阶段:生成最终镜像
|
# 第三阶段:生成最终镜像
|
||||||
@@ -27,7 +25,8 @@ COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html
|
|||||||
COPY --from=backend-builder /app/ushare /usr/local/bin/ushare
|
COPY --from=backend-builder /app/ushare /usr/local/bin/ushare
|
||||||
|
|
||||||
# 配置 Nginx
|
# 配置 Nginx
|
||||||
COPY deployment/nginx.conf /etc/nginx/nginx.conf
|
RUN rm /etc/nginx/conf.d/default.conf
|
||||||
|
COPY deployment/nginx.conf /etc/nginx/conf.d
|
||||||
COPY deployment/entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY deployment/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
|
|||||||
168
README.md
168
README.md
@@ -1,168 +0,0 @@
|
|||||||
# UShare
|
|
||||||
|
|
||||||
一个简单易用的文件分享工具,支持大文件分片上传下载,以及局域网内 P2P 文件分享。
|
|
||||||
|
|
||||||
## 功能特性
|
|
||||||
|
|
||||||
- 📁 大文件分片上传下载
|
|
||||||
- 🌐 网页端文件分享
|
|
||||||
- 🏠 局域网 P2P 文件分享(WebRTC)
|
|
||||||
- 📱 响应式设计,支持移动端
|
|
||||||
- 🔐 简单的用户认证
|
|
||||||
- 🚀 单一二进制,开箱即用
|
|
||||||
- 🐳 Docker 支持
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
### 开发模式
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 克隆仓库
|
|
||||||
git clone https://github.com/loveuer/ushare.git
|
|
||||||
cd ushare
|
|
||||||
|
|
||||||
# 启动开发服务器
|
|
||||||
./dev.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
开发服务器会同时启动后端(http://0.0.0.0:9119)和前端(http://0.0.0.0:5173),前端支持热重载。
|
|
||||||
|
|
||||||
### 生产模式
|
|
||||||
|
|
||||||
#### 使用预编译二进制
|
|
||||||
|
|
||||||
从 [Releases](https://github.com/loveuer/ushare/releases) 下载对应平台的二进制文件。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Linux amd64
|
|
||||||
wget https://github.com/loveuer/ushare/releases/download/v0.3.2/ushare-linux-amd64
|
|
||||||
chmod +x ushare-linux-amd64
|
|
||||||
|
|
||||||
# 运行
|
|
||||||
./ushare-linux-amd64 -data ./data
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 从源码构建
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 构建单一二进制(包含嵌入的前端)
|
|
||||||
./make.sh
|
|
||||||
|
|
||||||
# 运行
|
|
||||||
./dist/ushare -data ./data
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Docker 部署
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t ushare:latest .
|
|
||||||
docker run -d -p 80:80 -v $(pwd)/data:/data ushare:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## 配置
|
|
||||||
|
|
||||||
### 命令行参数
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ushare [options]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-debug 启用调试模式
|
|
||||||
-address string 监听地址 (default "0.0.0.0:9119")
|
|
||||||
-data string 数据目录 (default "/data")
|
|
||||||
-clean int 文件清理周期,单位:小时,0 表示不自动清理 (default 24)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 环境变量
|
|
||||||
|
|
||||||
| 环境变量 | 说明 | 默认值 |
|
|
||||||
|---------|------|--------|
|
|
||||||
| `USHARE_USERNAME` | 用户名 | `admin` |
|
|
||||||
| `USHARE_PASSWORD` | 密码 | `ushare@123` |
|
|
||||||
|
|
||||||
#### 示例
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 使用默认凭据(admin / ushare@123)
|
|
||||||
./ushare -data ./data
|
|
||||||
|
|
||||||
# 使用自定义凭据
|
|
||||||
export USHARE_USERNAME=myuser
|
|
||||||
export USHARE_PASSWORD=mypass
|
|
||||||
./ushare -data ./data
|
|
||||||
```
|
|
||||||
|
|
||||||
## 使用说明
|
|
||||||
|
|
||||||
### 文件分享
|
|
||||||
|
|
||||||
1. 访问 http://localhost:9119/share
|
|
||||||
2. 点击"选择文件"选择要上传的文件
|
|
||||||
3. 点击"上传文件",等待上传完成
|
|
||||||
4. 复制生成的下载码分享给他人
|
|
||||||
|
|
||||||
### 文件下载
|
|
||||||
|
|
||||||
1. 访问 http://localhost:9119/share
|
|
||||||
2. 在"获取文件"区域输入下载码
|
|
||||||
3. 点击"获取文件"下载文件
|
|
||||||
|
|
||||||
### 局域网 P2P 分享
|
|
||||||
|
|
||||||
1. 访问 http://localhost:9119
|
|
||||||
2. 自动注册到局域网设备列表
|
|
||||||
3. 选择要分享的设备,建立 P2P 连接
|
|
||||||
4. 开始文件传输
|
|
||||||
|
|
||||||
## 移动端支持
|
|
||||||
|
|
||||||
- 响应式设计,自动适配手机、平板等设备
|
|
||||||
- 移动端(≤768px)采用垂直布局,优化触摸操作
|
|
||||||
- 按钮尺寸优化,适合手指点击
|
|
||||||
|
|
||||||
## 开发
|
|
||||||
|
|
||||||
### 目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
ushare/
|
|
||||||
├── internal/ # 后端代码
|
|
||||||
│ ├── api/ # HTTP API 路由
|
|
||||||
│ ├── controller/ # 业务逻辑
|
|
||||||
│ ├── handler/ # 请求处理器
|
|
||||||
│ ├── model/ # 数据模型
|
|
||||||
│ ├── opt/ # 配置管理
|
|
||||||
│ └── pkg/ # 工具包
|
|
||||||
├── frontend/ # 前端代码
|
|
||||||
│ └── src/
|
|
||||||
│ ├── api/ # API 调用
|
|
||||||
│ ├── component/ # UI 组件
|
|
||||||
│ ├── hook/ # 自定义 Hooks
|
|
||||||
│ ├── page/ # 页面组件
|
|
||||||
│ └── store/ # 状态管理
|
|
||||||
├── .github/workflows/ # GitHub Actions
|
|
||||||
├── dist/ # 构建输出(.gitignore)
|
|
||||||
└── data/ # 数据目录(.gitignore)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 构建
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 开发构建(前后端分离)
|
|
||||||
./dev.sh
|
|
||||||
|
|
||||||
# 生产构建(单一二进制)
|
|
||||||
./make.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### 代码风格
|
|
||||||
|
|
||||||
请参考 [AGENTS.md](./AGENTS.md) 了解代码风格指南。
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT License
|
|
||||||
|
|
||||||
## 贡献
|
|
||||||
|
|
||||||
欢迎提交 Issue 和 Pull Request!
|
|
||||||
@@ -1,60 +1,27 @@
|
|||||||
user root;
|
server {
|
||||||
worker_processes auto;
|
listen 80;
|
||||||
error_log /var/log/nginx/error.log warn;
|
|
||||||
pid /var/run/nginx.pid;
|
|
||||||
|
|
||||||
events {
|
location / {
|
||||||
worker_connections 1024;
|
root /usr/share/nginx/html;
|
||||||
}
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
http {
|
location /api {
|
||||||
include /etc/nginx/mime.types;
|
proxy_pass http://localhost:9119;
|
||||||
default_type application/octet-stream;
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
location /ushare {
|
||||||
'$status $body_bytes_sent "$http_referer" '
|
proxy_pass http://localhost:9119;
|
||||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
access_log /var/log/nginx/access.log main;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
sendfile on;
|
proxy_send_timeout 3600s;
|
||||||
|
send_timeout 3600s;
|
||||||
keepalive_timeout 65;
|
proxy_buffering off;
|
||||||
|
gzip off;
|
||||||
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 /api/ulocal/ws {
|
|
||||||
proxy_pass http://localhost:9119;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
63
dev.sh
63
dev.sh
@@ -1,63 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# 捕获 Ctrl+C 信号
|
|
||||||
trap 'echo ""; echo "Stopping..."; kill $(jobs -p); exit 0' SIGINT SIGTERM
|
|
||||||
|
|
||||||
echo "=========================================="
|
|
||||||
echo " Starting UShare Development Server"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 构建前端(如果需要)
|
|
||||||
if [ ! -d "frontend/dist" ]; then
|
|
||||||
echo "[Frontend] Building..."
|
|
||||||
cd frontend && pnpm run build && cd ..
|
|
||||||
echo "[Frontend] Build complete!"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 创建临时嵌入目录用于编译
|
|
||||||
mkdir -p internal/static/frontend
|
|
||||||
if [ ! -d "internal/static/frontend/dist" ]; then
|
|
||||||
echo "[Setup] Creating frontend embed directory..."
|
|
||||||
cp -r frontend/dist internal/static/frontend/
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 检查后端是否已构建
|
|
||||||
if [ ! -f "./ushare" ]; then
|
|
||||||
echo "[Backend] Building..."
|
|
||||||
go build -o ushare .
|
|
||||||
echo "[Backend] Build complete!"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 创建数据目录
|
|
||||||
mkdir -p ./data
|
|
||||||
|
|
||||||
# 启动后端
|
|
||||||
echo "[Backend] Starting..."
|
|
||||||
./ushare -debug -address 0.0.0.0:9119 -data ./data &
|
|
||||||
BACKEND_PID=$!
|
|
||||||
echo "[Backend] Running on http://0.0.0.0:9119 (PID: $BACKEND_PID)"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 启动前端
|
|
||||||
echo "[Frontend] Starting..."
|
|
||||||
cd frontend && pnpm run dev &
|
|
||||||
FRONTEND_PID=$!
|
|
||||||
cd ..
|
|
||||||
echo "[Frontend] Running on http://localhost:5173 (PID: $FRONTEND_PID)"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
echo "=========================================="
|
|
||||||
echo " All services started!"
|
|
||||||
echo " - Backend: http://0.0.0.0:9119"
|
|
||||||
echo " - Frontend: http://0.0.0.0:5173"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
echo "Note: Frontend hot-reload is enabled. Changes to backend code require rebuilding."
|
|
||||||
echo "Press Ctrl+C to stop all services"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 等待所有后台进程
|
|
||||||
wait
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
export interface Role {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
permissions: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminUser {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
role_id: number;
|
|
||||||
role: Role;
|
|
||||||
active: boolean;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateUserReq {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
role_id: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateUserReq {
|
|
||||||
role_id?: number;
|
|
||||||
active?: boolean;
|
|
||||||
password?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const jsonHeaders: HeadersInit = {'Content-Type': 'application/json'};
|
|
||||||
|
|
||||||
export const adminApi = {
|
|
||||||
listUsers: async (): Promise<AdminUser[]> => {
|
|
||||||
const res = await fetch('/api/admin/users', {headers: jsonHeaders});
|
|
||||||
if (!res.ok) {
|
|
||||||
const json = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(json.msg || '查询失败');
|
|
||||||
}
|
|
||||||
return (await res.json()).data;
|
|
||||||
},
|
|
||||||
|
|
||||||
createUser: async (req: CreateUserReq): Promise<AdminUser> => {
|
|
||||||
const res = await fetch('/api/admin/users', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: jsonHeaders,
|
|
||||||
body: JSON.stringify(req),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const json = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(json.msg || '创建失败');
|
|
||||||
}
|
|
||||||
return (await res.json()).data;
|
|
||||||
},
|
|
||||||
|
|
||||||
updateUser: async (id: number, req: UpdateUserReq): Promise<AdminUser> => {
|
|
||||||
const res = await fetch(`/api/admin/users/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: jsonHeaders,
|
|
||||||
body: JSON.stringify(req),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const json = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(json.msg || '更新失败');
|
|
||||||
}
|
|
||||||
return (await res.json()).data;
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteUser: async (id: number): Promise<void> => {
|
|
||||||
const res = await fetch(`/api/admin/users/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: jsonHeaders,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const json = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(json.msg || '删除失败');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
listRoles: async (): Promise<Role[]> => {
|
|
||||||
const res = await fetch('/api/admin/roles', {headers: jsonHeaders});
|
|
||||||
if (!res.ok) {
|
|
||||||
const json = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(json.msg || '查询失败');
|
|
||||||
}
|
|
||||||
return (await res.json()).data;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import {message} from "../hook/message/u-message.tsx";
|
import {message} from "../component/message/u-message.tsx";
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
export interface ApiToken {
|
|
||||||
id: number;
|
|
||||||
user_id: number;
|
|
||||||
name: string;
|
|
||||||
created_at: string;
|
|
||||||
last_used_at: string | null;
|
|
||||||
expires_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateTokenRes {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
token: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const jsonHeaders: HeadersInit = {'Content-Type': 'application/json'};
|
|
||||||
|
|
||||||
export const tokenApi = {
|
|
||||||
list: async (): Promise<ApiToken[]> => {
|
|
||||||
const res = await fetch('/api/token', {headers: jsonHeaders});
|
|
||||||
if (!res.ok) {
|
|
||||||
const json = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(json.msg || '查询失败');
|
|
||||||
}
|
|
||||||
return (await res.json()).data;
|
|
||||||
},
|
|
||||||
|
|
||||||
create: async (name: string): Promise<CreateTokenRes> => {
|
|
||||||
const res = await fetch('/api/token', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: jsonHeaders,
|
|
||||||
body: JSON.stringify({name}),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const json = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(json.msg || '创建失败');
|
|
||||||
}
|
|
||||||
return (await res.json()).data;
|
|
||||||
},
|
|
||||||
|
|
||||||
delete: async (id: number): Promise<void> => {
|
|
||||||
const res = await fetch('/api/token', {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: jsonHeaders,
|
|
||||||
body: JSON.stringify({id}),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const json = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(json.msg || '删除失败');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -16,6 +16,7 @@ export const useFileUpload = () => {
|
|||||||
setProgress(0);
|
setProgress(0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log(`[D] api.Upload: upload file = ${file.name}, size = ${file.size}`, file);
|
||||||
const url = `/api/ushare/${file.name}`;
|
const url = `/api/ushare/${file.name}`;
|
||||||
|
|
||||||
// 1. 初始化上传
|
// 1. 初始化上传
|
||||||
@@ -25,7 +26,7 @@ export const useFileUpload = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res1.ok) {
|
if (!res1.ok) {
|
||||||
console.log(`[W] upload: put file not ok, status = ${res1.status}, res = ${await res1.text()}`)
|
console.log(`[D] upload: put file not ok, status = ${res1.status}, res = ${await res1.text()}`)
|
||||||
if (res1.status === 401) {
|
if (res1.status === 401) {
|
||||||
window.location.href = "/login?next=/share"
|
window.location.href = "/login?next=/share"
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -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,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;
|
|
||||||
}
|
|
||||||
@@ -1,27 +1,19 @@
|
|||||||
// import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import {createBrowserRouter, RouterProvider} from "react-router-dom";
|
import {createBrowserRouter, RouterProvider} from "react-router-dom";
|
||||||
import {Login} from "./page/login.tsx";
|
import {Login} from "./page/login.tsx";
|
||||||
import {FileSharing} from "./page/share/share.tsx";
|
import {FileSharing} from "./page/share.tsx";
|
||||||
import {LocalSharing} from "./page/local/local.tsx";
|
|
||||||
import {TestPage} from "./page/test/test.tsx";
|
|
||||||
import {AdminPage} from "./page/admin/admin.tsx";
|
|
||||||
import {SelfPage} from "./page/self/self.tsx";
|
|
||||||
|
|
||||||
const container = document.getElementById('root')
|
const container = document.getElementById('root')
|
||||||
const root = createRoot(container!)
|
const root = createRoot(container!)
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{path: "/login", element: <Login />},
|
{path: "/login", element: <Login />},
|
||||||
{path: "/share", element: <FileSharing />},
|
{path: "*", element: <FileSharing />},
|
||||||
{path: "/admin", element: <AdminPage />},
|
|
||||||
{path: "/self", element: <SelfPage />},
|
|
||||||
{path: "/test", element: <TestPage />},
|
|
||||||
{path: "*", element: <LocalSharing />},
|
|
||||||
])
|
])
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
// <StrictMode>
|
<StrictMode>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
// </StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,533 +0,0 @@
|
|||||||
import React, {useEffect, useState} from 'react';
|
|
||||||
import {createUseStyles} from 'react-jss';
|
|
||||||
import {adminApi, AdminUser, Role, UpdateUserReq} from '../../api/admin.ts';
|
|
||||||
import {message} from '../../hook/message/u-message.tsx';
|
|
||||||
import {UButton} from '../../component/button/u-button.tsx';
|
|
||||||
|
|
||||||
const PERM_LABELS: Record<string, string> = {
|
|
||||||
user_manage: '用户管理',
|
|
||||||
upload: '上传文件',
|
|
||||||
token_manage: 'Token管理',
|
|
||||||
};
|
|
||||||
|
|
||||||
const useStyle = createUseStyles({
|
|
||||||
container: {
|
|
||||||
minHeight: '100vh',
|
|
||||||
backgroundColor: '#e3f2fd',
|
|
||||||
padding: '24px',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
fontFamily: "'Segoe UI', Arial, sans-serif",
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '16px',
|
|
||||||
marginBottom: '24px',
|
|
||||||
},
|
|
||||||
backBtn: {
|
|
||||||
background: 'transparent',
|
|
||||||
border: '2px solid #2c9678',
|
|
||||||
color: '#2c9678',
|
|
||||||
borderRadius: '6px',
|
|
||||||
padding: '6px 14px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '14px',
|
|
||||||
transition: 'background-color 0.2s',
|
|
||||||
'&:hover': {backgroundColor: 'rgba(44,150,120,0.1)'},
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
color: '#2c9678',
|
|
||||||
margin: 0,
|
|
||||||
fontSize: '22px',
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
backgroundColor: '#C8E6C9',
|
|
||||||
boxShadow: 'inset 0 0 15px rgba(56, 142, 60, 0.15)',
|
|
||||||
borderRadius: '15px',
|
|
||||||
padding: '24px',
|
|
||||||
marginBottom: '24px',
|
|
||||||
},
|
|
||||||
cardHeader: {
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: '16px',
|
|
||||||
},
|
|
||||||
cardTitle: {
|
|
||||||
color: '#2c9678',
|
|
||||||
margin: 0,
|
|
||||||
fontSize: '16px',
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
table: {
|
|
||||||
width: '100%',
|
|
||||||
borderCollapse: 'collapse',
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.7)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
th: {
|
|
||||||
backgroundColor: 'rgba(44,150,120,0.15)',
|
|
||||||
color: '#2c9678',
|
|
||||||
padding: '10px 14px',
|
|
||||||
textAlign: 'left',
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
td: {
|
|
||||||
padding: '10px 14px',
|
|
||||||
borderBottom: '1px solid rgba(44,150,120,0.1)',
|
|
||||||
fontSize: '14px',
|
|
||||||
color: '#333',
|
|
||||||
},
|
|
||||||
badgeActive: {
|
|
||||||
display: 'inline-block',
|
|
||||||
padding: '2px 10px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: 500,
|
|
||||||
backgroundColor: '#c8e6c9',
|
|
||||||
color: '#2e7d32',
|
|
||||||
},
|
|
||||||
badgeInactive: {
|
|
||||||
display: 'inline-block',
|
|
||||||
padding: '2px 10px',
|
|
||||||
borderRadius: '12px',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: 500,
|
|
||||||
backgroundColor: '#ffcdd2',
|
|
||||||
color: '#c62828',
|
|
||||||
},
|
|
||||||
actionBtn: {
|
|
||||||
background: 'transparent',
|
|
||||||
border: '1px solid #2c9678',
|
|
||||||
color: '#2c9678',
|
|
||||||
borderRadius: '4px',
|
|
||||||
padding: '4px 10px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '12px',
|
|
||||||
marginRight: '6px',
|
|
||||||
transition: 'background-color 0.2s',
|
|
||||||
'&:hover': {backgroundColor: 'rgba(44,150,120,0.1)'},
|
|
||||||
},
|
|
||||||
deleteBtn: {
|
|
||||||
background: 'transparent',
|
|
||||||
border: '1px solid #c62828',
|
|
||||||
color: '#c62828',
|
|
||||||
borderRadius: '4px',
|
|
||||||
padding: '4px 10px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '12px',
|
|
||||||
transition: 'background-color 0.2s',
|
|
||||||
'&:hover': {backgroundColor: 'rgba(198,40,40,0.08)'},
|
|
||||||
},
|
|
||||||
permTag: {
|
|
||||||
display: 'inline-block',
|
|
||||||
backgroundColor: 'rgba(44,150,120,0.15)',
|
|
||||||
color: '#2c9678',
|
|
||||||
borderRadius: '10px',
|
|
||||||
padding: '2px 8px',
|
|
||||||
fontSize: '12px',
|
|
||||||
marginRight: '4px',
|
|
||||||
marginBottom: '2px',
|
|
||||||
},
|
|
||||||
overlay: {
|
|
||||||
position: 'fixed',
|
|
||||||
top: 0, left: 0, right: 0, bottom: 0,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
zIndex: 1000,
|
|
||||||
},
|
|
||||||
dialog: {
|
|
||||||
backgroundColor: '#C8E6C9',
|
|
||||||
boxShadow: '0 8px 32px rgba(44,150,120,0.2)',
|
|
||||||
borderRadius: '15px',
|
|
||||||
padding: '28px',
|
|
||||||
width: '400px',
|
|
||||||
maxWidth: '90vw',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
},
|
|
||||||
dialogTitle: {
|
|
||||||
color: '#2c9678',
|
|
||||||
margin: '0 0 20px',
|
|
||||||
fontSize: '16px',
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
formRow: {
|
|
||||||
marginBottom: '14px',
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
display: 'block',
|
|
||||||
color: '#2c9678',
|
|
||||||
fontSize: '13px',
|
|
||||||
marginBottom: '5px',
|
|
||||||
fontWeight: 500,
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
width: '100%',
|
|
||||||
padding: '9px 11px',
|
|
||||||
border: '2px solid #ddd',
|
|
||||||
borderRadius: '5px',
|
|
||||||
fontSize: '14px',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
background: 'rgba(255,255,255,0.8)',
|
|
||||||
transition: 'border-color 0.2s',
|
|
||||||
'&:focus': {outline: 'none', borderColor: '#2c9678'},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
width: '100%',
|
|
||||||
padding: '9px 11px',
|
|
||||||
border: '2px solid #ddd',
|
|
||||||
borderRadius: '5px',
|
|
||||||
fontSize: '14px',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
background: 'rgba(255,255,255,0.8)',
|
|
||||||
transition: 'border-color 0.2s',
|
|
||||||
'&:focus': {outline: 'none', borderColor: '#2c9678'},
|
|
||||||
},
|
|
||||||
checkboxRow: {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
},
|
|
||||||
dialogActions: {
|
|
||||||
display: 'flex',
|
|
||||||
gap: '10px',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
marginTop: '20px',
|
|
||||||
},
|
|
||||||
cancelBtn: {
|
|
||||||
background: 'transparent',
|
|
||||||
border: '2px solid #aaa',
|
|
||||||
color: '#666',
|
|
||||||
borderRadius: '5px',
|
|
||||||
padding: '8px 18px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '14px',
|
|
||||||
transition: 'border-color 0.2s',
|
|
||||||
'&:hover': {borderColor: '#888'},
|
|
||||||
},
|
|
||||||
dangerBtn: {
|
|
||||||
backgroundColor: '#c62828',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '5px',
|
|
||||||
padding: '8px 18px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '14px',
|
|
||||||
transition: 'background-color 0.2s',
|
|
||||||
'&:hover': {backgroundColor: '#b71c1c'},
|
|
||||||
},
|
|
||||||
emptyTip: {
|
|
||||||
textAlign: 'center',
|
|
||||||
color: '#999',
|
|
||||||
padding: '24px',
|
|
||||||
fontSize: '14px',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
type DialogMode = 'create' | 'edit';
|
|
||||||
|
|
||||||
interface DialogState {
|
|
||||||
open: boolean;
|
|
||||||
mode: DialogMode;
|
|
||||||
user?: AdminUser;
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
roleId: number;
|
|
||||||
active: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyDialog = (defaultRoleId = 0): DialogState => ({
|
|
||||||
open: false,
|
|
||||||
mode: 'create',
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
roleId: defaultRoleId,
|
|
||||||
active: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const AdminPage: React.FC = () => {
|
|
||||||
const classes = useStyle();
|
|
||||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
|
||||||
const [roles, setRoles] = useState<Role[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [dialog, setDialog] = useState<DialogState>(emptyDialog());
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null);
|
|
||||||
|
|
||||||
const loadData = async () => {
|
|
||||||
try {
|
|
||||||
const [u, r] = await Promise.all([adminApi.listUsers(), adminApi.listRoles()]);
|
|
||||||
setUsers(u ?? []);
|
|
||||||
setRoles(r ?? []);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
message.error(e instanceof Error ? e.message : '加载失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/uauth/me').then(res => {
|
|
||||||
if (res.status === 401) {
|
|
||||||
window.location.href = '/login?next=/admin';
|
|
||||||
} else if (res.status === 403) {
|
|
||||||
message.error('权限不足');
|
|
||||||
} else {
|
|
||||||
loadData();
|
|
||||||
}
|
|
||||||
}).catch(() => {
|
|
||||||
window.location.href = '/login?next=/admin';
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const openCreate = () => {
|
|
||||||
const defaultRoleId = roles.find(r => r.name === 'user')?.id ?? roles[0]?.id ?? 0;
|
|
||||||
setDialog({...emptyDialog(defaultRoleId), open: true, mode: 'create'});
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEdit = (user: AdminUser) => {
|
|
||||||
setDialog({
|
|
||||||
open: true,
|
|
||||||
mode: 'edit',
|
|
||||||
user,
|
|
||||||
username: user.username,
|
|
||||||
password: '',
|
|
||||||
roleId: user.role_id,
|
|
||||||
active: user.active,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeDialog = () => setDialog(emptyDialog());
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (dialog.mode === 'create') {
|
|
||||||
if (!dialog.username.trim()) return message.warning('请输入用户名');
|
|
||||||
if (!dialog.password) return message.warning('请输入密码');
|
|
||||||
if (!dialog.roleId) return message.warning('请选择角色');
|
|
||||||
}
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
if (dialog.mode === 'create') {
|
|
||||||
await adminApi.createUser({
|
|
||||||
username: dialog.username.trim(),
|
|
||||||
password: dialog.password,
|
|
||||||
role_id: dialog.roleId,
|
|
||||||
});
|
|
||||||
message.success('创建成功');
|
|
||||||
} else if (dialog.user) {
|
|
||||||
const req: UpdateUserReq = {};
|
|
||||||
if (dialog.roleId !== dialog.user.role_id) req.role_id = dialog.roleId;
|
|
||||||
if (dialog.active !== dialog.user.active) req.active = dialog.active;
|
|
||||||
if (dialog.password) req.password = dialog.password;
|
|
||||||
await adminApi.updateUser(dialog.user.id, req);
|
|
||||||
message.success('更新成功');
|
|
||||||
}
|
|
||||||
closeDialog();
|
|
||||||
await loadData();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
message.error(e instanceof Error ? e.message : '操作失败');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!deleteTarget) return;
|
|
||||||
try {
|
|
||||||
await adminApi.deleteUser(deleteTarget.id);
|
|
||||||
message.success('删除成功');
|
|
||||||
setDeleteTarget(null);
|
|
||||||
await loadData();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
message.error(e instanceof Error ? e.message : '删除失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classes.container}>
|
|
||||||
<div className={classes.header}>
|
|
||||||
<button className={classes.backBtn} onClick={() => window.location.href = '/share'}>
|
|
||||||
← 返回
|
|
||||||
</button>
|
|
||||||
<h2 className={classes.title}>管理控制台</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Users */}
|
|
||||||
<div className={classes.card}>
|
|
||||||
<div className={classes.cardHeader}>
|
|
||||||
<h3 className={classes.cardTitle}>用户管理</h3>
|
|
||||||
<UButton onClick={openCreate}>添加用户</UButton>
|
|
||||||
</div>
|
|
||||||
{loading ? (
|
|
||||||
<div className={classes.emptyTip}>加载中...</div>
|
|
||||||
) : (
|
|
||||||
<table className={classes.table}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className={classes.th}>ID</th>
|
|
||||||
<th className={classes.th}>用户名</th>
|
|
||||||
<th className={classes.th}>角色</th>
|
|
||||||
<th className={classes.th}>状态</th>
|
|
||||||
<th className={classes.th}>创建时间</th>
|
|
||||||
<th className={classes.th}>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{users.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td className={classes.td} colSpan={6}>
|
|
||||||
<div className={classes.emptyTip}>暂无用户</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : users.map(u => (
|
|
||||||
<tr key={u.id}>
|
|
||||||
<td className={classes.td}>{u.id}</td>
|
|
||||||
<td className={classes.td}>{u.username}</td>
|
|
||||||
<td className={classes.td}>{u.role?.label ?? '-'}</td>
|
|
||||||
<td className={classes.td}>
|
|
||||||
<span className={u.active ? classes.badgeActive : classes.badgeInactive}>
|
|
||||||
{u.active ? '启用' : '禁用'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className={classes.td}>
|
|
||||||
{new Date(u.created_at).toLocaleDateString('zh-CN')}
|
|
||||||
</td>
|
|
||||||
<td className={classes.td}>
|
|
||||||
<button className={classes.actionBtn} onClick={() => openEdit(u)}>编辑</button>
|
|
||||||
<button className={classes.deleteBtn} onClick={() => setDeleteTarget(u)}>删除</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Roles */}
|
|
||||||
<div className={classes.card}>
|
|
||||||
<div className={classes.cardHeader}>
|
|
||||||
<h3 className={classes.cardTitle}>角色权限</h3>
|
|
||||||
</div>
|
|
||||||
<table className={classes.table}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className={classes.th}>角色</th>
|
|
||||||
<th className={classes.th}>权限</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{roles.map(r => (
|
|
||||||
<tr key={r.id}>
|
|
||||||
<td className={classes.td}>{r.label}</td>
|
|
||||||
<td className={classes.td}>
|
|
||||||
{r.permissions.split(',')
|
|
||||||
.map(p => p.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map(p => (
|
|
||||||
<span key={p} className={classes.permTag}>
|
|
||||||
{PERM_LABELS[p] ?? p}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Create / Edit Dialog */}
|
|
||||||
{dialog.open && (
|
|
||||||
<div className={classes.overlay} onClick={e => e.target === e.currentTarget && closeDialog()}>
|
|
||||||
<div className={classes.dialog}>
|
|
||||||
<h3 className={classes.dialogTitle}>
|
|
||||||
{dialog.mode === 'create' ? '添加用户' : `编辑用户: ${dialog.user?.username}`}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{dialog.mode === 'create' && (
|
|
||||||
<div className={classes.formRow}>
|
|
||||||
<label className={classes.label}>用户名</label>
|
|
||||||
<input
|
|
||||||
className={classes.input}
|
|
||||||
placeholder="请输入用户名"
|
|
||||||
value={dialog.username}
|
|
||||||
onChange={e => setDialog(d => ({...d, username: e.target.value}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={classes.formRow}>
|
|
||||||
<label className={classes.label}>
|
|
||||||
{dialog.mode === 'create' ? '密码' : '新密码(留空则不修改)'}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className={classes.input}
|
|
||||||
type="password"
|
|
||||||
placeholder={dialog.mode === 'create' ? '请输入密码' : '留空不修改'}
|
|
||||||
value={dialog.password}
|
|
||||||
onChange={e => setDialog(d => ({...d, password: e.target.value}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={classes.formRow}>
|
|
||||||
<label className={classes.label}>角色</label>
|
|
||||||
<select
|
|
||||||
className={classes.select}
|
|
||||||
value={dialog.roleId}
|
|
||||||
onChange={e => setDialog(d => ({...d, roleId: Number(e.target.value)}))}
|
|
||||||
>
|
|
||||||
{roles.map(r => (
|
|
||||||
<option key={r.id} value={r.id}>{r.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dialog.mode === 'edit' && (
|
|
||||||
<div className={classes.formRow}>
|
|
||||||
<label className={classes.label}>状态</label>
|
|
||||||
<div className={classes.checkboxRow}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="active-check"
|
|
||||||
checked={dialog.active}
|
|
||||||
onChange={e => setDialog(d => ({...d, active: e.target.checked}))}
|
|
||||||
/>
|
|
||||||
<label htmlFor="active-check" style={{color: '#555', fontSize: '14px', cursor: 'pointer'}}>
|
|
||||||
{dialog.active ? '启用' : '禁用'}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={classes.dialogActions}>
|
|
||||||
<button className={classes.cancelBtn} onClick={closeDialog}>取消</button>
|
|
||||||
<UButton onClick={handleSave} loading={saving}>
|
|
||||||
{dialog.mode === 'create' ? '创建' : '保存'}
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete Confirm Dialog */}
|
|
||||||
{deleteTarget && (
|
|
||||||
<div className={classes.overlay} onClick={e => e.target === e.currentTarget && setDeleteTarget(null)}>
|
|
||||||
<div className={classes.dialog}>
|
|
||||||
<h3 className={classes.dialogTitle}>确认删除</h3>
|
|
||||||
<p style={{color: '#555', marginTop: 0, marginBottom: '20px', fontSize: '14px'}}>
|
|
||||||
确定要删除用户 <strong>{deleteTarget.username}</strong> 吗?此操作不可撤销。
|
|
||||||
</p>
|
|
||||||
<div className={classes.dialogActions}>
|
|
||||||
<button className={classes.cancelBtn} onClick={() => setDeleteTarget(null)}>取消</button>
|
|
||||||
<button className={classes.dangerBtn} onClick={handleDelete}>确认删除</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import {createUseStyles} from "react-jss";
|
import {createUseStyles} from "react-jss";
|
||||||
import {UButton} from "../../../component/button/u-button.tsx";
|
import {UButton} from "../../component/button/u-button.tsx";
|
||||||
import React, {useEffect, useState} from "react";
|
import React, {useState} from "react";
|
||||||
import {useStore} from "../../../store/share.ts";
|
import {useStore} from "../../store/share.ts";
|
||||||
import {message} from "../../../hook/message/u-message.tsx";
|
import {message} from "../../component/message/u-message.tsx";
|
||||||
import {useFileUpload} from "../../../api/upload.ts";
|
import {useFileUpload} from "../../api/upload.ts";
|
||||||
|
|
||||||
const useUploadStyle = createUseStyles({
|
const useUploadStyle = createUseStyles({
|
||||||
container: {
|
container: {
|
||||||
@@ -11,12 +11,6 @@ const useUploadStyle = createUseStyles({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
minHeight: "50vh",
|
|
||||||
|
|
||||||
"@media (max-width: 768px)": {
|
|
||||||
minHeight: "auto",
|
|
||||||
padding: "20px 10px",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
backgroundColor: "#C8E6C9",
|
backgroundColor: "#C8E6C9",
|
||||||
@@ -25,20 +19,10 @@ const useUploadStyle = createUseStyles({
|
|||||||
borderRadius: "15px",
|
borderRadius: "15px",
|
||||||
width: "70%",
|
width: "70%",
|
||||||
margin: "20px 60px 20px 0",
|
margin: "20px 60px 20px 0",
|
||||||
|
/*todo margin 不用 px*/
|
||||||
"@media (max-width: 768px)": {
|
|
||||||
width: "90%",
|
|
||||||
margin: "20px 0",
|
|
||||||
padding: "20px",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
color: "#2c9678",
|
color: "#2c9678"
|
||||||
|
|
||||||
"@media (max-width: 768px)": {
|
|
||||||
fontSize: "1.5rem",
|
|
||||||
marginBottom: "15px",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
file: {
|
file: {
|
||||||
display: 'none',
|
display: 'none',
|
||||||
@@ -49,33 +33,13 @@ const useUploadStyle = createUseStyles({
|
|||||||
},
|
},
|
||||||
name: {
|
name: {
|
||||||
color: "#2c9678",
|
color: "#2c9678",
|
||||||
marginLeft: '10px',
|
marginLeft: '10px'
|
||||||
|
|
||||||
"@media (max-width: 768px)": {
|
|
||||||
fontSize: "14px",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
clean: {
|
clean: {
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
'&:hover': {}
|
'&:hover': {}
|
||||||
},
|
}
|
||||||
adminLink: {
|
|
||||||
display: 'block',
|
|
||||||
textAlign: 'center',
|
|
||||||
marginTop: '16px',
|
|
||||||
color: '#2c9678',
|
|
||||||
fontSize: '12px',
|
|
||||||
textDecoration: 'none',
|
|
||||||
opacity: 0.8,
|
|
||||||
'&:hover': {opacity: 1, textDecoration: 'underline'},
|
|
||||||
},
|
|
||||||
navLinks: {
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: '16px',
|
|
||||||
marginTop: '16px',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const useShowStyle = createUseStyles({
|
const useShowStyle = createUseStyles({
|
||||||
@@ -84,12 +48,14 @@ const useShowStyle = createUseStyles({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
minHeight: "50vh",
|
position: "relative", // 为关闭按钮提供定位基准
|
||||||
|
},
|
||||||
"@media (max-width: 768px)": {
|
title: {
|
||||||
minHeight: "auto",
|
color: "#2c9678",
|
||||||
padding: "20px 10px",
|
marginTop: 0,
|
||||||
},
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
backgroundColor: "#C8E6C9",
|
backgroundColor: "#C8E6C9",
|
||||||
@@ -99,25 +65,6 @@ const useShowStyle = createUseStyles({
|
|||||||
width: "70%",
|
width: "70%",
|
||||||
margin: "20px 60px 20px 0",
|
margin: "20px 60px 20px 0",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
|
|
||||||
"@media (max-width: 768px)": {
|
|
||||||
width: "90%",
|
|
||||||
margin: "20px 0",
|
|
||||||
padding: "20px",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
color: "#2c9678",
|
|
||||||
marginTop: 0,
|
|
||||||
marginBottom: "25px",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
|
|
||||||
"@media (max-width: 768px)": {
|
|
||||||
fontSize: "1.5rem",
|
|
||||||
marginBottom: "15px",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
closeButton: {
|
closeButton: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@@ -131,7 +78,9 @@ const useShowStyle = createUseStyles({
|
|||||||
height: "24px",
|
height: "24px",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
|
// background: "#cc0000",
|
||||||
boxShadow: "20px 20px 60px #fff, -20px -20px 60px #fff",
|
boxShadow: "20px 20px 60px #fff, -20px -20px 60px #fff",
|
||||||
|
// boxShadow: "20px 20px 60px #eee",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
codeWrapper: {
|
codeWrapper: {
|
||||||
@@ -140,10 +89,6 @@ const useShowStyle = createUseStyles({
|
|||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
margin: "15px 0",
|
margin: "15px 0",
|
||||||
overflowX: "auto",
|
overflowX: "auto",
|
||||||
|
|
||||||
"@media (max-width: 768px)": {
|
|
||||||
padding: "0 10px",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
pre: {
|
pre: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -153,12 +98,6 @@ const useShowStyle = createUseStyles({
|
|||||||
height: '24px',
|
height: '24px',
|
||||||
"& > code": {
|
"& > code": {
|
||||||
marginLeft: "0",
|
marginLeft: "0",
|
||||||
fontSize: "14px",
|
|
||||||
wordBreak: "break-all",
|
|
||||||
|
|
||||||
"@media (max-width: 768px)": {
|
|
||||||
fontSize: "12px",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
copyButton: {
|
copyButton: {
|
||||||
@@ -173,11 +112,6 @@ const useShowStyle = createUseStyles({
|
|||||||
"&:hover": {
|
"&:hover": {
|
||||||
background: "#1f6d5a",
|
background: "#1f6d5a",
|
||||||
},
|
},
|
||||||
|
|
||||||
"@media (max-width: 768px)": {
|
|
||||||
padding: "6px 12px",
|
|
||||||
fontSize: "12px",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,19 +129,6 @@ const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_cod
|
|||||||
const style = useUploadStyle()
|
const style = useUploadStyle()
|
||||||
const {file, setFile} = useStore()
|
const {file, setFile} = useStore()
|
||||||
const {uploadFile, progress, loading} = useFileUpload();
|
const {uploadFile, progress, loading} = useFileUpload();
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
|
||||||
const [hasTokenPerm, setHasTokenPerm] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/uauth/me').then(async res => {
|
|
||||||
if (res.ok) {
|
|
||||||
const json = await res.json();
|
|
||||||
const perms: string[] = json.data?.permissions ?? [];
|
|
||||||
setIsAdmin(perms.includes('user_manage'));
|
|
||||||
setHasTokenPerm(perms.includes('token_manage'));
|
|
||||||
}
|
|
||||||
}).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function onFileSelect() {
|
function onFileSelect() {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -215,6 +136,7 @@ const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_cod
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
console.log('[D] onFileChange: e =', e)
|
||||||
setFile(e.currentTarget.files ? e.currentTarget.files[0] : null)
|
setFile(e.currentTarget.files ? e.currentTarget.files[0] : null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,12 +176,6 @@ const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_cod
|
|||||||
<div className={style.name}>{file.name}</div>
|
<div className={style.name}>{file.name}</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
{isAdmin && (
|
|
||||||
<a href="/admin" className={style.adminLink}>管理控制台</a>
|
|
||||||
)}
|
|
||||||
{hasTokenPerm && (
|
|
||||||
<a href="/self" className={style.adminLink}>个人中心 / API Token</a>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -5,10 +5,6 @@ const useStyle = createUseStyles({
|
|||||||
backgroundColor: 'lightgray',
|
backgroundColor: 'lightgray',
|
||||||
position: "relative",
|
position: "relative",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
|
||||||
"@media (max-width: 768px)": {
|
|
||||||
display: "none",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
left: {
|
left: {
|
||||||
backgroundColor: "#e3f2fd",
|
backgroundColor: "#e3f2fd",
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createUseStyles } from "react-jss";
|
import {createUseStyles} from "react-jss";
|
||||||
import { UButton } from "../../../component/button/u-button.tsx";
|
import {UButton} from "../../component/button/u-button.tsx";
|
||||||
import { useStore } from "../../../store/share.ts";
|
import {useStore} from "../../store/share.ts";
|
||||||
|
|
||||||
const useStyle = createUseStyles({
|
const useStyle = createUseStyles({
|
||||||
container: {
|
container: {
|
||||||
@@ -8,12 +8,6 @@ const useStyle = createUseStyles({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
minHeight: "50vh",
|
|
||||||
|
|
||||||
"@media (max-width: 768px)": {
|
|
||||||
minHeight: "auto",
|
|
||||||
padding: "20px 10px",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
backgroundColor: "#BBDEFB",
|
backgroundColor: "#BBDEFB",
|
||||||
@@ -22,58 +16,25 @@ const useStyle = createUseStyles({
|
|||||||
borderRadius: "15px",
|
borderRadius: "15px",
|
||||||
width: "70%",
|
width: "70%",
|
||||||
margin: "20px 0 20px 60px",
|
margin: "20px 0 20px 60px",
|
||||||
|
/*todo margin 不用 px*/
|
||||||
"@media (max-width: 768px)": {
|
|
||||||
width: "90%",
|
|
||||||
margin: "20px 0",
|
|
||||||
padding: "20px",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
color: '#1661ab',
|
color: '#1661ab', // 靛青
|
||||||
|
|
||||||
"@media (max-width: 768px)": {
|
|
||||||
fontSize: "1.5rem",
|
|
||||||
marginBottom: "15px",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
code: {
|
code: {
|
||||||
padding: '11px',
|
padding: '11px',
|
||||||
margin: '0',
|
margin: '20px 0',
|
||||||
width: '200px',
|
width: '200px',
|
||||||
border: '2px solid #ddd',
|
border: '2px solid #ddd',
|
||||||
borderRadius: '5px',
|
borderRadius: '5px',
|
||||||
'&:active': {
|
'&:active': {
|
||||||
border: '2px solid #1661ab',
|
border: '2px solid #1661ab',
|
||||||
},
|
}
|
||||||
|
}
|
||||||
"@media (max-width: 768px)": {
|
|
||||||
width: '100%',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
padding: '10px',
|
|
||||||
fontSize: '14px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
inputContainer: {
|
|
||||||
display: 'flex',
|
|
||||||
gap: '10px',
|
|
||||||
|
|
||||||
"@media (max-width: 768px)": {
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'stretch',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
buttonContainer: {
|
|
||||||
display: 'flex',
|
|
||||||
|
|
||||||
"@media (max-width: 768px)": {
|
|
||||||
justifyContent: 'left',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
export const PanelRight = () => {
|
export const PanelRight = () => {
|
||||||
const style = useStyle()
|
const style = useStyle()
|
||||||
const { code, setCode } = useStore()
|
const {code, setCode} = useStore()
|
||||||
|
|
||||||
function onCodeChange(e: React.ChangeEvent<HTMLInputElement>) {
|
function onCodeChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
setCode(e.currentTarget.value)
|
setCode(e.currentTarget.value)
|
||||||
@@ -81,6 +42,7 @@ export const PanelRight = () => {
|
|||||||
|
|
||||||
async function onFetchFile() {
|
async function onFetchFile() {
|
||||||
const url = `/ushare/${code}`
|
const url = `/ushare/${code}`
|
||||||
|
console.log('[D] onFetchFile: url =', url)
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
@@ -91,7 +53,7 @@ export const PanelRight = () => {
|
|||||||
return <div className={style.container}>
|
return <div className={style.container}>
|
||||||
<div className={style.form}>
|
<div className={style.form}>
|
||||||
<h2 className={style.title}>获取文件</h2>
|
<h2 className={style.title}>获取文件</h2>
|
||||||
<div className={style.inputContainer}>
|
<div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className={style.code}
|
className={style.code}
|
||||||
@@ -99,9 +61,7 @@ export const PanelRight = () => {
|
|||||||
value={code}
|
value={code}
|
||||||
onChange={onCodeChange}
|
onChange={onCodeChange}
|
||||||
/>
|
/>
|
||||||
<div className={style.buttonContainer}>
|
<UButton style={{marginLeft: '10px'}} onClick={onFetchFile}>获取文件</UButton>
|
||||||
<UButton onClick={onFetchFile}>获取文件</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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,7 +1,7 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { createUseStyles } from "react-jss";
|
import { createUseStyles } from "react-jss";
|
||||||
import { useAuth } from "../api/auth.ts";
|
import { CloudBackground } from "../component/fluid/cloud.tsx";
|
||||||
import { UButton } from "../component/button/u-button.tsx";
|
import {useAuth} from "../api/auth.ts";
|
||||||
|
|
||||||
const useClass = createUseStyles({
|
const useClass = createUseStyles({
|
||||||
container: {
|
container: {
|
||||||
@@ -14,42 +14,88 @@ const useClass = createUseStyles({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
backgroundColor: "#e3f2fd",
|
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: {
|
form: {
|
||||||
backgroundColor: "#C8E6C9",
|
height: '100%',
|
||||||
boxShadow: "inset 0 0 15px rgba(56, 142, 60, 0.15)",
|
width: '100%',
|
||||||
padding: "30px",
|
|
||||||
borderRadius: "15px",
|
|
||||||
width: "350px",
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
flexDirection: 'column',
|
||||||
|
color: "#1a73e8",
|
||||||
|
padding: '40px',
|
||||||
},
|
},
|
||||||
title: {
|
input: {
|
||||||
color: "#2c9678",
|
width: '100%',
|
||||||
marginTop: 0,
|
marginTop: '20px',
|
||||||
marginBottom: "25px",
|
"& > 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: {
|
inputContainer: {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
marginTop: '15px',
|
marginTop: '20px',
|
||||||
},
|
},
|
||||||
inputField: {
|
inputField: {
|
||||||
width: "100%",
|
width: "calc(100% - 52px)",
|
||||||
padding: "11px",
|
padding: "12px 35px 12px 15px",
|
||||||
border: "2px solid #ddd",
|
border: "1px solid #ddd",
|
||||||
borderRadius: "5px",
|
borderRadius: "6px",
|
||||||
fontSize: "16px",
|
fontSize: "16px",
|
||||||
boxSizing: "border-box",
|
|
||||||
transition: "border-color 0.3s",
|
transition: "border-color 0.3s",
|
||||||
background: "rgba(255,255,255,0.8)",
|
|
||||||
"&:focus": {
|
"&:focus": {
|
||||||
outline: "none",
|
outline: "none",
|
||||||
borderColor: "#2c9678",
|
borderColor: "#1a73e8",
|
||||||
|
boxShadow: "0 0 0 2px rgba(26, 115, 232, 0.2)",
|
||||||
},
|
},
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
borderColor: "#2c9678",
|
borderColor: "#1a73e8",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
iconButton: {
|
iconButton: {
|
||||||
@@ -64,19 +110,12 @@ const useClass = createUseStyles({
|
|||||||
"&:hover": {
|
"&:hover": {
|
||||||
color: '#333',
|
color: '#333',
|
||||||
}
|
}
|
||||||
},
|
|
||||||
button: {
|
|
||||||
marginTop: '25px',
|
|
||||||
width: '100%',
|
|
||||||
"& > button": {
|
|
||||||
width: "100%",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const Login: React.FC = () => {
|
export const Login: React.FC = () => {
|
||||||
const classes = useClass()
|
const classes = useClass()
|
||||||
const { login } = useAuth()
|
const {login} = useAuth()
|
||||||
const [username, setUsername] = useState("")
|
const [username, setUsername] = useState("")
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
@@ -91,48 +130,53 @@ export const Login: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return <div className={classes.container}>
|
return <div className={classes.container}>
|
||||||
<div className={classes.form}>
|
<CloudBackground/>
|
||||||
<h2 className={classes.title}>UShare</h2>
|
<div className={classes.login_container}>
|
||||||
|
<div className={classes.form}>
|
||||||
|
<h2>UShare</h2>
|
||||||
|
|
||||||
<div className={classes.inputContainer}>
|
{/* 用户名输入框 */}
|
||||||
<input
|
<div className={classes.inputContainer}>
|
||||||
className={classes.inputField}
|
<input
|
||||||
placeholder="请输入账号"
|
className={classes.inputField}
|
||||||
value={username}
|
placeholder="请输入账号"
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
value={username}
|
||||||
/>
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
{username && (
|
/>
|
||||||
|
{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
|
<button
|
||||||
className={classes.iconButton}
|
className={classes.iconButton}
|
||||||
onClick={() => setUsername("")}
|
onMouseDown={() => setShowPassword(true)}
|
||||||
style={{ right: '10px', fontSize: '16px' }}
|
onMouseUp={() => setShowPassword(false)}
|
||||||
|
onMouseLeave={() => setShowPassword(false)}
|
||||||
|
style={{ right: '10px', fontSize: '12px' }}
|
||||||
>
|
>
|
||||||
×
|
{showPassword ? "👁" : "👁"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={classes.inputContainer}>
|
<div className={classes.button}>
|
||||||
<input
|
<button onClick={onLogin}>登录</button>
|
||||||
className={classes.inputField}
|
</div>
|
||||||
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}>
|
|
||||||
<UButton onClick={onLogin}>登录</UButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,429 +0,0 @@
|
|||||||
import React, {useEffect, useState} from 'react';
|
|
||||||
import {createUseStyles} from 'react-jss';
|
|
||||||
import {tokenApi, ApiToken, CreateTokenRes} from '../../api/token.ts';
|
|
||||||
import {message} from '../../hook/message/u-message.tsx';
|
|
||||||
import {UButton} from '../../component/button/u-button.tsx';
|
|
||||||
|
|
||||||
const useStyle = createUseStyles({
|
|
||||||
container: {
|
|
||||||
minHeight: '100vh',
|
|
||||||
backgroundColor: '#e3f2fd',
|
|
||||||
padding: '24px',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
fontFamily: "'Segoe UI', Arial, sans-serif",
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '16px',
|
|
||||||
marginBottom: '24px',
|
|
||||||
},
|
|
||||||
backBtn: {
|
|
||||||
background: 'transparent',
|
|
||||||
border: '2px solid #2c9678',
|
|
||||||
color: '#2c9678',
|
|
||||||
borderRadius: '6px',
|
|
||||||
padding: '6px 14px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '14px',
|
|
||||||
transition: 'background-color 0.2s',
|
|
||||||
'&:hover': {backgroundColor: 'rgba(44,150,120,0.1)'},
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
color: '#2c9678',
|
|
||||||
margin: 0,
|
|
||||||
fontSize: '22px',
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
backgroundColor: '#C8E6C9',
|
|
||||||
boxShadow: 'inset 0 0 15px rgba(56, 142, 60, 0.15)',
|
|
||||||
borderRadius: '15px',
|
|
||||||
padding: '24px',
|
|
||||||
marginBottom: '24px',
|
|
||||||
},
|
|
||||||
cardTitle: {
|
|
||||||
color: '#2c9678',
|
|
||||||
marginTop: 0,
|
|
||||||
marginBottom: '16px',
|
|
||||||
fontSize: '16px',
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
table: {
|
|
||||||
width: '100%',
|
|
||||||
borderCollapse: 'collapse',
|
|
||||||
fontSize: '14px',
|
|
||||||
},
|
|
||||||
th: {
|
|
||||||
backgroundColor: 'rgba(44,150,120,0.15)',
|
|
||||||
padding: '10px 12px',
|
|
||||||
textAlign: 'left',
|
|
||||||
color: '#2c9678',
|
|
||||||
fontWeight: 600,
|
|
||||||
borderBottom: '2px solid rgba(44,150,120,0.3)',
|
|
||||||
},
|
|
||||||
td: {
|
|
||||||
padding: '10px 12px',
|
|
||||||
borderBottom: '1px solid rgba(44,150,120,0.2)',
|
|
||||||
color: '#333',
|
|
||||||
},
|
|
||||||
trHover: {
|
|
||||||
'&:hover': {backgroundColor: 'rgba(44,150,120,0.05)'},
|
|
||||||
},
|
|
||||||
emptyRow: {
|
|
||||||
textAlign: 'center',
|
|
||||||
color: '#888',
|
|
||||||
padding: '24px',
|
|
||||||
},
|
|
||||||
actionBtn: {
|
|
||||||
padding: '4px 12px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '13px',
|
|
||||||
transition: 'opacity 0.2s',
|
|
||||||
'&:hover': {opacity: 0.8},
|
|
||||||
},
|
|
||||||
deleteBtn: {
|
|
||||||
backgroundColor: '#e53935',
|
|
||||||
color: 'white',
|
|
||||||
},
|
|
||||||
topBar: {
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: '16px',
|
|
||||||
},
|
|
||||||
// Dialog overlay
|
|
||||||
overlay: {
|
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
zIndex: 1000,
|
|
||||||
},
|
|
||||||
dialog: {
|
|
||||||
backgroundColor: '#C8E6C9',
|
|
||||||
borderRadius: '15px',
|
|
||||||
padding: '28px',
|
|
||||||
width: '440px',
|
|
||||||
maxWidth: '90vw',
|
|
||||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
|
|
||||||
},
|
|
||||||
dialogTitle: {
|
|
||||||
color: '#2c9678',
|
|
||||||
marginTop: 0,
|
|
||||||
marginBottom: '20px',
|
|
||||||
fontSize: '16px',
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
display: 'block',
|
|
||||||
color: '#2c9678',
|
|
||||||
fontSize: '13px',
|
|
||||||
marginBottom: '6px',
|
|
||||||
fontWeight: 500,
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderRadius: '6px',
|
|
||||||
border: '1px solid rgba(44,150,120,0.4)',
|
|
||||||
fontSize: '14px',
|
|
||||||
marginBottom: '16px',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.8)',
|
|
||||||
outline: 'none',
|
|
||||||
'&:focus': {borderColor: '#2c9678'},
|
|
||||||
},
|
|
||||||
dialogFooter: {
|
|
||||||
display: 'flex',
|
|
||||||
gap: '10px',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
},
|
|
||||||
cancelBtn: {
|
|
||||||
padding: '8px 18px',
|
|
||||||
borderRadius: '6px',
|
|
||||||
border: '2px solid #2c9678',
|
|
||||||
background: 'transparent',
|
|
||||||
color: '#2c9678',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '14px',
|
|
||||||
'&:hover': {backgroundColor: 'rgba(44,150,120,0.1)'},
|
|
||||||
},
|
|
||||||
tokenValueBox: {
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.9)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '12px 14px',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: '13px',
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
marginBottom: '12px',
|
|
||||||
color: '#1a1a2e',
|
|
||||||
border: '1px solid rgba(44,150,120,0.4)',
|
|
||||||
},
|
|
||||||
warningText: {
|
|
||||||
color: '#e53935',
|
|
||||||
fontSize: '12px',
|
|
||||||
marginBottom: '16px',
|
|
||||||
},
|
|
||||||
copyBtn: {
|
|
||||||
padding: '8px 18px',
|
|
||||||
borderRadius: '6px',
|
|
||||||
border: 'none',
|
|
||||||
background: '#2c9678',
|
|
||||||
color: 'white',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '14px',
|
|
||||||
'&:hover': {backgroundColor: '#1f6d5a'},
|
|
||||||
},
|
|
||||||
usageCard: {
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.5)',
|
|
||||||
borderRadius: '10px',
|
|
||||||
padding: '16px 20px',
|
|
||||||
},
|
|
||||||
usageTitle: {
|
|
||||||
color: '#2c9678',
|
|
||||||
margin: '0 0 10px',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
pre: {
|
|
||||||
margin: '6px 0',
|
|
||||||
padding: '10px 14px',
|
|
||||||
backgroundColor: '#1a1a2e',
|
|
||||||
color: '#c3e88d',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '13px',
|
|
||||||
overflowX: 'auto',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
interface Session {
|
|
||||||
user_id: number;
|
|
||||||
username: string;
|
|
||||||
role_label: string;
|
|
||||||
permissions: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SelfPage: React.FC = () => {
|
|
||||||
const style = useStyle();
|
|
||||||
const [session, setSession] = useState<Session | null>(null);
|
|
||||||
const [tokens, setTokens] = useState<ApiToken[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
|
||||||
const [newTokenName, setNewTokenName] = useState('');
|
|
||||||
const [creating, setCreating] = useState(false);
|
|
||||||
const [createdToken, setCreatedToken] = useState<CreateTokenRes | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/uauth/me')
|
|
||||||
.then(async res => {
|
|
||||||
if (!res.ok) {
|
|
||||||
window.location.href = '/login';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const json = await res.json();
|
|
||||||
const s: Session = json.data;
|
|
||||||
setSession(s);
|
|
||||||
if (!s.permissions.includes('token_manage')) {
|
|
||||||
message.warning('无 Token 管理权限');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return loadTokens();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
window.location.href = '/login';
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function loadTokens() {
|
|
||||||
try {
|
|
||||||
const list = await tokenApi.list();
|
|
||||||
setTokens(list ?? []);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
message.error(e instanceof Error ? e.message : '加载失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreate() {
|
|
||||||
if (!newTokenName.trim()) {
|
|
||||||
message.warning('请输入 Token 名称');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCreating(true);
|
|
||||||
try {
|
|
||||||
const res = await tokenApi.create(newTokenName.trim());
|
|
||||||
setCreatedToken(res);
|
|
||||||
setNewTokenName('');
|
|
||||||
setShowCreate(false);
|
|
||||||
await loadTokens();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
message.error(e instanceof Error ? e.message : '创建失败');
|
|
||||||
} finally {
|
|
||||||
setCreating(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(id: number, name: string) {
|
|
||||||
if (!confirm(`确认吊销 Token「${name}」?`)) return;
|
|
||||||
try {
|
|
||||||
await tokenApi.delete(id);
|
|
||||||
message.success('已吊销');
|
|
||||||
setTokens(prev => prev.filter(t => t.id !== id));
|
|
||||||
} catch (e: unknown) {
|
|
||||||
message.error(e instanceof Error ? e.message : '操作失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCopyToken(val: string) {
|
|
||||||
navigator.clipboard.writeText(val)
|
|
||||||
.then(() => message.success('已复制到剪贴板'))
|
|
||||||
.catch(() => message.warning('复制失败,请手动复制'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(s: string | null) {
|
|
||||||
if (!s) return '-';
|
|
||||||
return new Date(s).toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasTokenPerm = session?.permissions.includes('token_manage') ?? false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={style.container}>
|
|
||||||
<div className={style.header}>
|
|
||||||
<button className={style.backBtn} onClick={() => window.history.back()}>← 返回</button>
|
|
||||||
<h2 className={style.title}>个人中心</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!loading && session && (
|
|
||||||
<>
|
|
||||||
{/* User info card */}
|
|
||||||
<div className={style.card}>
|
|
||||||
<h3 className={style.cardTitle}>账号信息</h3>
|
|
||||||
<p style={{margin: '4px 0', color: '#333', fontSize: '14px'}}>
|
|
||||||
用户名:<strong>{session.username}</strong>
|
|
||||||
</p>
|
|
||||||
<p style={{margin: '4px 0', color: '#333', fontSize: '14px'}}>
|
|
||||||
角色:<strong>{session.role_label}</strong>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Token management card */}
|
|
||||||
{hasTokenPerm && (
|
|
||||||
<div className={style.card}>
|
|
||||||
<div className={style.topBar}>
|
|
||||||
<h3 className={style.cardTitle} style={{margin: 0}}>API Token</h3>
|
|
||||||
<UButton onClick={() => setShowCreate(true)}>+ 新建 Token</UButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table className={style.table}>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className={style.th}>名称</th>
|
|
||||||
<th className={style.th}>创建时间</th>
|
|
||||||
<th className={style.th}>最后使用</th>
|
|
||||||
<th className={style.th}>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{tokens.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td className={style.td} colSpan={4} style={{textAlign: 'center', color: '#888'}}>
|
|
||||||
暂无 Token,点击「新建 Token」创建
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
tokens.map(t => (
|
|
||||||
<tr key={t.id} className={style.trHover}>
|
|
||||||
<td className={style.td}>{t.name}</td>
|
|
||||||
<td className={style.td}>{formatDate(t.created_at)}</td>
|
|
||||||
<td className={style.td}>{formatDate(t.last_used_at)}</td>
|
|
||||||
<td className={style.td}>
|
|
||||||
<button
|
|
||||||
className={`${style.actionBtn} ${style.deleteBtn}`}
|
|
||||||
onClick={() => handleDelete(t.id, t.name)}
|
|
||||||
>
|
|
||||||
吊销
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{/* Usage guide */}
|
|
||||||
<div style={{marginTop: '20px'}}>
|
|
||||||
<div className={style.usageCard}>
|
|
||||||
<p className={style.usageTitle}>使用方式(curl 示例)</p>
|
|
||||||
<pre className={style.pre}>{`curl -H "Authorization: Bearer <your_token>" \\
|
|
||||||
-T <file_path> \\
|
|
||||||
https://<your_domain>/api/v1/upload/<filename>`}</pre>
|
|
||||||
<p style={{margin: '8px 0 4px', color: '#555', fontSize: '13px'}}>返回示例:</p>
|
|
||||||
<pre className={style.pre}>{`{"status":200,"data":{"code":"ABCD1234"}}`}</pre>
|
|
||||||
<p style={{margin: '8px 0 4px', color: '#555', fontSize: '13px'}}>下载文件:</p>
|
|
||||||
<pre className={style.pre}>{`https://<your_domain>/ushare/<code>`}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!hasTokenPerm && (
|
|
||||||
<div className={style.card}>
|
|
||||||
<p style={{color: '#888', margin: 0}}>当前角色无 Token 管理权限</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Create token dialog */}
|
|
||||||
{showCreate && (
|
|
||||||
<div className={style.overlay} onClick={() => setShowCreate(false)}>
|
|
||||||
<div className={style.dialog} onClick={e => e.stopPropagation()}>
|
|
||||||
<h3 className={style.dialogTitle}>新建 API Token</h3>
|
|
||||||
<label className={style.label}>Token 名称</label>
|
|
||||||
<input
|
|
||||||
className={style.input}
|
|
||||||
placeholder="例:服务器上传脚本"
|
|
||||||
value={newTokenName}
|
|
||||||
onChange={e => setNewTokenName(e.target.value)}
|
|
||||||
onKeyDown={e => e.key === 'Enter' && handleCreate()}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div className={style.dialogFooter}>
|
|
||||||
<button className={style.cancelBtn} onClick={() => setShowCreate(false)}>取消</button>
|
|
||||||
<UButton onClick={handleCreate} loading={creating} disabled={creating}>
|
|
||||||
创建
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Newly created token display - shown only once */}
|
|
||||||
{createdToken && (
|
|
||||||
<div className={style.overlay} onClick={() => setCreatedToken(null)}>
|
|
||||||
<div className={style.dialog} onClick={e => e.stopPropagation()}>
|
|
||||||
<h3 className={style.dialogTitle}>Token 已创建</h3>
|
|
||||||
<p className={style.warningText}>
|
|
||||||
请立即复制并妥善保存,Token 值仅显示一次,关闭后无法再次查看!
|
|
||||||
</p>
|
|
||||||
<label className={style.label}>Token 名称:{createdToken.name}</label>
|
|
||||||
<div className={style.tokenValueBox}>{createdToken.token}</div>
|
|
||||||
<div className={style.dialogFooter}>
|
|
||||||
<button className={style.cancelBtn} onClick={() => setCreatedToken(null)}>关闭</button>
|
|
||||||
<button className={style.copyBtn} onClick={() => handleCopyToken(createdToken.token)}>
|
|
||||||
复制 Token
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -13,12 +13,6 @@ const useStyle = createUseStyles({
|
|||||||
height: "100vh",
|
height: "100vh",
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "40% 20% 40%",
|
gridTemplateColumns: "40% 20% 40%",
|
||||||
|
|
||||||
"@media (max-width: 768px)": {
|
|
||||||
gridTemplateColumns: "100%",
|
|
||||||
gridTemplateRows: "auto auto",
|
|
||||||
overflowY: "auto",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -26,7 +20,7 @@ export const FileSharing = () => {
|
|||||||
const style = useStyle()
|
const style = useStyle()
|
||||||
return <div className={style.container}>
|
return <div className={style.container}>
|
||||||
<PanelLeft />
|
<PanelLeft />
|
||||||
<PanelMid />
|
<PanelMid/>
|
||||||
<PanelRight/>
|
<PanelRight/>
|
||||||
</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}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
@@ -5,17 +5,11 @@ import react from '@vitejs/plugin-react'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://127.0.0.1:9119',
|
target: 'http://127.0.0.1:9119',
|
||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
},
|
},
|
||||||
'/api/ulocal/ws': {
|
|
||||||
target: 'ws://127.0.0.1:9119',
|
|
||||||
rewriteWsOrigin: true,
|
|
||||||
ws: true,
|
|
||||||
},
|
|
||||||
'/ushare': {
|
'/ushare': {
|
||||||
target: 'http://127.0.0.1:9119',
|
target: 'http://127.0.0.1:9119',
|
||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
|
|||||||
31
go.mod
31
go.mod
@@ -3,65 +3,48 @@ module github.com/loveuer/ushare
|
|||||||
go 1.24.2
|
go 1.24.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/glebarez/sqlite v1.11.0
|
|
||||||
github.com/google/uuid v1.6.0
|
|
||||||
github.com/gorilla/websocket v1.5.3
|
|
||||||
github.com/jedib0t/go-pretty/v6 v6.6.7
|
|
||||||
github.com/loveuer/nf v0.3.5
|
github.com/loveuer/nf v0.3.5
|
||||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
|
||||||
github.com/mileusna/useragent v1.3.5
|
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
github.com/spf13/cast v1.7.1
|
|
||||||
github.com/spf13/viper v1.20.1
|
github.com/spf13/viper v1.20.1
|
||||||
golang.org/x/crypto v0.32.0
|
|
||||||
gorm.io/driver/mysql v1.6.0
|
|
||||||
gorm.io/driver/postgres v1.6.0
|
|
||||||
gorm.io/gorm v1.30.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dario.cat/mergo v1.0.0 // indirect
|
dario.cat/mergo v1.0.0 // indirect
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
|
||||||
github.com/Microsoft/go-winio v0.6.1 // indirect
|
github.com/Microsoft/go-winio v0.6.1 // indirect
|
||||||
github.com/ProtonMail/go-crypto v1.0.0 // indirect
|
github.com/ProtonMail/go-crypto v1.0.0 // indirect
|
||||||
github.com/cloudflare/circl v1.3.7 // indirect
|
github.com/cloudflare/circl v1.3.7 // indirect
|
||||||
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
|
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
|
||||||
github.com/emirpasic/gods v1.18.1 // indirect
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
github.com/fatih/color v1.17.0 // indirect
|
github.com/fatih/color v1.17.0 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||||
github.com/go-git/go-billy/v5 v5.5.0 // indirect
|
github.com/go-git/go-billy/v5 v5.5.0 // indirect
|
||||||
github.com/go-git/go-git/v5 v5.12.0 // indirect
|
github.com/go-git/go-git/v5 v5.12.0 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
|
||||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jedib0t/go-pretty/v6 v6.6.7 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
|
||||||
github.com/kevinburke/ssh_config v1.2.0 // 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-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||||
github.com/pjbgf/sha1cd v0.3.0 // indirect
|
github.com/pjbgf/sha1cd v0.3.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.7.0 // indirect
|
github.com/sagikazarmark/locafero v0.7.0 // indirect
|
||||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||||
github.com/skeema/knownhosts v1.2.2 // indirect
|
github.com/skeema/knownhosts v1.2.2 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||||
github.com/spf13/afero v1.12.0 // indirect
|
github.com/spf13/afero v1.12.0 // indirect
|
||||||
|
github.com/spf13/cast v1.7.1 // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
go.uber.org/atomic v1.9.0 // indirect
|
go.uber.org/atomic v1.9.0 // indirect
|
||||||
go.uber.org/multierr v1.9.0 // indirect
|
go.uber.org/multierr v1.9.0 // indirect
|
||||||
|
golang.org/x/crypto v0.32.0 // indirect
|
||||||
golang.org/x/mod v0.17.0 // indirect
|
golang.org/x/mod v0.17.0 // indirect
|
||||||
golang.org/x/net v0.33.0 // indirect
|
golang.org/x/net v0.33.0 // indirect
|
||||||
golang.org/x/sync v0.11.0 // indirect
|
golang.org/x/sync v0.11.0 // indirect
|
||||||
@@ -70,8 +53,4 @@ require (
|
|||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/libc v1.22.5 // indirect
|
|
||||||
modernc.org/mathutil v1.5.0 // indirect
|
|
||||||
modernc.org/memory v1.5.0 // indirect
|
|
||||||
modernc.org/sqlite v1.23.1 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
58
go.sum
58
go.sum
@@ -1,7 +1,5 @@
|
|||||||
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||||
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
|
||||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||||
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
|
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
|
||||||
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
|
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
|
||||||
@@ -20,8 +18,6 @@ github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxG
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
||||||
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
|
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
|
||||||
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
|
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
|
||||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||||
@@ -32,10 +28,6 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
|||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
|
||||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
|
||||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
|
||||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
|
||||||
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
|
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
|
||||||
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
|
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||||
@@ -46,36 +38,18 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj
|
|||||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||||
github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys=
|
github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys=
|
||||||
github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY=
|
github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY=
|
||||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
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/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7 h1:y3N7Bm7Y9/CtpiVkw/ZWj6lSlDF3F74SfKwfTCer72Q=
|
|
||||||
github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
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/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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
|
||||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
|
||||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
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/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 h1:m+LbHpm0aIAPLzLbMfn8dc3Ht8MW7lsSO4MPItz/Uuo=
|
||||||
github.com/jedib0t/go-pretty/v6 v6.6.7/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
|
github.com/jedib0t/go-pretty/v6 v6.6.7/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
|
||||||
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
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/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
@@ -96,8 +70,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
|||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
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 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
|
||||||
github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M=
|
github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||||
@@ -108,9 +80,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
@@ -137,7 +106,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
|||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
@@ -172,7 +140,8 @@ golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
|||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
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/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-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-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -188,6 +157,8 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
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.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 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
@@ -195,8 +166,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
|
|||||||
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||||
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
|
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||||
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
@@ -204,6 +175,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|||||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
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.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 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
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-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@@ -221,20 +194,5 @@ gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
|||||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
|
||||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
|
||||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
|
||||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
|
||||||
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
|
|
||||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
|
||||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
|
||||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
|
||||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
|
||||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
|
||||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
|
||||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
|
||||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
|
||||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
|
||||||
|
|||||||
@@ -2,67 +2,26 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/loveuer/nf"
|
"github.com/loveuer/nf"
|
||||||
"github.com/loveuer/nf/nft/log"
|
"github.com/loveuer/nf/nft/log"
|
||||||
"github.com/loveuer/nf/nft/tool"
|
"github.com/loveuer/nf/nft/tool"
|
||||||
"github.com/loveuer/ushare/internal/handler"
|
"github.com/loveuer/ushare/internal/handler"
|
||||||
"github.com/loveuer/ushare/internal/model"
|
|
||||||
"github.com/loveuer/ushare/internal/opt"
|
"github.com/loveuer/ushare/internal/opt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Start(ctx context.Context) <-chan struct{} {
|
func Start(ctx context.Context) <-chan struct{} {
|
||||||
app := nf.New(nf.Config{BodyLimit: 10 * 1024 * 1024 * 1024})
|
app := nf.New(nf.Config{BodyLimit: 10 * 1024 * 1024 * 1024})
|
||||||
|
|
||||||
app.Get("/api/healthz", func(c *nf.Ctx) error {
|
app.Get("/api/available", func(c *nf.Ctx) error {
|
||||||
return c.SendStatus(http.StatusOK)
|
return c.SendStatus(http.StatusOK)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auth
|
|
||||||
app.Post("/api/uauth/login", handler.AuthLogin())
|
|
||||||
app.Get("/api/uauth/me", handler.AuthVerify(), handler.AuthMe())
|
|
||||||
|
|
||||||
// File sharing
|
|
||||||
app.Get("/ushare/:code", handler.Fetch())
|
app.Get("/ushare/:code", handler.Fetch())
|
||||||
app.Put("/api/ushare/:filename", handler.AuthVerify(), handler.AuthPermission(model.PermUpload), handler.ShareNew())
|
app.Put("/api/ushare/:filename", handler.AuthVerify(), handler.ShareNew()) // 获取上传 code, 分片大小
|
||||||
app.Post("/api/ushare/:code", handler.ShareUpload())
|
app.Post("/api/ushare/:code", handler.ShareUpload()) // 分片上传接口
|
||||||
|
app.Post("/api/uauth/login", handler.AuthLogin())
|
||||||
// Local sharing (WebRTC signaling)
|
|
||||||
{
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin
|
|
||||||
{
|
|
||||||
api := app.Group("/api/admin")
|
|
||||||
api.Get("/users", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminListUsers())
|
|
||||||
api.Post("/users", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminCreateUser())
|
|
||||||
api.Put("/users/:id", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminUpdateUser())
|
|
||||||
api.Delete("/users/:id", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminDeleteUser())
|
|
||||||
api.Get("/roles", handler.AuthVerify(), handler.AuthPermission(model.PermUserManage), handler.AdminListRoles())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Token management
|
|
||||||
{
|
|
||||||
api := app.Group("/api/token")
|
|
||||||
api.Get("", handler.AuthVerify(), handler.AuthPermission(model.PermTokenManage), handler.TokenList())
|
|
||||||
api.Post("", handler.AuthVerify(), handler.AuthPermission(model.PermTokenManage), handler.TokenCreate())
|
|
||||||
api.Delete("", handler.AuthVerify(), handler.AuthPermission(model.PermTokenManage), handler.TokenDelete())
|
|
||||||
}
|
|
||||||
|
|
||||||
// API v1 - token-authenticated file upload
|
|
||||||
app.Put("/api/v1/upload/:filename", handler.AuthVerify(), handler.AuthPermission(model.PermUpload), handler.ShareAPIUpload())
|
|
||||||
|
|
||||||
// Frontend static files
|
|
||||||
app.Use(handler.ServeFrontendMiddleware())
|
|
||||||
|
|
||||||
ready := make(chan struct{})
|
ready := make(chan struct{})
|
||||||
ln, err := net.Listen("tcp", opt.Cfg.Address)
|
ln, err := net.Listen("tcp", opt.Cfg.Address)
|
||||||
|
|||||||
@@ -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,97 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/loveuer/ushare/internal/model"
|
|
||||||
"github.com/loveuer/ushare/internal/pkg/db"
|
|
||||||
"github.com/loveuer/ushare/internal/pkg/tool"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
type tokenManager struct{}
|
|
||||||
|
|
||||||
var TokenManager = &tokenManager{}
|
|
||||||
|
|
||||||
// List returns all tokens belonging to a user (token value is not exposed).
|
|
||||||
func (tm *tokenManager) List(userID uint) ([]model.Token, error) {
|
|
||||||
var tokens []model.Token
|
|
||||||
if err := db.Default.Session().Where("user_id = ?", userID).Order("created_at desc").Find(&tokens).Error; err != nil {
|
|
||||||
return nil, errors.Wrap(err, "list tokens failed")
|
|
||||||
}
|
|
||||||
return tokens, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create generates a new API token for the given user and returns the full token value (only shown once).
|
|
||||||
func (tm *tokenManager) Create(userID uint, name string) (*model.Token, string, error) {
|
|
||||||
name = strings.TrimSpace(name)
|
|
||||||
if name == "" {
|
|
||||||
return nil, "", errors.New("token 名称不能为空")
|
|
||||||
}
|
|
||||||
|
|
||||||
rawToken := model.TokenPrefix + tool.RandomString(32)
|
|
||||||
|
|
||||||
t := &model.Token{
|
|
||||||
UserID: userID,
|
|
||||||
Name: name,
|
|
||||||
Token: rawToken,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.Default.Session().Create(t).Error; err != nil {
|
|
||||||
return nil, "", errors.Wrap(err, "create token failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
return t, rawToken, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes a token by ID, only if it belongs to the given user.
|
|
||||||
func (tm *tokenManager) Delete(userID uint, tokenID uint) error {
|
|
||||||
result := db.Default.Session().
|
|
||||||
Where("id = ? AND user_id = ?", tokenID, userID).
|
|
||||||
Delete(&model.Token{})
|
|
||||||
|
|
||||||
if result.Error != nil {
|
|
||||||
return errors.Wrap(result.Error, "delete token failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
return errors.New("token 不存在或无权限删除")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify looks up a DB API token and returns a Session if valid.
|
|
||||||
func (tm *tokenManager) Verify(rawToken string) (*model.Session, error) {
|
|
||||||
var t model.Token
|
|
||||||
err := db.Default.Session().
|
|
||||||
Where("token = ?", rawToken).
|
|
||||||
Preload("User").
|
|
||||||
Preload("User.Role").
|
|
||||||
First(&t).Error
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.New("无效的 API Token")
|
|
||||||
}
|
|
||||||
|
|
||||||
if t.ExpiresAt != nil && time.Now().After(*t.ExpiresAt) {
|
|
||||||
return nil, errors.New("API Token 已过期")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update last_used_at asynchronously
|
|
||||||
now := time.Now()
|
|
||||||
go db.Default.Session().Model(&t).Update("last_used_at", now) //nolint:errcheck
|
|
||||||
|
|
||||||
session := &model.Session{
|
|
||||||
UserID: t.User.ID,
|
|
||||||
Username: t.User.Username,
|
|
||||||
Role: t.User.Role.Name,
|
|
||||||
RoleLabel: t.User.Role.Label,
|
|
||||||
Permissions: t.User.Role.PermissionList(),
|
|
||||||
LoginAt: now.Unix(),
|
|
||||||
Token: rawToken,
|
|
||||||
}
|
|
||||||
|
|
||||||
return session, nil
|
|
||||||
}
|
|
||||||
@@ -2,149 +2,75 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/loveuer/nf/nft/log"
|
|
||||||
"github.com/loveuer/ushare/internal/model"
|
"github.com/loveuer/ushare/internal/model"
|
||||||
"github.com/loveuer/ushare/internal/opt"
|
"github.com/loveuer/ushare/internal/opt"
|
||||||
"github.com/loveuer/ushare/internal/pkg/db"
|
|
||||||
"github.com/loveuer/ushare/internal/pkg/tool"
|
"github.com/loveuer/ushare/internal/pkg/tool"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type userManager struct {
|
type userManager struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
sessions map[string]*model.Session
|
um map[string]*model.User
|
||||||
}
|
}
|
||||||
|
|
||||||
var UserManager = &userManager{
|
func (um *userManager) Login(username string, password string) (*model.User, error) {
|
||||||
sessions: make(map[string]*model.Session),
|
var (
|
||||||
}
|
now = time.Now()
|
||||||
|
)
|
||||||
|
|
||||||
func (um *userManager) seed(ctx context.Context) error {
|
if username != "admin" {
|
||||||
// Seed default roles if they don't exist
|
return nil, errors.New("账号或密码错误")
|
||||||
defaultRoles := []model.Role{
|
|
||||||
{
|
|
||||||
Name: model.RoleAdmin,
|
|
||||||
Label: "管理员",
|
|
||||||
Permissions: strings.Join([]string{model.PermUserManage, model.PermUpload, model.PermTokenManage}, ","),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: model.RoleUser,
|
|
||||||
Label: "用户",
|
|
||||||
Permissions: model.PermUpload,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range defaultRoles {
|
if !tool.ComparePassword(password, opt.Cfg.Auth) {
|
||||||
role := &defaultRoles[i]
|
return nil, errors.New("账号或密码错误")
|
||||||
var existing model.Role
|
|
||||||
if err := db.Default.Session(ctx).Where("name = ?", role.Name).First(&existing).Error; err != nil {
|
|
||||||
if err := db.Default.Session(ctx).Create(role).Error; err != nil {
|
|
||||||
return errors.Wrap(err, "seed role failed")
|
|
||||||
}
|
|
||||||
log.Debug("controller.userManager.seed: created role %s", role.Name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed default admin user only if no users exist
|
op := &model.User{
|
||||||
var count int64
|
Id: 1,
|
||||||
db.Default.Session(ctx).Model(&model.User{}).Count(&count)
|
|
||||||
if count > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var adminRole model.Role
|
|
||||||
if err := db.Default.Session(ctx).Where("name = ?", model.RoleAdmin).First(&adminRole).Error; err != nil {
|
|
||||||
return errors.Wrap(err, "get admin role failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
username := opt.Cfg.Username
|
|
||||||
if username == "" {
|
|
||||||
username = "admin"
|
|
||||||
}
|
|
||||||
|
|
||||||
// opt.Cfg.Password is already hashed by opt.Init(); store it directly.
|
|
||||||
adminUser := &model.User{
|
|
||||||
Username: username,
|
Username: username,
|
||||||
Password: opt.Cfg.Password,
|
LoginAt: now.Unix(),
|
||||||
RoleID: adminRole.ID,
|
Token: tool.RandomString(32),
|
||||||
Active: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.Default.Session(ctx).Create(adminUser).Error; err != nil {
|
|
||||||
return errors.Wrap(err, "seed admin user failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debug("controller.userManager.seed: created admin user %s", username)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (um *userManager) Login(username, password string) (*model.Session, error) {
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
user := new(model.User)
|
|
||||||
if err := db.Default.Session().
|
|
||||||
Where("username = ? AND active = ?", username, true).
|
|
||||||
Preload("Role").
|
|
||||||
First(user).Error; err != nil {
|
|
||||||
return nil, errors.New("账号或密码错误")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !tool.ComparePassword(password, user.Password) {
|
|
||||||
return nil, errors.New("账号或密码错误")
|
|
||||||
}
|
|
||||||
|
|
||||||
session := &model.Session{
|
|
||||||
UserID: user.ID,
|
|
||||||
Username: user.Username,
|
|
||||||
Role: user.Role.Name,
|
|
||||||
RoleLabel: user.Role.Label,
|
|
||||||
Permissions: user.Role.PermissionList(),
|
|
||||||
LoginAt: now.Unix(),
|
|
||||||
Token: tool.RandomString(32),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
um.Lock()
|
um.Lock()
|
||||||
defer um.Unlock()
|
defer um.Unlock()
|
||||||
um.sessions[session.Token] = session
|
um.um[op.Token] = op
|
||||||
|
|
||||||
return session, nil
|
return op, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (um *userManager) Verify(token string) (*model.Session, error) {
|
func (um *userManager) Verify(token string) (*model.User, error) {
|
||||||
um.Lock()
|
um.Lock()
|
||||||
defer um.Unlock()
|
defer um.Unlock()
|
||||||
|
|
||||||
session, ok := um.sessions[token]
|
op, ok := um.um[token]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, errors.New("未登录或凭证已失效, 请重新登录")
|
return nil, errors.New("未登录或凭证已失效, 请重新登录")
|
||||||
}
|
}
|
||||||
|
|
||||||
return session, nil
|
return op, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (um *userManager) Start(ctx context.Context) {
|
func (um *userManager) Start(ctx context.Context) {
|
||||||
um.ctx = ctx
|
um.ctx = ctx
|
||||||
|
|
||||||
if err := um.seed(ctx); err != nil {
|
|
||||||
log.Fatal("controller.userManager.Start: seed failed: %s", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
|
||||||
ticker := time.NewTicker(time.Minute)
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-um.ctx.Done():
|
case <-um.ctx.Done():
|
||||||
return
|
return
|
||||||
case now := <-ticker.C:
|
case now := <-ticker.C:
|
||||||
um.Lock()
|
um.Lock()
|
||||||
for token, session := range um.sessions {
|
for _, op := range um.um {
|
||||||
if now.Unix()-session.LoginAt > 8*3600 {
|
if now.Sub(time.UnixMilli(op.LoginAt)) > 8*time.Hour {
|
||||||
delete(um.sessions, token)
|
delete(um.um, op.Token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
um.Unlock()
|
um.Unlock()
|
||||||
@@ -152,3 +78,9 @@ func (um *userManager) Start(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
UserManager = &userManager{
|
||||||
|
um: make(map[string]*model.User),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/loveuer/nf"
|
|
||||||
"github.com/loveuer/nf/nft/log"
|
|
||||||
"github.com/loveuer/ushare/internal/model"
|
|
||||||
"github.com/loveuer/ushare/internal/pkg/db"
|
|
||||||
"github.com/loveuer/ushare/internal/pkg/tool"
|
|
||||||
"github.com/spf13/cast"
|
|
||||||
)
|
|
||||||
|
|
||||||
func AdminListUsers() nf.HandlerFunc {
|
|
||||||
return func(c *nf.Ctx) error {
|
|
||||||
var users []model.User
|
|
||||||
if err := db.Default.Session().Preload("Role").Find(&users).Error; err != nil {
|
|
||||||
log.Error("handler.AdminListUsers: %s", err.Error())
|
|
||||||
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询失败"})
|
|
||||||
}
|
|
||||||
return c.Status(http.StatusOK).JSON(map[string]any{"data": users})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func AdminCreateUser() nf.HandlerFunc {
|
|
||||||
return func(c *nf.Ctx) error {
|
|
||||||
type Req struct {
|
|
||||||
Username string `json:"username"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
RoleID uint `json:"role_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var req Req
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "参数错误"})
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Username = strings.TrimSpace(req.Username)
|
|
||||||
if req.Username == "" {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "用户名不能为空"})
|
|
||||||
}
|
|
||||||
if req.Password == "" {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "密码不能为空"})
|
|
||||||
}
|
|
||||||
if req.RoleID == 0 {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "角色不能为空"})
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tool.CheckPassword(req.Password); err != nil {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
var count int64
|
|
||||||
db.Default.Session().Model(&model.User{}).Where("username = ?", req.Username).Count(&count)
|
|
||||||
if count > 0 {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "用户名已存在"})
|
|
||||||
}
|
|
||||||
|
|
||||||
user := &model.User{
|
|
||||||
Username: req.Username,
|
|
||||||
Password: tool.NewPassword(req.Password),
|
|
||||||
RoleID: req.RoleID,
|
|
||||||
Active: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.Default.Session().Create(user).Error; err != nil {
|
|
||||||
log.Error("handler.AdminCreateUser: %s", err.Error())
|
|
||||||
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "创建用户失败"})
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.Default.Session().Preload("Role").First(user, user.ID).Error; err != nil {
|
|
||||||
log.Error("handler.AdminCreateUser: preload role: %s", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.Status(http.StatusOK).JSON(map[string]any{"data": user})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func AdminUpdateUser() nf.HandlerFunc {
|
|
||||||
return func(c *nf.Ctx) error {
|
|
||||||
type Req struct {
|
|
||||||
RoleID *uint `json:"role_id"`
|
|
||||||
Active *bool `json:"active"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := cast.ToUintE(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无效的用户ID"})
|
|
||||||
}
|
|
||||||
|
|
||||||
var req Req
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "参数错误"})
|
|
||||||
}
|
|
||||||
|
|
||||||
session := c.Locals("user").(*model.Session)
|
|
||||||
|
|
||||||
user := new(model.User)
|
|
||||||
if err := db.Default.Session().Preload("Role").First(user, id).Error; err != nil {
|
|
||||||
return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "用户不存在"})
|
|
||||||
}
|
|
||||||
|
|
||||||
updates := map[string]any{}
|
|
||||||
|
|
||||||
if req.RoleID != nil && *req.RoleID != user.RoleID {
|
|
||||||
var newRole model.Role
|
|
||||||
if err := db.Default.Session().First(&newRole, *req.RoleID).Error; err != nil {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无效的角色"})
|
|
||||||
}
|
|
||||||
// If demoting from admin, ensure at least one other active admin remains
|
|
||||||
if user.Role.Name == model.RoleAdmin && newRole.Name != model.RoleAdmin {
|
|
||||||
var adminCount int64
|
|
||||||
db.Default.Session().Model(&model.User{}).
|
|
||||||
Where("role_id = ? AND active = ? AND id != ?", user.RoleID, true, id).
|
|
||||||
Count(&adminCount)
|
|
||||||
if adminCount == 0 {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无法更改角色: 系统中至少需要一个管理员"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updates["role_id"] = *req.RoleID
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Active != nil && *req.Active != user.Active {
|
|
||||||
if user.ID == session.UserID && !*req.Active {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "不能禁用自己的账号"})
|
|
||||||
}
|
|
||||||
if user.Role.Name == model.RoleAdmin && !*req.Active {
|
|
||||||
var adminCount int64
|
|
||||||
db.Default.Session().Model(&model.User{}).
|
|
||||||
Where("role_id = ? AND active = ? AND id != ?", user.RoleID, true, id).
|
|
||||||
Count(&adminCount)
|
|
||||||
if adminCount == 0 {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无法禁用: 系统中至少需要一个启用的管理员"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updates["active"] = *req.Active
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Password != "" {
|
|
||||||
if err := tool.CheckPassword(req.Password); err != nil {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
|
|
||||||
}
|
|
||||||
updates["password"] = tool.NewPassword(req.Password)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(updates) == 0 {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "没有需要更新的字段"})
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.Default.Session().Model(user).Updates(updates).Error; err != nil {
|
|
||||||
log.Error("handler.AdminUpdateUser: %s", err.Error())
|
|
||||||
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "更新失败"})
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.Default.Session().Preload("Role").First(user, user.ID).Error; err != nil {
|
|
||||||
log.Error("handler.AdminUpdateUser: preload: %s", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.Status(http.StatusOK).JSON(map[string]any{"data": user})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func AdminDeleteUser() nf.HandlerFunc {
|
|
||||||
return func(c *nf.Ctx) error {
|
|
||||||
id, err := cast.ToUintE(c.Param("id"))
|
|
||||||
if err != nil {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无效的用户ID"})
|
|
||||||
}
|
|
||||||
|
|
||||||
session := c.Locals("user").(*model.Session)
|
|
||||||
if session.UserID == id {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "不能删除自己的账号"})
|
|
||||||
}
|
|
||||||
|
|
||||||
user := new(model.User)
|
|
||||||
if err := db.Default.Session().Preload("Role").First(user, id).Error; err != nil {
|
|
||||||
return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "用户不存在"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prevent deleting the last admin
|
|
||||||
if user.Role.Name == model.RoleAdmin {
|
|
||||||
var adminCount int64
|
|
||||||
db.Default.Session().Model(&model.User{}).
|
|
||||||
Where("role_id = ? AND id != ?", user.RoleID, id).
|
|
||||||
Count(&adminCount)
|
|
||||||
if adminCount == 0 {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "无法删除最后一个管理员"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.Default.Session().Delete(user).Error; err != nil {
|
|
||||||
log.Error("handler.AdminDeleteUser: %s", err.Error())
|
|
||||||
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "删除失败"})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.Status(http.StatusOK).JSON(map[string]any{"data": "ok"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func AdminListRoles() nf.HandlerFunc {
|
|
||||||
return func(c *nf.Ctx) error {
|
|
||||||
var roles []model.Role
|
|
||||||
if err := db.Default.Session().Find(&roles).Error; err != nil {
|
|
||||||
log.Error("handler.AdminListRoles: %s", err.Error())
|
|
||||||
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "查询失败"})
|
|
||||||
}
|
|
||||||
return c.Status(http.StatusOK).JSON(map[string]any{"data": roles})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,82 +2,45 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/loveuer/nf"
|
"github.com/loveuer/nf"
|
||||||
"github.com/loveuer/ushare/internal/controller"
|
"github.com/loveuer/ushare/internal/controller"
|
||||||
"github.com/loveuer/ushare/internal/model"
|
"github.com/loveuer/ushare/internal/model"
|
||||||
|
"github.com/loveuer/ushare/internal/opt"
|
||||||
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
func AuthVerify() nf.HandlerFunc {
|
func AuthVerify() nf.HandlerFunc {
|
||||||
tokenFn := func(c *nf.Ctx) (token string) {
|
tokenFn := func(c *nf.Ctx) (token string) {
|
||||||
if raw := c.Get("Authorization"); raw != "" {
|
if token = c.Get("Authorization"); token != "" {
|
||||||
// Strip "Bearer " prefix if present
|
return
|
||||||
if strings.HasPrefix(raw, "Bearer ") {
|
|
||||||
return strings.TrimPrefix(raw, "Bearer ")
|
|
||||||
}
|
|
||||||
return raw
|
|
||||||
}
|
}
|
||||||
|
|
||||||
token = c.Cookies("ushare")
|
token = c.Cookies("ushare")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return func(c *nf.Ctx) error {
|
return func(c *nf.Ctx) error {
|
||||||
|
if opt.Cfg.Auth == "" {
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
token := tokenFn(c)
|
token := tokenFn(c)
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
|
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
op, err := controller.UserManager.Verify(token)
|
||||||
session *model.Session
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
|
|
||||||
// API tokens have the "ust_" prefix; session tokens do not.
|
|
||||||
if strings.HasPrefix(token, model.TokenPrefix) {
|
|
||||||
session, err = controller.TokenManager.Verify(token)
|
|
||||||
} else {
|
|
||||||
session, err = controller.UserManager.Verify(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized", "msg": err.Error()})
|
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized", "msg": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Locals("user", session)
|
c.Locals("user", op)
|
||||||
|
|
||||||
return c.Next()
|
return c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func AuthPermission(perm string) nf.HandlerFunc {
|
|
||||||
return func(c *nf.Ctx) error {
|
|
||||||
session, ok := c.Locals("user").(*model.Session)
|
|
||||||
if !ok || session == nil {
|
|
||||||
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range session.Permissions {
|
|
||||||
if p == perm {
|
|
||||||
return c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.Status(http.StatusForbidden).JSON(map[string]string{"error": "forbidden", "msg": "权限不足"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func AuthMe() nf.HandlerFunc {
|
|
||||||
return func(c *nf.Ctx) error {
|
|
||||||
session, ok := c.Locals("user").(*model.Session)
|
|
||||||
if !ok || session == nil {
|
|
||||||
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
|
|
||||||
}
|
|
||||||
return c.Status(http.StatusOK).JSON(map[string]any{"data": session})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func AuthLogin() nf.HandlerFunc {
|
func AuthLogin() nf.HandlerFunc {
|
||||||
return func(c *nf.Ctx) error {
|
return func(c *nf.Ctx) error {
|
||||||
type Req struct {
|
type Req struct {
|
||||||
@@ -86,22 +49,22 @@ func AuthLogin() nf.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
err error
|
err error
|
||||||
req Req
|
req Req
|
||||||
session *model.Session
|
op *model.User
|
||||||
)
|
)
|
||||||
|
|
||||||
if err = c.BodyParser(&req); err != nil {
|
if err = c.BodyParser(&req); err != nil {
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "错误的用户名或密码<1>"})
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "错误的用户名或密码<1>"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if session, err = controller.UserManager.Login(req.Username, req.Password); err != nil {
|
if op, err = controller.UserManager.Login(req.Username, req.Password); err != nil {
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
|
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
header := fmt.Sprintf("ushare=%s; Path=/; Max-Age=%d", session.Token, 8*3600)
|
header := fmt.Sprintf("ushare=%s; Path=/; Max-Age=%d", op.Token, 8*3600)
|
||||||
c.SetHeader("Set-Cookie", header)
|
c.SetHeader("Set-Cookie", header)
|
||||||
|
|
||||||
return c.Status(http.StatusOK).JSON(map[string]any{"data": session})
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,11 +2,6 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/loveuer/nf"
|
"github.com/loveuer/nf"
|
||||||
"github.com/loveuer/nf/nft/log"
|
"github.com/loveuer/nf/nft/log"
|
||||||
"github.com/loveuer/ushare/internal/controller"
|
"github.com/loveuer/ushare/internal/controller"
|
||||||
@@ -15,6 +10,10 @@ import (
|
|||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/spf13/cast"
|
"github.com/spf13/cast"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Fetch() nf.HandlerFunc {
|
func Fetch() nf.HandlerFunc {
|
||||||
@@ -117,36 +116,3 @@ func ShareUpload() nf.HandlerFunc {
|
|||||||
return c.Status(http.StatusOK).JSON(map[string]any{"size": total, "cursor": cursor})
|
return c.Status(http.StatusOK).JSON(map[string]any{"size": total, "cursor": cursor})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShareAPIUpload handles one-step file upload via API token.
|
|
||||||
// PUT /api/v1/upload/:filename
|
|
||||||
// Accepts the raw file body and Content-Length header, returns the download code.
|
|
||||||
func ShareAPIUpload() 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.Request.ContentLength)
|
|
||||||
if err != nil || size <= 0 {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "Content-Length header required"})
|
|
||||||
}
|
|
||||||
|
|
||||||
code, err := controller.MetaManager.New(size, filename, c.IP())
|
|
||||||
if err != nil {
|
|
||||||
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "create upload failed"})
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _, err = controller.MetaManager.Write(code, 0, size-1, c.Request.Body)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("handler.ShareAPIUpload: write error: %s", err)
|
|
||||||
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "upload failed"})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.Status(http.StatusOK).JSON(map[string]any{
|
|
||||||
"status": 200,
|
|
||||||
"data": map[string]string{"code": code},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/loveuer/nf"
|
|
||||||
"github.com/loveuer/nf/nft/log"
|
|
||||||
"github.com/loveuer/ushare/internal/static"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ServeFrontend() nf.HandlerFunc {
|
|
||||||
assets := static.Frontend()
|
|
||||||
|
|
||||||
return func(c *nf.Ctx) error {
|
|
||||||
path := strings.TrimPrefix(c.Path(), "/")
|
|
||||||
if path == "" || path == "/" {
|
|
||||||
path = "index.html"
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := assets.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
if err.Error() == "file does not exist" {
|
|
||||||
return serveIndex(assets, c)
|
|
||||||
}
|
|
||||||
return c.SendStatus(http.StatusNotFound)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
stat, err := file.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return c.SendStatus(http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
|
|
||||||
if stat.IsDir() {
|
|
||||||
return serveIndex(assets, c)
|
|
||||||
}
|
|
||||||
|
|
||||||
io.Copy(c.Writer, file)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ServeFrontendMiddleware() nf.HandlerFunc {
|
|
||||||
assets := static.Frontend()
|
|
||||||
|
|
||||||
return func(c *nf.Ctx) error {
|
|
||||||
path := c.Path()
|
|
||||||
|
|
||||||
if strings.HasPrefix(path, "/api") || strings.HasPrefix(path, "/ushare") {
|
|
||||||
return c.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
filePath := strings.TrimPrefix(path, "/")
|
|
||||||
if filePath == "" || filePath == "/" {
|
|
||||||
filePath = "index.html"
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := assets.Open(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return serveIndex(assets, c)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
stat, err := file.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return c.SendStatus(http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
|
|
||||||
if stat.IsDir() {
|
|
||||||
return serveIndex(assets, c)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.SetHeader("Content-Type", getContentType(filePath))
|
|
||||||
io.Copy(c.Writer, file)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func serveIndex(assets fs.FS, c *nf.Ctx) error {
|
|
||||||
index, err := assets.Open("index.html")
|
|
||||||
if err != nil {
|
|
||||||
log.Error("failed to open index.html: %v", err)
|
|
||||||
return c.SendStatus(http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
defer index.Close()
|
|
||||||
|
|
||||||
c.SetHeader("Content-Type", "text/html; charset=utf-8")
|
|
||||||
io.Copy(c.Writer, index)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getContentType(path string) string {
|
|
||||||
if strings.HasSuffix(path, ".html") {
|
|
||||||
return "text/html; charset=utf-8"
|
|
||||||
}
|
|
||||||
if strings.HasSuffix(path, ".css") {
|
|
||||||
return "text/css; charset=utf-8"
|
|
||||||
}
|
|
||||||
if strings.HasSuffix(path, ".js") {
|
|
||||||
return "application/javascript; charset=utf-8"
|
|
||||||
}
|
|
||||||
if strings.HasSuffix(path, ".png") {
|
|
||||||
return "image/png"
|
|
||||||
}
|
|
||||||
if strings.HasSuffix(path, ".jpg") || strings.HasSuffix(path, ".jpeg") {
|
|
||||||
return "image/jpeg"
|
|
||||||
}
|
|
||||||
if strings.HasSuffix(path, ".svg") {
|
|
||||||
return "image/svg+xml"
|
|
||||||
}
|
|
||||||
return "application/octet-stream"
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/loveuer/nf"
|
|
||||||
"github.com/loveuer/ushare/internal/controller"
|
|
||||||
"github.com/loveuer/ushare/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TokenList() nf.HandlerFunc {
|
|
||||||
return func(c *nf.Ctx) error {
|
|
||||||
session, ok := c.Locals("user").(*model.Session)
|
|
||||||
if !ok || session == nil {
|
|
||||||
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
|
|
||||||
}
|
|
||||||
|
|
||||||
tokens, err := controller.TokenManager.List(session.UserID)
|
|
||||||
if err != nil {
|
|
||||||
return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.Status(http.StatusOK).JSON(map[string]any{"data": tokens})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TokenCreate() nf.HandlerFunc {
|
|
||||||
return func(c *nf.Ctx) error {
|
|
||||||
session, ok := c.Locals("user").(*model.Session)
|
|
||||||
if !ok || session == nil {
|
|
||||||
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
|
|
||||||
}
|
|
||||||
|
|
||||||
type Req struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var req Req
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "请求格式错误"})
|
|
||||||
}
|
|
||||||
|
|
||||||
t, rawToken, err := controller.TokenManager.Create(session.UserID, req.Name)
|
|
||||||
if err != nil {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.Status(http.StatusOK).JSON(map[string]any{
|
|
||||||
"data": map[string]any{
|
|
||||||
"id": t.ID,
|
|
||||||
"name": t.Name,
|
|
||||||
"token": rawToken,
|
|
||||||
"created_at": t.CreatedAt,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TokenDelete() nf.HandlerFunc {
|
|
||||||
return func(c *nf.Ctx) error {
|
|
||||||
session, ok := c.Locals("user").(*model.Session)
|
|
||||||
if !ok || session == nil {
|
|
||||||
return c.Status(http.StatusUnauthorized).JSON(map[string]string{"error": "unauthorized"})
|
|
||||||
}
|
|
||||||
|
|
||||||
type Req struct {
|
|
||||||
ID uint `json:"id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var req Req
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "请求格式错误"})
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.ID == 0 {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "token id 不能为空"})
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := controller.TokenManager.Delete(session.UserID, req.ID); err != nil {
|
|
||||||
return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.Status(http.StatusOK).JSON(map[string]any{"data": "ok"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
PermUserManage = "user_manage"
|
|
||||||
PermUpload = "upload"
|
|
||||||
PermTokenManage = "token_manage"
|
|
||||||
|
|
||||||
RoleAdmin = "admin"
|
|
||||||
RoleUser = "user"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Role struct {
|
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
|
||||||
Name string `gorm:"uniqueIndex;not null" json:"name"`
|
|
||||||
Label string `gorm:"not null" json:"label"`
|
|
||||||
Permissions string `gorm:"not null" json:"permissions"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Role) HasPermission(perm string) bool {
|
|
||||||
for _, p := range r.PermissionList() {
|
|
||||||
if p == perm {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Role) PermissionList() []string {
|
|
||||||
list := make([]string, 0)
|
|
||||||
for _, p := range strings.Split(r.Permissions, ",") {
|
|
||||||
p = strings.TrimSpace(p)
|
|
||||||
if p != "" {
|
|
||||||
list = append(list, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// Token is a personal API token for programmatic file upload.
|
|
||||||
// Token values are prefixed with "ust_" to distinguish them from session tokens.
|
|
||||||
type Token struct {
|
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
|
||||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
|
||||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
|
||||||
Name string `gorm:"not null" json:"name"`
|
|
||||||
Token string `gorm:"uniqueIndex;not null" json:"-"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
LastUsedAt *time.Time `json:"last_used_at"`
|
|
||||||
ExpiresAt *time.Time `json:"expires_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TokenPrefix is the prefix for all API token values.
|
|
||||||
const TokenPrefix = "ust_"
|
|
||||||
@@ -1,27 +1,10 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// User is the GORM database model for persistent user storage.
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
Id int `json:"id"`
|
||||||
Username string `gorm:"uniqueIndex;not null" json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `gorm:"not null" json:"-"`
|
Key string `json:"key"`
|
||||||
RoleID uint `gorm:"not null" json:"role_id"`
|
Password string `json:"-"`
|
||||||
Role Role `gorm:"foreignKey:RoleID" json:"role"`
|
LoginAt int64 `json:"login_at"`
|
||||||
Active bool `gorm:"default:true" json:"active"`
|
Token string `json:"token"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session is the in-memory representation of an authenticated user.
|
|
||||||
// It is created on login and stored in the UserManager session map.
|
|
||||||
type Session struct {
|
|
||||||
UserID uint `json:"user_id"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
RoleLabel string `json:"role_label"`
|
|
||||||
Permissions []string `json:"permissions"`
|
|
||||||
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"`
|
|
||||||
}
|
|
||||||
@@ -4,15 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"github.com/loveuer/nf/nft/log"
|
"github.com/loveuer/nf/nft/log"
|
||||||
"github.com/loveuer/ushare/internal/pkg/tool"
|
"github.com/loveuer/ushare/internal/pkg/tool"
|
||||||
"os"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
Debug bool
|
Debug bool
|
||||||
Address string
|
Address string
|
||||||
DataPath string
|
DataPath string
|
||||||
Username string
|
Auth string
|
||||||
Password string
|
|
||||||
CleanInterval int
|
CleanInterval int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,22 +19,8 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Init(_ context.Context) {
|
func Init(_ context.Context) {
|
||||||
if Cfg.Username == "" {
|
if Cfg.Auth != "" {
|
||||||
Cfg.Username = "admin"
|
Cfg.Auth = tool.NewPassword(Cfg.Auth)
|
||||||
}
|
log.Debug("opt.Init: encrypted password = %s", Cfg.Auth)
|
||||||
if Cfg.Password == "" {
|
|
||||||
Cfg.Password = "ushare@123"
|
|
||||||
}
|
|
||||||
|
|
||||||
Cfg.Password = tool.NewPassword(Cfg.Password)
|
|
||||||
log.Debug("opt.Init: username = %s, encrypted password = %s", Cfg.Username, Cfg.Password)
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadFromEnv() {
|
|
||||||
if username := os.Getenv("USHARE_USERNAME"); username != "" {
|
|
||||||
Cfg.Username = username
|
|
||||||
}
|
|
||||||
if password := os.Getenv("USHARE_PASSWORD"); password != "" {
|
|
||||||
Cfg.Password = password
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"au99999/internal/opt"
|
||||||
|
"au99999/pkg/tool"
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/loveuer/ushare/internal/opt"
|
|
||||||
"github.com/loveuer/ushare/internal/pkg/tool"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,10 +45,6 @@ func (c *Client) Session(ctxs ...context.Context) *gorm.DB {
|
|||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Migrate(models ...interface{}) error {
|
|
||||||
return c.cli.AutoMigrate(models...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) Close() {
|
func (c *Client) Close() {
|
||||||
d, _ := c.cli.DB()
|
d, _ := c.cli.DB()
|
||||||
d.Close()
|
d.Close()
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -3,31 +3,14 @@ package tool
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"math/big"
|
"math/big"
|
||||||
mrand "math/rand"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
letters = []byte("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
letters = []byte("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||||
letterNum = []byte("0123456789")
|
letterNum = []byte("0123456789")
|
||||||
letterLow = []byte("abcdefghijklmnopqrstuvwxyz")
|
letterLow = []byte("abcdefghijklmnopqrstuvwxyz")
|
||||||
letterCap = []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
letterCap = []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||||
letterSyb = []byte("!@#$%^&*()_+-=")
|
letterSyb = []byte("!@#$%^&*()_+-=")
|
||||||
adjectives = []string{
|
|
||||||
"开心的", "灿烂的", "温暖的", "阳光的", "活泼的",
|
|
||||||
"聪明的", "优雅的", "幸运的", "甜蜜的", "勇敢的",
|
|
||||||
"宁静的", "热情的", "温柔的", "幽默的", "坚强的",
|
|
||||||
"迷人的", "神奇的", "快乐的", "健康的", "自由的",
|
|
||||||
"梦幻的", "勤劳的", "真诚的", "浪漫的", "自信的",
|
|
||||||
}
|
|
||||||
|
|
||||||
plants = []string{
|
|
||||||
"苹果", "香蕉", "橘子", "葡萄", "草莓",
|
|
||||||
"西瓜", "樱桃", "菠萝", "柠檬", "蜜桃",
|
|
||||||
"蓝莓", "芒果", "石榴", "甜瓜", "雪梨",
|
|
||||||
"番茄", "南瓜", "土豆", "青椒", "洋葱",
|
|
||||||
"黄瓜", "萝卜", "豌豆", "玉米", "蘑菇",
|
|
||||||
"菠菜", "茄子", "芹菜", "莲藕", "西兰花",
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func RandomInt(max int64) int64 {
|
func RandomInt(max int64) int64 {
|
||||||
@@ -69,7 +52,3 @@ func RandomPassword(length int, withSymbol bool) string {
|
|||||||
}
|
}
|
||||||
return string(result)
|
return string(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func RandomName() string {
|
|
||||||
return adjectives[mrand.Intn(len(adjectives))] + plants[mrand.Intn(len(plants))]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
package static
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"io/fs"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed frontend/dist
|
|
||||||
var FrontendFS embed.FS
|
|
||||||
|
|
||||||
func Frontend() fs.FS {
|
|
||||||
sub, _ := fs.Sub(FrontendFS, "frontend/dist")
|
|
||||||
return sub
|
|
||||||
}
|
|
||||||
24
main.go
24
main.go
@@ -3,15 +3,10 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/loveuer/nf/nft/log"
|
"github.com/loveuer/nf/nft/log"
|
||||||
"github.com/loveuer/ushare/internal/api"
|
"github.com/loveuer/ushare/internal/api"
|
||||||
"github.com/loveuer/ushare/internal/controller"
|
"github.com/loveuer/ushare/internal/controller"
|
||||||
"github.com/loveuer/ushare/internal/model"
|
|
||||||
"github.com/loveuer/ushare/internal/opt"
|
"github.com/loveuer/ushare/internal/opt"
|
||||||
"github.com/loveuer/ushare/internal/pkg/db"
|
|
||||||
"github.com/loveuer/ushare/internal/pkg/tool"
|
"github.com/loveuer/ushare/internal/pkg/tool"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -21,11 +16,10 @@ func init() {
|
|||||||
flag.BoolVar(&opt.Cfg.Debug, "debug", false, "debug mode")
|
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:9119", "")
|
||||||
flag.StringVar(&opt.Cfg.DataPath, "data", "/data", "")
|
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.IntVar(&opt.Cfg.CleanInterval, "clean", 24, "清理文件的周期, 单位: 小时, 0 则表示不自动清理")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
opt.LoadFromEnv()
|
|
||||||
|
|
||||||
if opt.Cfg.Debug {
|
if opt.Cfg.Debug {
|
||||||
log.SetLogLevel(log.LogLevelDebug)
|
log.SetLogLevel(log.LogLevelDebug)
|
||||||
tool.TablePrinter(opt.Cfg)
|
tool.TablePrinter(opt.Cfg)
|
||||||
@@ -37,24 +31,8 @@ func main() {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
opt.Init(ctx)
|
opt.Init(ctx)
|
||||||
|
|
||||||
if err := os.MkdirAll(opt.Cfg.DataPath, 0755); err != nil {
|
|
||||||
log.Fatal("main: create data path failed: %s", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
dbPath := filepath.Join(opt.Cfg.DataPath, ".ushare.db")
|
|
||||||
if err := db.Init(ctx, "sqlite::"+dbPath); err != nil {
|
|
||||||
log.Fatal("main: init db failed: %s", err.Error())
|
|
||||||
}
|
|
||||||
log.Debug("main: db initialized at %s", dbPath)
|
|
||||||
|
|
||||||
if err := db.Default.Migrate(&model.Role{}, &model.User{}, &model.Token{}); err != nil {
|
|
||||||
log.Fatal("main: db migrate failed: %s", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
controller.UserManager.Start(ctx)
|
controller.UserManager.Start(ctx)
|
||||||
controller.MetaManager.Start(ctx)
|
controller.MetaManager.Start(ctx)
|
||||||
controller.RoomController.Start(ctx)
|
|
||||||
api.Start(ctx)
|
api.Start(ctx)
|
||||||
|
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
|
|||||||
49
make.sh
49
make.sh
@@ -1,49 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "=========================================="
|
|
||||||
echo " Building UShare Single Binary"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 清理旧的构建产物
|
|
||||||
echo "[Cleanup] Removing old build files..."
|
|
||||||
rm -rf dist
|
|
||||||
rm -f ushare
|
|
||||||
rm -rf internal/static/frontend
|
|
||||||
|
|
||||||
# 构建前端
|
|
||||||
echo ""
|
|
||||||
echo "[Frontend] Building..."
|
|
||||||
cd frontend
|
|
||||||
pnpm run build
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
# 复制前端构建产物到 internal/static
|
|
||||||
echo "[Frontend] Copying dist files..."
|
|
||||||
mkdir -p internal/static/frontend
|
|
||||||
cp -r frontend/dist internal/static/frontend/
|
|
||||||
|
|
||||||
# 构建后端(包含嵌入的前端文件)
|
|
||||||
echo ""
|
|
||||||
echo "[Backend] Building with embedded frontend..."
|
|
||||||
mkdir -p dist
|
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags '-s -w' -o dist/ushare .
|
|
||||||
|
|
||||||
# 清理临时文件
|
|
||||||
echo ""
|
|
||||||
echo "[Cleanup] Removing temporary files..."
|
|
||||||
rm -rf internal/static/frontend
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=========================================="
|
|
||||||
echo " Build Complete!"
|
|
||||||
echo " Binary: dist/ushare"
|
|
||||||
echo "=========================================="
|
|
||||||
echo ""
|
|
||||||
echo "Usage:"
|
|
||||||
echo " ./dist/ushare -debug -address 0.0.0.0:9119 -data ./data -auth \"admin:password\""
|
|
||||||
echo ""
|
|
||||||
echo " Development: ./dev.sh"
|
|
||||||
echo " Production: ./make.sh && ./dist/ushare ..."
|
|
||||||
149
page/bubble.html
149
page/bubble.html
@@ -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>
|
|
||||||
179
page/sender.html
179
page/sender.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