mirror of
https://github.com/keven1024/015.git
synced 2026-06-07 21:04:33 +00:00
Compare commits
125 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
759318813c | ||
|
|
a91345f39c | ||
|
|
123b1ec4fb | ||
|
|
79887d6c6c | ||
|
|
f1f10de051 | ||
|
|
80d60cc0a0 | ||
|
|
30d0abc2b5 | ||
|
|
8d3675cfa1 | ||
|
|
cb6b0fae6a | ||
|
|
6631e1e1a2 | ||
|
|
625399bdd9 | ||
|
|
10b79615b7 | ||
|
|
7c38773451 | ||
|
|
976011697c | ||
|
|
c25b41e20e | ||
|
|
3c031dcee9 | ||
|
|
d6c54de659 | ||
|
|
72ca69330f | ||
|
|
7f74441f5d | ||
|
|
28abd8d1a2 | ||
|
|
c50bb5d0bf | ||
|
|
95ab8b97da | ||
|
|
d6880dbf00 | ||
|
|
c871c55f79 | ||
|
|
82e9292b66 | ||
|
|
1d86f2bdf6 | ||
|
|
52cc89a73d | ||
|
|
af9a5b45d2 | ||
|
|
b26821a504 | ||
|
|
560387d8f1 | ||
|
|
b69af056aa | ||
|
|
707ade5dd2 | ||
|
|
b4570c5961 | ||
|
|
60a588c92a | ||
|
|
47781ff374 | ||
|
|
7760697233 | ||
|
|
8f1ced62ac | ||
|
|
72560cf233 | ||
|
|
62443f38a4 | ||
|
|
5c264a4297 | ||
|
|
332fa7195f | ||
|
|
4ce68d91a8 | ||
|
|
78cfc3943c | ||
|
|
7772a935d7 | ||
|
|
7d204345cc | ||
|
|
f9251663a2 | ||
|
|
75c269b60e | ||
|
|
64126e7c4c | ||
|
|
f8a43c9b1f | ||
|
|
9aa52915b0 | ||
|
|
fe66240671 | ||
|
|
1635a31f33 | ||
|
|
67b1d07fbe | ||
|
|
ceb2d026d2 | ||
|
|
123178084a | ||
|
|
183297bd2c | ||
|
|
55900224ae | ||
|
|
a64fa95608 | ||
|
|
70fc2be02f | ||
|
|
e14eed73c4 | ||
|
|
4a8932f921 | ||
|
|
60831c779e | ||
|
|
7663e8eb0a | ||
|
|
fe6c832275 | ||
|
|
f1dec39851 | ||
|
|
59cd23daf7 | ||
|
|
7ca301e54d | ||
|
|
6d7dc6bc40 | ||
|
|
bde4e36e47 | ||
|
|
e5b5def6f0 | ||
|
|
ed7ac4e657 | ||
|
|
22637bcf6e | ||
|
|
3d8d1ccd3f | ||
|
|
ae7db9de02 | ||
|
|
6304dffa39 | ||
|
|
2ea2d89f44 | ||
|
|
b98853ebe1 | ||
|
|
e9d0848f87 | ||
|
|
155c697e53 | ||
|
|
301dcaf8b6 | ||
|
|
8f3bafcbfb | ||
|
|
b471972dd4 | ||
|
|
8c4c7b0471 | ||
|
|
9679a42be2 | ||
|
|
e43d27958d | ||
|
|
31c0736562 | ||
|
|
185f7a3503 | ||
|
|
90607aad7c | ||
|
|
e250202deb | ||
|
|
e8653a8e8b | ||
|
|
b4a4be09fa | ||
|
|
bfe9a8f3d6 | ||
|
|
a71cc5e919 | ||
|
|
18a626fd04 | ||
|
|
ca33fcc332 | ||
|
|
85c6b4582f | ||
|
|
9eaf06e7a8 | ||
|
|
0c980e8b6c | ||
|
|
831d9b2660 | ||
|
|
dd3ced5b03 | ||
|
|
d7d2a5c00b | ||
|
|
a82c894119 | ||
|
|
d4b0368ae2 | ||
|
|
f317e0f538 | ||
|
|
e17b1a04c5 | ||
|
|
04ebb22d35 | ||
|
|
552434e389 | ||
|
|
d5fc54de31 | ||
|
|
675a6e860a | ||
|
|
18a74b6545 | ||
|
|
313ce4455f | ||
|
|
208875841e | ||
|
|
35c5e1ccec | ||
|
|
6da3d9a15d | ||
|
|
23c33fc52e | ||
|
|
4f4139159e | ||
|
|
62463494b4 | ||
|
|
c8fd8c874b | ||
|
|
70d35a1a15 | ||
|
|
9e2a6411ec | ||
|
|
d8bc68b008 | ||
|
|
d2478dd23b | ||
|
|
99538c6fdb | ||
|
|
0f0814ce40 | ||
|
|
15922ea2df |
31
.gitea/workflows/build.yaml
Normal file
31
.gitea/workflows/build.yaml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
name: Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Set build time
|
||||||
|
id: build-time
|
||||||
|
run: echo "time=$(date +%s)" >> $GITHUB_OUTPUT
|
||||||
|
- name: build-app
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: false
|
||||||
|
tags: 015-app:test
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ gitea.ref_name || github.ref_name }}
|
||||||
|
BUILD_TIME=${{ steps.build-time.outputs.time }}
|
||||||
|
- name: build-worker
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./worker/Dockerfile
|
||||||
|
push: false
|
||||||
|
tags: 015-worker:test
|
||||||
47
.gitea/workflows/lint.yaml
Normal file
47
.gitea/workflows/lint.yaml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
name: Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-front:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
cache: true
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
- name: Run frontend lint
|
||||||
|
run: pnpm lint:front
|
||||||
|
|
||||||
|
lint-backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: 'go.work'
|
||||||
|
- name: Run golangci-lint
|
||||||
|
uses: golangci/golangci-lint-action@v9
|
||||||
|
with:
|
||||||
|
skip-cache: true
|
||||||
|
working-directory: backend
|
||||||
|
|
||||||
|
lint-worker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: 'go.work'
|
||||||
|
- name: Run golangci-lint
|
||||||
|
uses: golangci/golangci-lint-action@v9
|
||||||
|
with:
|
||||||
|
skip-cache: true
|
||||||
|
working-directory: worker
|
||||||
87
.gitea/workflows/publish.yaml
Normal file
87
.gitea/workflows/publish.yaml
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
name: Publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-app:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: docker/setup-qemu-action@v3
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: fudaoyuanicu
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- uses: docker/metadata-action@v5
|
||||||
|
id: meta
|
||||||
|
with:
|
||||||
|
images: fudaoyuanicu/015-app
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=raw,value=edge,enable=${{ contains(github.ref_name, '-') }}
|
||||||
|
type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }}
|
||||||
|
- name: Set build time
|
||||||
|
id: build-time
|
||||||
|
run: |
|
||||||
|
echo "time=$(date +%s)" >> $GITHUB_OUTPUT
|
||||||
|
shell: bash
|
||||||
|
- name: build-app
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ gitea.ref_name || github.ref_name }}
|
||||||
|
BUILD_TIME=${{ steps.build-time.outputs.time }}
|
||||||
|
|
||||||
|
build-worker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: docker/setup-qemu-action@v3
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: fudaoyuanicu
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- uses: docker/metadata-action@v5
|
||||||
|
id: meta
|
||||||
|
with:
|
||||||
|
images: fudaoyuanicu/015-worker
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=raw,value=edge,enable=${{ contains(github.ref_name, '-') }}
|
||||||
|
type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }}
|
||||||
|
- name: Set build time
|
||||||
|
id: build-time
|
||||||
|
run: |
|
||||||
|
echo "time=$(date +%s)" >> $GITHUB_OUTPUT
|
||||||
|
shell: bash
|
||||||
|
- name: build-worker
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./worker/Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [build-app, build-worker]
|
||||||
|
if: ${{ !contains(github.ref_name, '-') }}
|
||||||
|
steps:
|
||||||
|
- name: Send deployment webhook
|
||||||
|
run: |
|
||||||
|
curl -X POST http://192.168.100.5:8364/v1/update \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer helloworld"
|
||||||
BIN
.github/image/0.png
vendored
BIN
.github/image/0.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 716 KiB After Width: | Height: | Size: 804 KiB |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,6 +22,7 @@ logs
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
config.yaml
|
||||||
|
|
||||||
# Serwist
|
# Serwist
|
||||||
/front/public/sw*
|
/front/public/sw*
|
||||||
|
|||||||
16
.vscode/launch.json
vendored
Normal file
16
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
// 使用 IntelliSense 了解相关属性。
|
||||||
|
// 悬停以查看现有属性的描述。
|
||||||
|
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Launch Backend",
|
||||||
|
"type": "go",
|
||||||
|
"request": "launch",
|
||||||
|
"mode": "auto",
|
||||||
|
"program": "${workspaceFolder}/backend",
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
23
Dockerfile
23
Dockerfile
@@ -5,7 +5,7 @@ FROM front-base AS front-deps
|
|||||||
RUN apk add --no-cache gcompat
|
RUN apk add --no-cache gcompat
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN corepack enable pnpm && pnpm i && pnpm --filter=015-front deploy dist
|
RUN corepack enable pnpm && pnpm i && pnpm --filter=015-front deploy dist --legacy
|
||||||
|
|
||||||
|
|
||||||
FROM front-base AS front-builder
|
FROM front-base AS front-builder
|
||||||
@@ -13,16 +13,17 @@ WORKDIR /app
|
|||||||
COPY --from=front-deps /app/dist/ .
|
COPY --from=front-deps /app/dist/ .
|
||||||
RUN corepack enable pnpm && pnpm build
|
RUN corepack enable pnpm && pnpm build
|
||||||
|
|
||||||
FROM golang:1.24.3 AS backend-builder
|
FROM golang:1.25.5 AS backend-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# Download Go modules
|
# Workspace and module manifests for cache
|
||||||
COPY backend/go.mod backend/go.sum ./
|
COPY go.work go.work.sum ./
|
||||||
RUN go env -w GO111MODULE=on && go env -w GOPROXY=https://goproxy.cn,direct && go mod download
|
COPY backend/ ./backend/
|
||||||
# Copy the source code. Note the slash at the end, as explained in
|
COPY worker/ ./worker/
|
||||||
# https://docs.docker.com/engine/reference/builder/#copy
|
COPY pkg/ ./pkg/
|
||||||
COPY backend/ .
|
RUN go env -w GO111MODULE=on && go env -w GOPROXY=https://goproxy.cn,direct && \
|
||||||
# Build
|
go mod download
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o backend
|
# Build from workspace root so pkg/utils, pkg/models, pkg/services resolve
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o backend-bin ./backend
|
||||||
|
|
||||||
|
|
||||||
FROM front-base AS runner
|
FROM front-base AS runner
|
||||||
@@ -37,7 +38,7 @@ RUN adduser --system --uid 1001 nuxtjs
|
|||||||
|
|
||||||
# Only `.output` folder is needed from the build stage
|
# Only `.output` folder is needed from the build stage
|
||||||
COPY --from=front-builder --chown=nuxtjs:nodejs /app/.output/ ./
|
COPY --from=front-builder --chown=nuxtjs:nodejs /app/.output/ ./
|
||||||
COPY --from=backend-builder /app/backend /bin/backend
|
COPY --from=backend-builder /app/backend-bin /bin/backend
|
||||||
COPY 015.sh /app/015.sh
|
COPY 015.sh /app/015.sh
|
||||||
|
|
||||||
# Change the port and host
|
# Change the port and host
|
||||||
|
|||||||
37
README-zh.md
37
README-zh.md
@@ -39,25 +39,36 @@
|
|||||||
📷 **图片处理** - 图片压缩、格式转换等处理功能
|
📷 **图片处理** - 图片压缩、格式转换等处理功能
|
||||||
🏷️ **下载控制** - 基于 JWT 的下载令牌管理系统
|
🏷️ **下载控制** - 基于 JWT 的下载令牌管理系统
|
||||||
|
|
||||||
## 截图
|
## 📸 截图预览
|
||||||
|
|
||||||
选择文件上传页面
|
| 选择文件上传页面 | 输入文本上传页面 |
|
||||||

|
|---------------------------------------|-----------------------------------------------|
|
||||||
|
|  |  |
|
||||||
|
|
||||||
输入文本上传页面
|
| 多选文件上传 | 文件上传进度热力图 |
|
||||||

|
|------------------------------------------------|---------------------------------------------------|
|
||||||
|
|  |  |
|
||||||
|
|
||||||
选择文件上传页面 - 支持多选文件上传
|
| 文件上传进度条 | 文件上传成功页面 |
|
||||||

|
|------------------------------------------------|-------------------------------------------------|
|
||||||
|
|  |  |
|
||||||
|
|
||||||
文件正在上传页面 - 类似Github的展示上传进度的文件热力图
|
## 🚀 快速开始
|
||||||

|
|
||||||
|
|
||||||
文件正在上传页面 - 类似 QbitTorrent 的展示文件上传进度的进度条
|
### Docker
|
||||||

|
|
||||||
|
1. 下载文件
|
||||||
|
- config.example.yaml
|
||||||
|
- docker-compose.yml
|
||||||
|
|
||||||
|
2. 把config.example.yaml配置完成后改为config.yaml
|
||||||
|
|
||||||
|
|
||||||
|
3. 启动
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
文件上传成功页面
|
|
||||||

|
|
||||||
|
|
||||||
## 🚀 快速开始
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -41,23 +41,17 @@ English | [中文](README-zh.md)
|
|||||||
|
|
||||||
## 📸 Screenshots
|
## 📸 Screenshots
|
||||||
|
|
||||||
File selection upload page
|
| File Selection Upload Page | Text Input Upload Page |
|
||||||

|
|---------------------------------------|-----------------------------------------------|
|
||||||
|
|  |  |
|
||||||
|
|
||||||
Text input upload page
|
| Multiple File Upload | Upload Progress Heatmap |
|
||||||

|
|------------------------------------------------|---------------------------------------------------|
|
||||||
|
|  |  |
|
||||||
|
|
||||||
File selection upload page - supports multiple file uploads
|
| Upload Progress Bar | Upload Success Page |
|
||||||

|
|------------------------------------------------|-------------------------------------------------|
|
||||||
|
|  |  |
|
||||||
File uploading page - similar to GitHub's file heatmap showing upload progress
|
|
||||||

|
|
||||||
|
|
||||||
File uploading page - similar to qBittorrent's progress bar showing file upload progress
|
|
||||||

|
|
||||||
|
|
||||||
File upload success page
|
|
||||||

|
|
||||||
|
|
||||||
## 🏗️ Technical Architecture
|
## 🏗️ Technical Architecture
|
||||||
|
|
||||||
|
|||||||
55
backend/.air.toml
Normal file
55
backend/.air.toml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
#:schema https://json.schemastore.org/any.json
|
||||||
|
|
||||||
|
root = "."
|
||||||
|
testdata_dir = "testdata"
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
args_bin = []
|
||||||
|
bin = "./tmp/main"
|
||||||
|
cmd = "go build -o ./tmp/main ."
|
||||||
|
delay = 1000
|
||||||
|
entrypoint = ["./tmp/main"]
|
||||||
|
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
||||||
|
exclude_file = []
|
||||||
|
exclude_regex = ["_test.go"]
|
||||||
|
exclude_unchanged = false
|
||||||
|
follow_symlink = false
|
||||||
|
full_bin = ""
|
||||||
|
include_dir = ["../pkg"]
|
||||||
|
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||||
|
include_file = []
|
||||||
|
kill_delay = "0s"
|
||||||
|
log = "build-errors.log"
|
||||||
|
poll = false
|
||||||
|
poll_interval = 0
|
||||||
|
post_cmd = []
|
||||||
|
pre_cmd = []
|
||||||
|
rerun = false
|
||||||
|
rerun_delay = 500
|
||||||
|
send_interrupt = false
|
||||||
|
stop_on_error = false
|
||||||
|
|
||||||
|
[color]
|
||||||
|
app = ""
|
||||||
|
build = "yellow"
|
||||||
|
main = "magenta"
|
||||||
|
runner = "green"
|
||||||
|
watcher = "cyan"
|
||||||
|
|
||||||
|
[log]
|
||||||
|
main_only = false
|
||||||
|
silent = false
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = false
|
||||||
|
|
||||||
|
[proxy]
|
||||||
|
app_port = 0
|
||||||
|
enabled = false
|
||||||
|
proxy_port = 0
|
||||||
|
|
||||||
|
[screen]
|
||||||
|
clear_on_rebuild = false
|
||||||
|
keep_scroll = true
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
FROM golang:1.24.3 AS builder
|
FROM golang:1.25.5 AS builder
|
||||||
|
|
||||||
# Set destination for COPY
|
# Set destination for COPY
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -1,56 +1,36 @@
|
|||||||
module backend
|
module backend
|
||||||
|
|
||||||
go 1.24.0
|
go 1.25.5
|
||||||
|
|
||||||
toolchain go1.24.3
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dario.cat/mergo v1.0.2
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/dustin/go-humanize v1.0.1
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
|
||||||
github.com/gorilla/sessions v1.4.0
|
github.com/gorilla/sessions v1.4.0
|
||||||
github.com/hibiken/asynq v0.25.1
|
github.com/hibiken/asynq v0.26.0
|
||||||
github.com/labstack/echo-contrib v0.17.4
|
github.com/labstack/echo/v5 v5.0.1
|
||||||
github.com/labstack/echo/v4 v4.13.4
|
|
||||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||||
github.com/redis/go-redis/v9 v9.14.0
|
github.com/samber/lo v1.53.0
|
||||||
github.com/samber/lo v1.51.0
|
|
||||||
github.com/spf13/cast v1.10.0
|
github.com/spf13/cast v1.10.0
|
||||||
github.com/spf13/viper v1.21.0
|
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.1
|
||||||
golang.org/x/crypto v0.42.0
|
golang.org/x/crypto v0.49.0
|
||||||
golang.org/x/time v0.13.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/context v1.1.2 // indirect
|
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
github.com/labstack/gommon v0.4.2 // indirect
|
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
golang.org/x/net v0.52.0 // indirect
|
||||||
golang.org/x/net v0.44.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/sys v0.36.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
golang.org/x/text v0.29.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.9 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
|
||||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
@@ -10,97 +8,57 @@ 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
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/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
|
|
||||||
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
|
|
||||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||||
github.com/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw=
|
github.com/hibiken/asynq v0.26.0 h1:1Zxr92MlDnb1Zt/QR5g2vSCqUS03i95lUfqx5X7/wrw=
|
||||||
github.com/hibiken/asynq v0.25.1/go.mod h1:pazWNOLBu0FEynQRBvHA26qdIKRSmfdIfUm4HdsLmXg=
|
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk=
|
github.com/labstack/echo/v5 v5.0.1 h1:60L7x1KMWRIJuaFqvnEHH322g+YnsMWq5Rzaeo6lcP4=
|
||||||
github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0=
|
github.com/labstack/echo/v5 v5.0.1/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
|
||||||
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
|
||||||
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
|
|
||||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
|
||||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
|
||||||
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
|
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
|
||||||
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
|
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
|
||||||
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/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
|
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||||
github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
|
||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
||||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
|
||||||
github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI=
|
|
||||||
github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
|
||||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
|
||||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
|
||||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
|
||||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
|
||||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
|
||||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
|
||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
|
||||||
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
|
|
||||||
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
|
||||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
|
||||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"backend/internal/models"
|
|
||||||
"backend/internal/utils"
|
"backend/internal/utils"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"pkg/models"
|
||||||
|
u "pkg/utils"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v5"
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetAbout(c echo.Context) error {
|
func GetAbout(c *echo.Context) error {
|
||||||
maxStorageSize, err := utils.GetFileSize(utils.GetEnv("upload.maximum"))
|
maxStorageSize, err := u.GetFileSize(u.GetEnv("upload.maximum"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
@@ -30,12 +31,12 @@ func GetAbout(c echo.Context) error {
|
|||||||
}, 0)
|
}, 0)
|
||||||
|
|
||||||
return utils.HTTPSuccessHandler(c, map[string]any{
|
return utils.HTTPSuccessHandler(c, map[string]any{
|
||||||
"bg_url": utils.GetEnv("about.bg_url"),
|
"bg_url": u.GetEnv("about.bg_url"),
|
||||||
"content": utils.GetEnvMapString("about.content"),
|
"content": u.GetEnvMap("about.content"),
|
||||||
"email": utils.GetEnv("about.email"),
|
"email": u.GetEnv("about.email"),
|
||||||
"name": utils.GetEnv("about.name"),
|
"name": u.GetEnv("about.name"),
|
||||||
"url": utils.GetEnv("about.url"),
|
"url": u.GetEnv("about.url"),
|
||||||
"avatar": utils.GetEnv("about.avatar"),
|
"avatar": u.GetEnv("about.avatar"),
|
||||||
"file": map[string]any{
|
"file": map[string]any{
|
||||||
"maximun": maxStorageSize,
|
"maximun": maxStorageSize,
|
||||||
"current": currentFileSize,
|
"current": currentFileSize,
|
||||||
|
|||||||
@@ -2,20 +2,29 @@ package controllers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"backend/internal/utils"
|
"backend/internal/utils"
|
||||||
|
u "pkg/utils"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v5"
|
||||||
|
"github.com/samber/lo"
|
||||||
"github.com/spf13/cast"
|
"github.com/spf13/cast"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetConfig(c echo.Context) error {
|
func GetConfig(c *echo.Context) error {
|
||||||
|
featureConfig := u.GetEnvMap("features")
|
||||||
|
features := lo.FilterMap(lo.Entries(featureConfig), func(e lo.Entry[string, any], _ int) (string, bool) {
|
||||||
|
node, ok := e.Value.(map[string]any)
|
||||||
|
return e.Key, ok && cast.ToBool(node["enabled"])
|
||||||
|
})
|
||||||
|
|
||||||
return utils.HTTPSuccessHandler(c, map[string]any{
|
return utils.HTTPSuccessHandler(c, map[string]any{
|
||||||
"site_title": utils.GetEnvMapString("site.title"),
|
"site_title": u.GetEnvMap("site.title"),
|
||||||
"site_desc": utils.GetEnvMapString("site.desc"),
|
"site_desc": u.GetEnvMap("site.desc"),
|
||||||
"site_url": utils.GetEnv("site.url"),
|
"site_url": u.GetEnv("site.url"),
|
||||||
"site_icon": utils.GetEnvWithDefault("site.icon", "/logo.png"),
|
"site_icon": u.GetEnvWithDefault("site.icon", "/logo.png"),
|
||||||
"site_bg_url": utils.GetEnvWithDefault("site.bg_url", "https://img.fudaoyuan.icu/api/1/random/?scale_min=1.5&webp=true&md=false&format=302"),
|
"site_bg_url": u.GetEnvWithDefault("site.bg_url", "https://img.fudaoyuan.icu/api/1/random/?scale_min=1.5&webp=true&md=false&format=302"),
|
||||||
"version": utils.GetEnvWithDefault("VERSION", "dev"),
|
"version": u.GetEnvWithDefault("VERSION", "dev"),
|
||||||
"build_time": cast.ToInt(utils.GetEnvWithDefault("BUILD_TIME", cast.ToString(time.Now().Unix()))),
|
"build_time": cast.ToInt(u.GetEnvWithDefault("BUILD_TIME", cast.ToString(time.Now().Unix()))),
|
||||||
|
"features": features,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"backend/internal/models"
|
|
||||||
"backend/internal/utils"
|
"backend/internal/utils"
|
||||||
"backend/middleware"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"pkg/models"
|
||||||
|
u "pkg/utils"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v5"
|
||||||
"github.com/spf13/cast"
|
"github.com/spf13/cast"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,31 +17,30 @@ type DownloadShareClaims struct {
|
|||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
func DownloadShare(c echo.Context) error {
|
func DownloadShare(c *echo.Context) error {
|
||||||
cc := c.(*middleware.CustomContext)
|
token := c.FormValue("token")
|
||||||
token := cc.FormValue("token")
|
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("缺少token"))
|
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
|
||||||
}
|
}
|
||||||
claims := DownloadShareClaims{}
|
claims := DownloadShareClaims{}
|
||||||
t, err := jwt.ParseWithClaims(token, &claims, func(token *jwt.Token) (interface{}, error) {
|
t, err := jwt.ParseWithClaims(token, &claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
return []byte(utils.GetEnv("share.download_secret")), nil
|
return []byte(u.GetEnv("share.download_secret")), nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
if !t.Valid {
|
if !t.Valid {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("token格式错误"))
|
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
|
||||||
}
|
}
|
||||||
shareInfo, _ := models.GetRedisShareInfo(claims.ShareId)
|
shareInfo, _ := models.GetRedisShareInfo(claims.ShareId)
|
||||||
|
|
||||||
if shareInfo.Type == models.ShareTypeFile {
|
if shareInfo.Type == models.ShareTypeFile {
|
||||||
fileInfo, _ := models.GetRedisFileInfo(shareInfo.Data)
|
fileInfo, _ := models.GetRedisFileInfo(shareInfo.Data)
|
||||||
uploadPath, err := utils.GetUploadDirPath()
|
uploadPath, err := u.GetUploadDirPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return cc.Attachment(fmt.Sprintf("%s/%s", uploadPath, utils.GetFileId(fileInfo.FileHash, fileInfo.FileSize)), shareInfo.FileName)
|
return c.Attachment(fmt.Sprintf("%s/%s", uploadPath, u.GetFileId(fileInfo.FileHash, fileInfo.FileSize)), shareInfo.FileName)
|
||||||
}
|
}
|
||||||
return utils.HTTPSuccessHandler(c, map[string]any{
|
return utils.HTTPSuccessHandler(c, map[string]any{
|
||||||
"data": shareInfo.Data,
|
"data": shareInfo.Data,
|
||||||
@@ -54,16 +52,14 @@ type VaildateShareProps struct {
|
|||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func VaildateShare(c echo.Context) error {
|
func VaildateShare(c *echo.Context) error {
|
||||||
cc := c.(*middleware.CustomContext)
|
|
||||||
|
|
||||||
r := new(VaildateShareProps)
|
r := new(VaildateShareProps)
|
||||||
if err := cc.Bind(r); err != nil {
|
if err := c.Bind(r); err != nil {
|
||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.ShareId == "" {
|
if r.ShareId == "" {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("缺少分享ID"))
|
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
shareInfo, err := models.GetRedisShareInfo(r.ShareId)
|
shareInfo, err := models.GetRedisShareInfo(r.ShareId)
|
||||||
@@ -71,25 +67,25 @@ func VaildateShare(c echo.Context) error {
|
|||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
if shareInfo == nil {
|
if shareInfo == nil {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("分享不存在"))
|
return utils.HTTPErrorHandler(c, ErrShareNotFound)
|
||||||
}
|
}
|
||||||
if shareInfo.Password != "" {
|
if shareInfo.Password != "" {
|
||||||
if r.Password == "" {
|
if r.Password == "" {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("缺少分享密码"))
|
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
|
||||||
}
|
}
|
||||||
hash, err := utils.GeneratePasswordHash(r.Password)
|
hash, err := utils.GeneratePasswordHash(r.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
if hash != shareInfo.Password {
|
if hash != shareInfo.Password {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("分享密码错误"))
|
return utils.HTTPErrorHandler(c, ErrInvalidSharePassword)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 如果下载次数为0,则设置为-1 防止空值问题
|
// 如果下载次数为0,则设置为-1 防止空值问题
|
||||||
if shareInfo.ViewNum < 1 {
|
if shareInfo.ViewNum < 1 {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("下载次数不足"))
|
return utils.HTTPErrorHandler(c, ErrInsufficientDownloadQuota)
|
||||||
}
|
}
|
||||||
downloadWindow := utils.GetEnvWithDefault("share.download_window", "12")
|
downloadWindow := u.GetEnvWithDefault("share.download_window", "12")
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, DownloadShareClaims{
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, DownloadShareClaims{
|
||||||
ShareId: r.ShareId,
|
ShareId: r.ShareId,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
@@ -98,7 +94,7 @@ func VaildateShare(c echo.Context) error {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Sign and get the complete encoded token as a string using the secret
|
// Sign and get the complete encoded token as a string using the secret
|
||||||
downloadToken, err := token.SignedString([]byte(utils.GetEnv("share.download_secret")))
|
downloadToken, err := token.SignedString([]byte(u.GetEnv("share.download_secret")))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
@@ -108,10 +104,10 @@ func VaildateShare(c echo.Context) error {
|
|||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
if fileInfo == nil {
|
if fileInfo == nil {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("分享文件不存在"))
|
return utils.HTTPErrorHandler(c, ErrShareFileNotFound)
|
||||||
}
|
}
|
||||||
if fileInfo.FileType != models.FileTypeUpload {
|
if fileInfo.FileType != models.FileTypeUpload {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("分享文件状态错误"))
|
return utils.HTTPErrorHandler(c, ErrInvalidShareFileState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// download_nums 必须放在创建token的时候减掉,不然多线程下载会导致多次减掉
|
// download_nums 必须放在创建token的时候减掉,不然多线程下载会导致多次减掉
|
||||||
@@ -120,23 +116,22 @@ func VaildateShare(c echo.Context) error {
|
|||||||
if latestViewNum < 1 {
|
if latestViewNum < 1 {
|
||||||
latestViewNum = -1
|
latestViewNum = -1
|
||||||
}
|
}
|
||||||
models.SetRedisShareInfo(r.ShareId, models.RedisShareInfo{
|
err = models.SetRedisShareInfo(r.ShareId, models.RedisShareInfo{
|
||||||
ViewNum: latestViewNum,
|
ViewNum: latestViewNum,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return utils.HTTPErrorHandler(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
// 统计分享数
|
// 统计分享数
|
||||||
currentDate := time.Now().Format("2006-01-02")
|
currentDate := time.Now().Format("2006-01-02")
|
||||||
statData, _ := models.GetRedisStat(currentDate)
|
err = models.SetRedisStat(currentDate, func(stat *models.StatData) *models.StatData {
|
||||||
if statData == nil {
|
stat.DownloadNum += 1
|
||||||
statData = &models.StatData{
|
return stat
|
||||||
FileSize: 0,
|
})
|
||||||
FileNum: 0,
|
if err != nil {
|
||||||
ShareNum: 0,
|
return utils.HTTPErrorHandler(c, err)
|
||||||
DownloadNum: 0,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
statData.DownloadNum += 1
|
|
||||||
models.SetRedisStat(currentDate, *statData)
|
|
||||||
|
|
||||||
if shareInfo.Type == models.ShareTypeFile {
|
if shareInfo.Type == models.ShareTypeFile {
|
||||||
return utils.HTTPSuccessHandler(c, map[string]any{
|
return utils.HTTPSuccessHandler(c, map[string]any{
|
||||||
|
|||||||
30
backend/internal/controllers/errors.go
Normal file
30
backend/internal/controllers/errors.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
// 通用错误(参数校验失败)
|
||||||
|
ErrInvalidRequest = errors.New("InvalidRequest") // 调用接口参数错误
|
||||||
|
|
||||||
|
// 任务相关
|
||||||
|
ErrTaskNotFound = errors.New("TaskNotFound") // 任务不存在
|
||||||
|
ErrTaskExpired = errors.New("TaskExpired") // 任务已过期
|
||||||
|
|
||||||
|
// 文件上传相关
|
||||||
|
ErrInsufficientStorage = errors.New("InsufficientStorage") // 存储空间不足
|
||||||
|
ErrUploadTaskExpired = errors.New("UploadTaskExpired") // 上传任务已过期
|
||||||
|
ErrInvalidUploadTaskState = errors.New("InvalidUploadTaskState") // 上传任务状态错误
|
||||||
|
ErrInvalidFileSliceIndex = errors.New("InvalidFileSliceIndex") // 文件切片索引错误
|
||||||
|
ErrInvalidFileSliceSize = errors.New("InvalidFileSliceSize") // 文件切片大小错误
|
||||||
|
ErrIncompleteFileSlices = errors.New("IncompleteFileSlices") // 文件切片不完整
|
||||||
|
ErrFileMD5Mismatch = errors.New("FileMD5Mismatch") // 文件MD5不一致
|
||||||
|
|
||||||
|
// 分享相关
|
||||||
|
ErrShareFileNotFound = errors.New("ShareFileNotFound") // 分享文件不存在
|
||||||
|
ErrInvalidShareFileState = errors.New("InvalidShareFileState") // 分享文件状态错误
|
||||||
|
ErrShareNotFound = errors.New("ShareNotFound") // 分享不存在
|
||||||
|
|
||||||
|
// 下载相关
|
||||||
|
ErrInvalidSharePassword = errors.New("InvalidSharePassword") // 分享密码错误
|
||||||
|
ErrInsufficientDownloadQuota = errors.New("InsufficientDownloadQuota") // 下载次数不足
|
||||||
|
)
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"backend/internal/models"
|
|
||||||
"backend/internal/services"
|
"backend/internal/services"
|
||||||
"backend/internal/utils"
|
"backend/internal/utils"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"math"
|
||||||
"fmt"
|
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"pkg/models"
|
||||||
|
s "pkg/services"
|
||||||
|
u "pkg/utils"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hibiken/asynq"
|
"github.com/labstack/echo/v5"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/spf13/cast"
|
||||||
)
|
)
|
||||||
|
|
||||||
func CreateUploadTask(c echo.Context) error {
|
func CreateUploadTask(c *echo.Context) error {
|
||||||
// cc := c.(*middleware.CustomContext)
|
// cc := c.(*middleware.CustomContext)
|
||||||
r := new(models.FileInfo)
|
r := new(models.FileInfo)
|
||||||
if err := c.Bind(r); err != nil {
|
if err := c.Bind(r); err != nil {
|
||||||
@@ -24,12 +24,23 @@ func CreateUploadTask(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if r.FileSize == 0 || r.MimeType == "" || r.FileHash == "" {
|
if r.FileSize == 0 || r.MimeType == "" || r.FileHash == "" {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("调用接口参数错误"))
|
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
fileId := u.GetFileId(r.FileHash, r.FileSize)
|
||||||
|
fileInfo, err := models.GetRedisFileInfo(fileId)
|
||||||
|
if err != nil {
|
||||||
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
fileId := utils.GetFileId(r.FileHash, r.FileSize)
|
|
||||||
fileInfo, _ := models.GetRedisFileInfo(fileId)
|
|
||||||
|
|
||||||
if fileInfo != nil {
|
if fileInfo != nil {
|
||||||
|
uploadPath, err := u.GetUploadDirPath()
|
||||||
|
if err != nil {
|
||||||
|
return utils.HTTPErrorHandler(c, err)
|
||||||
|
}
|
||||||
|
sliceList, err := services.GetFileSliceList(fileId, uploadPath)
|
||||||
|
if err != nil {
|
||||||
|
return utils.HTTPErrorHandler(c, err)
|
||||||
|
}
|
||||||
return utils.HTTPSuccessHandler(c, map[string]any{
|
return utils.HTTPSuccessHandler(c, map[string]any{
|
||||||
"size": fileInfo.FileSize,
|
"size": fileInfo.FileSize,
|
||||||
"mime_type": fileInfo.MimeType,
|
"mime_type": fileInfo.MimeType,
|
||||||
@@ -38,9 +49,10 @@ func CreateUploadTask(c echo.Context) error {
|
|||||||
"expire": fileInfo.Expire,
|
"expire": fileInfo.Expire,
|
||||||
"id": fileId,
|
"id": fileId,
|
||||||
"chunk_size": fileInfo.ChunkSize,
|
"chunk_size": fileInfo.ChunkSize,
|
||||||
|
"chunks": sliceList,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
maxStorageSize, err := utils.GetFileSize(utils.GetEnv("upload.maximum"))
|
maxStorageSize, err := u.GetFileSize(u.GetEnv("upload.maximum"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
@@ -58,7 +70,7 @@ func CreateUploadTask(c echo.Context) error {
|
|||||||
totalSize += fileInfo.FileSize
|
totalSize += fileInfo.FileSize
|
||||||
}
|
}
|
||||||
if totalSize+r.FileSize > int64(maxStorageSize) {
|
if totalSize+r.FileSize > int64(maxStorageSize) {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("存储空间不足"))
|
return utils.HTTPErrorHandler(c, ErrInsufficientStorage)
|
||||||
}
|
}
|
||||||
|
|
||||||
ChunkSize := int64(0.25 * 1024 * 1024)
|
ChunkSize := int64(0.25 * 1024 * 1024)
|
||||||
@@ -66,7 +78,7 @@ func CreateUploadTask(c echo.Context) error {
|
|||||||
for r.FileSize/ChunkSize > 1000 {
|
for r.FileSize/ChunkSize > 1000 {
|
||||||
ChunkSize *= 2
|
ChunkSize *= 2
|
||||||
}
|
}
|
||||||
uploadTaskExpire := int64(3600)
|
uploadTaskExpire := cast.ToInt64(u.GetEnvWithDefault("upload.remove_expire", "2")) * 3600
|
||||||
newFileInfo := models.RedisFileInfo{
|
newFileInfo := models.RedisFileInfo{
|
||||||
FileType: models.FileTypeInit,
|
FileType: models.FileTypeInit,
|
||||||
FileInfo: models.FileInfo{
|
FileInfo: models.FileInfo{
|
||||||
@@ -83,14 +95,7 @@ func CreateUploadTask(c echo.Context) error {
|
|||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
client := utils.GetQueueClient()
|
err = s.SetFileRemoveTask(fileId, time.Duration(uploadTaskExpire)*time.Second)
|
||||||
json, err := json.Marshal(map[string]any{
|
|
||||||
"file_id": fileId,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return utils.HTTPErrorHandler(c, err)
|
|
||||||
}
|
|
||||||
_, err = client.Enqueue(asynq.NewTask("file:remove", json), asynq.ProcessIn(time.Duration(uploadTaskExpire)*time.Second))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
@@ -112,14 +117,14 @@ type UploadFileSliceProps struct {
|
|||||||
FileSlice *multipart.FileHeader `form:"file"`
|
FileSlice *multipart.FileHeader `form:"file"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func UploadFileSlice(c echo.Context) error {
|
func UploadFileSlice(c *echo.Context) error {
|
||||||
r := new(UploadFileSliceProps)
|
r := new(UploadFileSliceProps)
|
||||||
if err := c.Bind(r); err != nil {
|
if err := c.Bind(r); err != nil {
|
||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.FileId == "" || r.FileIndex == 0 || r.FileSlice == nil {
|
if r.FileId == "" || r.FileIndex == 0 || r.FileSlice == nil {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("调用接口参数错误"))
|
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
|
||||||
}
|
}
|
||||||
fileInfo, err := models.GetRedisFileInfo(r.FileId)
|
fileInfo, err := models.GetRedisFileInfo(r.FileId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -128,18 +133,18 @@ func UploadFileSlice(c echo.Context) error {
|
|||||||
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
if fileInfo.CreatedAt+fileInfo.Expire < now {
|
if fileInfo.CreatedAt+fileInfo.Expire < now {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("上传任务已过期"))
|
return utils.HTTPErrorHandler(c, ErrUploadTaskExpired)
|
||||||
}
|
}
|
||||||
|
|
||||||
if fileInfo.FileType != models.FileTypeInit {
|
if fileInfo.FileType != models.FileTypeInit {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("上传任务状态错误"))
|
return utils.HTTPErrorHandler(c, ErrInvalidUploadTaskState)
|
||||||
}
|
}
|
||||||
if r.FileIndex > ((fileInfo.FileSize / fileInfo.ChunkSize) + 1) {
|
if r.FileIndex > ((fileInfo.FileSize / fileInfo.ChunkSize) + 1) {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("文件切片索引错误"))
|
return utils.HTTPErrorHandler(c, ErrInvalidFileSliceIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.FileSlice.Size > fileInfo.ChunkSize {
|
if r.FileSlice.Size > fileInfo.ChunkSize {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("文件切片大小错误"))
|
return utils.HTTPErrorHandler(c, ErrInvalidFileSliceSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开文件
|
// 打开文件
|
||||||
@@ -147,9 +152,14 @@ func UploadFileSlice(c echo.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close() //nolint:errcheck
|
||||||
|
|
||||||
if err := services.CreateFileSlice(file, r.FileId, r.FileIndex); err != nil {
|
uploadPath, err := u.GetUploadDirPath()
|
||||||
|
if err != nil {
|
||||||
|
return utils.HTTPErrorHandler(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := services.CreateFileSlice(r.FileId, uploadPath, file, r.FileIndex); err != nil {
|
||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,14 +172,14 @@ type FinishUploadTaskProps struct {
|
|||||||
FileId string `json:"id"`
|
FileId string `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func FinishUploadTask(c echo.Context) error {
|
func FinishUploadTask(c *echo.Context) error {
|
||||||
r := new(FinishUploadTaskProps)
|
r := new(FinishUploadTaskProps)
|
||||||
if err := c.Bind(r); err != nil {
|
if err := c.Bind(r); err != nil {
|
||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.FileId == "" {
|
if r.FileId == "" {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("文件ID不能为空"))
|
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
fileInfo, err := models.GetRedisFileInfo(r.FileId)
|
fileInfo, err := models.GetRedisFileInfo(r.FileId)
|
||||||
@@ -178,64 +188,67 @@ func FinishUploadTask(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if fileInfo.FileType != models.FileTypeInit {
|
if fileInfo.FileType != models.FileTypeInit {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("上传任务状态错误"))
|
return utils.HTTPErrorHandler(c, ErrInvalidUploadTaskState)
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
if fileInfo.CreatedAt+fileInfo.Expire < now {
|
if fileInfo.CreatedAt+fileInfo.Expire < now {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("上传任务已过期"))
|
return utils.HTTPErrorHandler(c, ErrUploadTaskExpired)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 合并文件切片
|
uploadPath, err := u.GetUploadDirPath()
|
||||||
uploadPath, _ := utils.GetUploadDirPath()
|
if err != nil {
|
||||||
slicesPath := filepath.Join(uploadPath, fmt.Sprintf("%s_%s", r.FileId, "tmp"))
|
return utils.HTTPErrorHandler(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileSliceList, err := services.GetFileSliceList(r.FileId, uploadPath)
|
||||||
|
if err != nil {
|
||||||
|
return utils.HTTPErrorHandler(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fileSliceList) != int(math.Ceil(float64(fileInfo.FileSize)/float64(fileInfo.ChunkSize))) {
|
||||||
|
return utils.HTTPErrorHandler(c, ErrIncompleteFileSlices)
|
||||||
|
}
|
||||||
|
|
||||||
// 最终合并后的文件路径
|
// 最终合并后的文件路径
|
||||||
mergeFilePath := filepath.Join(uploadPath, r.FileId)
|
mergeFilePath, err := services.MergeFileSlices(r.FileId, uploadPath)
|
||||||
if err := services.MergeFileSlices(slicesPath, mergeFilePath); err != nil {
|
if err != nil {
|
||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算文件MD5
|
// 计算文件MD5
|
||||||
file, err := os.Open(mergeFilePath)
|
file, err := os.Open(mergeFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
file.Close()
|
return utils.HTTPErrorHandler(c, err)
|
||||||
os.Remove(mergeFilePath)
|
}
|
||||||
|
defer file.Close() //nolint:errcheck
|
||||||
|
|
||||||
|
file_hash, err := u.GetFileMd5(file)
|
||||||
|
if err != nil || file_hash != fileInfo.FileHash {
|
||||||
|
defer os.Remove(mergeFilePath) //nolint:errcheck
|
||||||
|
if err == nil {
|
||||||
|
return utils.HTTPErrorHandler(c, ErrFileMD5Mismatch)
|
||||||
|
}
|
||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
file_hash, err := utils.GetFileMd5(file)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
file.Close()
|
|
||||||
os.Remove(mergeFilePath)
|
|
||||||
return utils.HTTPErrorHandler(c, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if file_hash != fileInfo.FileHash {
|
|
||||||
file.Close()
|
|
||||||
os.Remove(mergeFilePath)
|
|
||||||
return utils.HTTPErrorHandler(c, errors.New("文件MD5不一致"))
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
// 更新文件信息
|
// 更新文件信息
|
||||||
models.SetRedisFileInfo(r.FileId, models.RedisFileInfo{
|
err = models.SetRedisFileInfo(r.FileId, models.RedisFileInfo{
|
||||||
FileType: models.FileTypeUpload,
|
FileType: models.FileTypeUpload,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return utils.HTTPErrorHandler(c, err)
|
||||||
|
}
|
||||||
// 统计
|
// 统计
|
||||||
currentDate := time.Now().Format("2006-01-02")
|
currentDate := time.Now().Format("2006-01-02")
|
||||||
statData, _ := models.GetRedisStat(currentDate)
|
err = models.SetRedisStat(currentDate, func(stat *models.StatData) *models.StatData {
|
||||||
if statData == nil {
|
stat.FileSize += fileInfo.FileSize
|
||||||
statData = &models.StatData{
|
stat.FileNum += 1
|
||||||
FileSize: 0,
|
return stat
|
||||||
FileNum: 0,
|
})
|
||||||
ShareNum: 0,
|
if err != nil {
|
||||||
DownloadNum: 0,
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
statData.FileSize += fileInfo.FileSize
|
|
||||||
statData.FileNum += 1
|
|
||||||
models.SetRedisStat(currentDate, *statData)
|
|
||||||
|
|
||||||
return utils.HTTPSuccessHandler(c, map[string]any{
|
return utils.HTTPSuccessHandler(c, map[string]any{
|
||||||
"size": fileInfo.FileSize,
|
"size": fileInfo.FileSize,
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"backend/internal/models"
|
|
||||||
"backend/internal/utils"
|
"backend/internal/utils"
|
||||||
"backend/middleware"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"pkg/models"
|
||||||
|
u "pkg/utils"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hibiken/asynq"
|
"github.com/hibiken/asynq"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v5"
|
||||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||||
"github.com/spf13/cast"
|
"github.com/spf13/cast"
|
||||||
)
|
)
|
||||||
@@ -33,19 +32,19 @@ type ShareConfig struct {
|
|||||||
HasPickupCode bool `json:"has_pickup_code"`
|
HasPickupCode bool `json:"has_pickup_code"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateShareInfo(c echo.Context) error {
|
func CreateShareInfo(c *echo.Context) error {
|
||||||
cc := c.(*middleware.CustomContext)
|
owner, _ := echo.ContextGet[string](c, "auth")
|
||||||
|
|
||||||
r := new(CreateShareProps)
|
r := new(CreateShareProps)
|
||||||
if err := cc.Bind(r); err != nil {
|
if err := c.Bind(r); err != nil {
|
||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
if r.Config.ExpireAt < 1 {
|
if r.Config.ExpireAt < 1 {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("非法的分享过期时间"))
|
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
|
||||||
}
|
}
|
||||||
ExpireTime := time.Now().Add(time.Duration(r.Config.ExpireAt) * time.Minute)
|
ExpireTime := time.Now().Add(time.Duration(r.Config.ExpireAt) * time.Minute)
|
||||||
if r.Data == "" || (r.Type != models.ShareTypeFile && r.Type != models.ShareTypeText) || ExpireTime.Before(time.Now()) || r.Config.ViewNum < 1 {
|
if r.Data == "" || (r.Type != models.ShareTypeFile && r.Type != models.ShareTypeText) || ExpireTime.Before(time.Now()) || r.Config.ViewNum < 1 {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("调用接口参数错误"))
|
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := gonanoid.New()
|
id, err := gonanoid.New()
|
||||||
@@ -59,10 +58,10 @@ func CreateShareInfo(c echo.Context) error {
|
|||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
if fileInfo == nil {
|
if fileInfo == nil {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("分享文件不存在"))
|
return utils.HTTPErrorHandler(c, ErrShareFileNotFound)
|
||||||
}
|
}
|
||||||
if fileInfo.FileType != models.FileTypeUpload {
|
if fileInfo.FileType != models.FileTypeUpload {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("分享文件状态错误"))
|
return utils.HTTPErrorHandler(c, ErrInvalidShareFileState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
password := ""
|
password := ""
|
||||||
@@ -74,17 +73,20 @@ func CreateShareInfo(c echo.Context) error {
|
|||||||
password = hash
|
password = hash
|
||||||
}
|
}
|
||||||
|
|
||||||
models.SetRedisShareInfo(id, models.RedisShareInfo{
|
err = models.SetRedisShareInfo(id, models.RedisShareInfo{
|
||||||
Data: r.Data,
|
Data: r.Data,
|
||||||
Type: r.Type,
|
Type: r.Type,
|
||||||
CreatedAt: time.Now().Unix(),
|
CreatedAt: time.Now().Unix(),
|
||||||
Owner: cc.Auth.(string),
|
Owner: owner,
|
||||||
ViewNum: r.Config.ViewNum,
|
ViewNum: r.Config.ViewNum,
|
||||||
Password: password,
|
Password: password,
|
||||||
// NotifyEmail: r.Config.NotifyEmail,
|
// NotifyEmail: r.Config.NotifyEmail,
|
||||||
FileName: r.FileName,
|
FileName: r.FileName,
|
||||||
ExpireAt: ExpireTime.Unix(),
|
ExpireAt: ExpireTime.Unix(),
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return utils.HTTPErrorHandler(c, err)
|
||||||
|
}
|
||||||
var pickupCode string
|
var pickupCode string
|
||||||
if r.Config.HasPickupCode {
|
if r.Config.HasPickupCode {
|
||||||
for {
|
for {
|
||||||
@@ -106,14 +108,17 @@ func CreateShareInfo(c echo.Context) error {
|
|||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
shareIDs = append(shareIDs, id)
|
shareIDs = append(shareIDs, id)
|
||||||
models.SetRedisFileShareRelational(r.Data, shareIDs)
|
err = models.SetRedisFileShareRelational(r.Data, shareIDs)
|
||||||
client := utils.GetQueueClient()
|
if err != nil {
|
||||||
|
return utils.HTTPErrorHandler(c, err)
|
||||||
|
}
|
||||||
|
client := u.GetQueueClient()
|
||||||
json, err := json.Marshal(map[string]any{"share_id": id, "file_id": r.Data})
|
json, err := json.Marshal(map[string]any{"share_id": id, "file_id": r.Data})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
// 这里延时分享过期时间基础上加下载窗口期后1小时删除,防止用户过期前几分钟才开始下载,下载一半文件不见了
|
// 这里延时分享过期时间基础上加下载窗口期后1小时删除,防止用户过期前几分钟才开始下载,下载一半文件不见了
|
||||||
downloadWindow := utils.GetEnvWithDefault("share.download_window", "12")
|
downloadWindow := u.GetEnvWithDefault("share.download_window", "12")
|
||||||
deleteTime := time.Duration(r.Config.ExpireAt)*time.Minute + cast.ToDuration(downloadWindow+"h") + 1*time.Hour
|
deleteTime := time.Duration(r.Config.ExpireAt)*time.Minute + cast.ToDuration(downloadWindow+"h") + 1*time.Hour
|
||||||
_, err = client.Enqueue(asynq.NewTask("share:remove", json), asynq.ProcessIn(deleteTime))
|
_, err = client.Enqueue(asynq.NewTask("share:remove", json), asynq.ProcessIn(deleteTime))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -123,17 +128,13 @@ func CreateShareInfo(c echo.Context) error {
|
|||||||
|
|
||||||
// 统计分享数
|
// 统计分享数
|
||||||
currentDate := time.Now().Format("2006-01-02")
|
currentDate := time.Now().Format("2006-01-02")
|
||||||
statData, _ := models.GetRedisStat(currentDate)
|
err = models.SetRedisStat(currentDate, func(stat *models.StatData) *models.StatData {
|
||||||
if statData == nil {
|
stat.ShareNum += 1
|
||||||
statData = &models.StatData{
|
return stat
|
||||||
FileSize: 0,
|
})
|
||||||
FileNum: 0,
|
if err != nil {
|
||||||
ShareNum: 0,
|
return utils.HTTPErrorHandler(c, err)
|
||||||
DownloadNum: 0,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
statData.ShareNum += 1
|
|
||||||
models.SetRedisStat(currentDate, *statData)
|
|
||||||
|
|
||||||
return utils.HTTPSuccessHandler(c, map[string]any{
|
return utils.HTTPSuccessHandler(c, map[string]any{
|
||||||
"id": id,
|
"id": id,
|
||||||
@@ -148,11 +149,10 @@ type GetShareProps struct {
|
|||||||
ShareId string `param:"id"`
|
ShareId string `param:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetShareInfo(c echo.Context) error {
|
func GetShareInfo(c *echo.Context) error {
|
||||||
cc := c.(*middleware.CustomContext)
|
shareId := c.Param("id")
|
||||||
shareId := cc.Param("id")
|
|
||||||
if shareId == "" {
|
if shareId == "" {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("缺少分享ID"))
|
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
shareInfo, err := models.GetRedisShareInfo(shareId)
|
shareInfo, err := models.GetRedisShareInfo(shareId)
|
||||||
@@ -160,7 +160,7 @@ func GetShareInfo(c echo.Context) error {
|
|||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
if shareInfo == nil || shareInfo.ViewNum < 1 {
|
if shareInfo == nil || shareInfo.ViewNum < 1 {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("分享不存在"))
|
return utils.HTTPErrorHandler(c, ErrShareNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
if shareInfo.Type == models.ShareTypeFile {
|
if shareInfo.Type == models.ShareTypeFile {
|
||||||
@@ -169,10 +169,10 @@ func GetShareInfo(c echo.Context) error {
|
|||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
if fileInfo == nil {
|
if fileInfo == nil {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("分享文件不存在"))
|
return utils.HTTPErrorHandler(c, ErrShareFileNotFound)
|
||||||
}
|
}
|
||||||
if fileInfo.FileType != models.FileTypeUpload {
|
if fileInfo.FileType != models.FileTypeUpload {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("分享文件状态错误"))
|
return utils.HTTPErrorHandler(c, ErrInvalidShareFileState)
|
||||||
}
|
}
|
||||||
return utils.HTTPSuccessHandler(c, map[string]any{
|
return utils.HTTPSuccessHandler(c, map[string]any{
|
||||||
"id": shareId,
|
"id": shareId,
|
||||||
@@ -198,18 +198,17 @@ func GetShareInfo(c echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetShareByPickupCode(c echo.Context) error {
|
func GetShareByPickupCode(c *echo.Context) error {
|
||||||
cc := c.(*middleware.CustomContext)
|
pickupCode := c.Param("code")
|
||||||
pickupCode := cc.Param("code")
|
|
||||||
if pickupCode == "" {
|
if pickupCode == "" {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("缺少提取码"))
|
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
|
||||||
}
|
}
|
||||||
shareId, err := models.GetRedisPickupData(strings.ToUpper(pickupCode))
|
shareId, err := models.GetRedisPickupData(strings.ToUpper(pickupCode))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
if shareId == "" {
|
if shareId == "" {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("分享不存在"))
|
return utils.HTTPErrorHandler(c, ErrShareNotFound)
|
||||||
}
|
}
|
||||||
return utils.HTTPSuccessHandler(c, map[string]any{
|
return utils.HTTPSuccessHandler(c, map[string]any{
|
||||||
"share_id": shareId,
|
"share_id": shareId,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"backend/internal/models"
|
|
||||||
"backend/internal/utils"
|
"backend/internal/utils"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"pkg/models"
|
||||||
|
u "pkg/utils"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -26,7 +27,7 @@ type QueueChartData struct {
|
|||||||
Failed int `json:"failed"`
|
Failed int `json:"failed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetStat(c echo.Context) error {
|
func GetStat(c *echo.Context) error {
|
||||||
statInfoMap, err := models.GetRedisStatAll()
|
statInfoMap, err := models.GetRedisStatAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
@@ -47,7 +48,7 @@ func GetStat(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
queueInspector := utils.GetQueueInspector()
|
queueInspector := u.GetQueueInspector()
|
||||||
queues, err := queueInspector.History("default", QueueHistoryDays)
|
queues, err := queueInspector.History("default", QueueHistoryDays)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
|
|||||||
@@ -1,38 +1,36 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"backend/internal/models"
|
"backend/internal/controllers/task"
|
||||||
"backend/internal/utils"
|
"backend/internal/utils"
|
||||||
"backend/middleware"
|
"pkg/models"
|
||||||
"encoding/json"
|
u "pkg/utils"
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/hibiken/asynq"
|
"github.com/hibiken/asynq"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type GenCompressImageRequest struct {
|
var handleTaskMap = map[string]func(c *echo.Context) ([]byte, error){
|
||||||
FileId string `json:"file_id"`
|
"image:compress": task.HandleImageCompress,
|
||||||
|
"image:convert": task.HandleImageConvert,
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenCompressImage(c echo.Context) error {
|
func CreateTask(c *echo.Context) error {
|
||||||
cc := c.(*middleware.CustomContext)
|
taskType := c.Param("type")
|
||||||
r := new(GenCompressImageRequest)
|
if taskType == "" {
|
||||||
if err := cc.Bind(r); err != nil {
|
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
|
||||||
return utils.HTTPErrorHandler(c, err)
|
|
||||||
}
|
}
|
||||||
|
handleTask, ok := handleTaskMap[taskType]
|
||||||
if r.FileId == "" {
|
if !ok {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("调用接口参数错误"))
|
return utils.HTTPErrorHandler(c, ErrTaskNotFound)
|
||||||
}
|
}
|
||||||
client := utils.GetQueueClient()
|
json, err := handleTask(c)
|
||||||
json, err := json.Marshal(map[string]any{
|
|
||||||
"file_id": r.FileId,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
info, err := client.Enqueue(asynq.NewTask("image:compress", json), asynq.MaxRetry(3))
|
|
||||||
|
client := u.GetQueueClient()
|
||||||
|
info, err := client.Enqueue(asynq.NewTask(taskType, json), asynq.MaxRetry(3))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
@@ -42,11 +40,10 @@ func GenCompressImage(c echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCompressImage(c echo.Context) error {
|
func GetTask(c *echo.Context) error {
|
||||||
cc := c.(*middleware.CustomContext)
|
taskId := c.Param("id")
|
||||||
taskId := cc.Param("id")
|
|
||||||
if taskId == "" {
|
if taskId == "" {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("调用接口参数错误"))
|
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
taskInfo, err := models.GetRedisTaskInfo(taskId)
|
taskInfo, err := models.GetRedisTaskInfo(taskId)
|
||||||
@@ -54,11 +51,11 @@ func GetCompressImage(c echo.Context) error {
|
|||||||
return utils.HTTPErrorHandler(c, err)
|
return utils.HTTPErrorHandler(c, err)
|
||||||
}
|
}
|
||||||
if taskInfo == nil {
|
if taskInfo == nil {
|
||||||
client := utils.GetQueueInspector()
|
client := u.GetQueueInspector()
|
||||||
|
|
||||||
queneTaskInfo, err := client.GetTaskInfo("default", taskId)
|
queneTaskInfo, err := client.GetTaskInfo("default", taskId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return utils.HTTPErrorHandler(c, errors.New("任务已过期"))
|
return utils.HTTPErrorHandler(c, ErrTaskExpired)
|
||||||
}
|
}
|
||||||
stateMap := map[asynq.TaskState]string{
|
stateMap := map[asynq.TaskState]string{
|
||||||
asynq.TaskStateActive: "processing",
|
asynq.TaskStateActive: "processing",
|
||||||
7
backend/internal/controllers/task/errors.go
Normal file
7
backend/internal/controllers/task/errors.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidRequest = errors.New("InvalidRequest")
|
||||||
|
)
|
||||||
53
backend/internal/controllers/task/image.go
Normal file
53
backend/internal/controllers/task/image.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BaseImageRequest struct {
|
||||||
|
FileId string `json:"file_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleImageCompress(c *echo.Context) ([]byte, error) {
|
||||||
|
r := new(BaseImageRequest)
|
||||||
|
if err := c.Bind(r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if r.FileId == "" {
|
||||||
|
return nil, ErrInvalidRequest
|
||||||
|
}
|
||||||
|
json, err := json.Marshal(map[string]any{
|
||||||
|
"file_id": r.FileId,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return json, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageConvertRequest struct {
|
||||||
|
BaseImageRequest
|
||||||
|
TargetExt string `json:"target_ext"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleImageConvert(c *echo.Context) ([]byte, error) {
|
||||||
|
r := new(ImageConvertRequest)
|
||||||
|
if err := c.Bind(r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if r.FileId == "" || r.TargetExt == "" {
|
||||||
|
return nil, ErrInvalidRequest
|
||||||
|
}
|
||||||
|
json, err := json.Marshal(map[string]any{
|
||||||
|
"file_id": r.FileId,
|
||||||
|
"target_ext": r.TargetExt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return json, nil
|
||||||
|
}
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"backend/internal/utils"
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"dario.cat/mergo"
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 统计数据结构
|
|
||||||
type StatData struct {
|
|
||||||
FileSize int64 `json:"file_size"` // 文件大小
|
|
||||||
FileNum int64 `json:"file_num"` // 文件数量
|
|
||||||
ShareNum int64 `json:"share_num"` // 分享数量
|
|
||||||
DownloadNum int64 `json:"download_num"` // 下载数量
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetRedisStat(key string) (*StatData, error) {
|
|
||||||
rdb, ctx := utils.GetRedisClient()
|
|
||||||
statUnmarshalData, err := rdb.HGet(ctx, "015:stat", key).Result()
|
|
||||||
if err == redis.Nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var stat StatData
|
|
||||||
if err := json.Unmarshal([]byte(statUnmarshalData), &stat); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &stat, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SetRedisStat(key string, stat StatData) error {
|
|
||||||
rdb, ctx := utils.GetRedisClient()
|
|
||||||
old_stat, err := GetRedisStat(key)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if old_stat != nil {
|
|
||||||
mergo.Merge(&stat, old_stat)
|
|
||||||
}
|
|
||||||
jsonData, _ := json.Marshal(stat)
|
|
||||||
_, err = rdb.HSet(ctx, "015:stat", key, string(jsonData)).Result()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetRedisStatAll() (map[string]string, error) {
|
|
||||||
rdb, ctx := utils.GetRedisClient()
|
|
||||||
return rdb.HGetAll(ctx, "015:stat").Result()
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"backend/internal/utils"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetRedisTaskInfo(taskId string) (*map[string]any, error) {
|
|
||||||
rdb, ctx := utils.GetRedisClient()
|
|
||||||
taskInfo := rdb.Get(ctx, fmt.Sprintf("015:taskInfoMap:%s", taskId))
|
|
||||||
taskInfoUnmarshalData, err := taskInfo.Result()
|
|
||||||
if err == redis.Nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var taskInfoData map[string]any
|
|
||||||
|
|
||||||
if err := json.Unmarshal([]byte(taskInfoUnmarshalData), &taskInfoData); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &taskInfoData, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SetRedisTaskInfo(taskId string, taskInfo map[string]any) error {
|
|
||||||
rdb, ctx := utils.GetRedisClient()
|
|
||||||
jsonData, _ := json.Marshal(taskInfo)
|
|
||||||
_, err := rdb.Set(ctx, fmt.Sprintf("015:taskInfoMap:%s", taskId), jsonData, time.Hour).Result()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,89 +1,89 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"backend/internal/utils"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
func CreateFileSlice(fileSlice io.Reader, fileId string, fileIndex int64) error {
|
func CreateFileSlice(fileId string, uploadPath string, fileSlice io.Reader, fileIndex int64) (string, error) {
|
||||||
uploadPath, err := utils.GetUploadDirPath()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
filePath := filepath.Join(uploadPath, fmt.Sprintf("%s_%s", fileId, "tmp"))
|
filePath := filepath.Join(uploadPath, fmt.Sprintf("%s_%s", fileId, "tmp"))
|
||||||
if err := os.MkdirAll(filePath, 0755); err != nil {
|
if err := os.MkdirAll(filePath, 0755); err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
dst, err := os.Create(filepath.Join(filePath, fmt.Sprintf("%d", fileIndex)))
|
dst, err := os.Create(filepath.Join(filePath, fmt.Sprintf("%d", fileIndex)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
defer dst.Close()
|
defer dst.Close() //nolint:errcheck
|
||||||
|
|
||||||
if _, err = io.Copy(dst, fileSlice); err != nil {
|
if _, err = io.Copy(dst, fileSlice); err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return filePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MergeFileSlices 合并文件切片
|
func GetFileSliceList(fileId string, uploadPath string) ([]int, error) {
|
||||||
func MergeFileSlices(slicesPath string, mergeFilePath string) error {
|
slicesPath := filepath.Join(uploadPath, fmt.Sprintf("%s_%s", fileId, "tmp"))
|
||||||
// 创建最终文件
|
|
||||||
destFile, err := os.Create(mergeFilePath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("创建合并文件失败: %v", err)
|
|
||||||
}
|
|
||||||
defer destFile.Close()
|
|
||||||
|
|
||||||
// 读取目录下的所有文件
|
|
||||||
files, err := os.ReadDir(slicesPath)
|
files, err := os.ReadDir(slicesPath)
|
||||||
if err != nil {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
return fmt.Errorf("读取切片目录失败: %v", err)
|
return nil, fmt.Errorf("读取切片目录失败: %v", err)
|
||||||
}
|
}
|
||||||
|
fileSliceList := []int{}
|
||||||
// 按照索引排序文件切片
|
|
||||||
sliceFiles := make([]string, len(files))
|
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
index, err := strconv.Atoi(file.Name())
|
index, err := strconv.Atoi(file.Name())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("无效的切片文件名: %v", err)
|
return nil, fmt.Errorf("无效的切片文件名: %v", err)
|
||||||
}
|
}
|
||||||
sliceFiles[index-1] = filepath.Join(slicesPath, file.Name())
|
fileSliceList = append(fileSliceList, index)
|
||||||
|
}
|
||||||
|
sort.Ints(fileSliceList)
|
||||||
|
return fileSliceList, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeFileSlices 合并文件切片
|
||||||
|
func MergeFileSlices(fileId string, uploadPath string) (string, error) {
|
||||||
|
mergeFilePath := filepath.Join(uploadPath, fileId)
|
||||||
|
slicesPath := filepath.Join(uploadPath, fmt.Sprintf("%s_%s", fileId, "tmp"))
|
||||||
|
defer os.RemoveAll(slicesPath) //nolint:errcheck
|
||||||
|
// 创建最终文件
|
||||||
|
destFile, err := os.Create(mergeFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("创建合并文件失败: %v", err)
|
||||||
|
}
|
||||||
|
defer destFile.Close() //nolint:errcheck
|
||||||
|
|
||||||
|
fileSliceList, err := GetFileSliceList(fileId, uploadPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 合并文件
|
// 合并文件
|
||||||
buffer := make([]byte, 4*1024*1024) // 4MB buffer
|
buffer := make([]byte, 4*1024*1024) // 4MB buffer
|
||||||
for _, sliceFile := range sliceFiles {
|
for _, index := range fileSliceList {
|
||||||
sf, err := os.Open(sliceFile)
|
sliceFilePath := filepath.Join(slicesPath, fmt.Sprintf("%d", index))
|
||||||
|
sf, err := os.Open(sliceFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("打开切片文件失败: %v", err)
|
return "", fmt.Errorf("打开切片文件失败: %v", err)
|
||||||
}
|
}
|
||||||
|
defer sf.Close() //nolint:errcheck
|
||||||
for {
|
for {
|
||||||
n, err := sf.Read(buffer)
|
n, err := sf.Read(buffer)
|
||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sf.Close()
|
return "", fmt.Errorf("读取切片文件失败: %v", err)
|
||||||
return fmt.Errorf("读取切片文件失败: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := destFile.Write(buffer[:n]); err != nil {
|
if _, err := destFile.Write(buffer[:n]); err != nil {
|
||||||
sf.Close()
|
return "", fmt.Errorf("写入合并文件失败: %v", err)
|
||||||
return fmt.Errorf("写入合并文件失败: %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sf.Close()
|
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(slicesPath)
|
return mergeFilePath, nil
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
)
|
|
||||||
|
|
||||||
var v *viper.Viper
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
InitEnv()
|
|
||||||
}
|
|
||||||
|
|
||||||
func InitEnv() {
|
|
||||||
if v != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
v = viper.New()
|
|
||||||
v.SetConfigName("config.yaml")
|
|
||||||
v.SetConfigType("yaml")
|
|
||||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
|
||||||
v.AddConfigPath(".")
|
|
||||||
v.AddConfigPath("../")
|
|
||||||
v.AutomaticEnv()
|
|
||||||
v.WatchConfig()
|
|
||||||
err := v.ReadInConfig()
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
// if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
|
||||||
// // 只有当错误不是"配置文件未找到"时才 panic
|
|
||||||
// panic(err)
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetEnv(key string) string {
|
|
||||||
InitEnv()
|
|
||||||
return v.GetString(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetEnvWithDefault(key string, defaultValue string) string {
|
|
||||||
value := v.GetString(key)
|
|
||||||
if value == "" {
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetEnvMapString(key string) map[string]string {
|
|
||||||
InitEnv()
|
|
||||||
return v.GetStringMapString(key)
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"crypto/md5"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
humanize "github.com/dustin/go-humanize"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetFileId(fileHash string, fileSize int64) string {
|
|
||||||
return fmt.Sprintf("%s_%d", fileHash, fileSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetFileMd5(file io.Reader) (string, error) {
|
|
||||||
|
|
||||||
const bufferSize = 1024 * 1000 // 1MB
|
|
||||||
|
|
||||||
hash := md5.New()
|
|
||||||
for buf, reader := make([]byte, bufferSize), bufio.NewReader(file); ; {
|
|
||||||
n, err := reader.Read(buf)
|
|
||||||
if err != nil {
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
hash.Write(buf[:n])
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%x", hash.Sum(nil)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetUploadDirPath() (string, error) {
|
|
||||||
basepath, err := os.Getwd()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
finalPath := filepath.Join(basepath, "uploads")
|
|
||||||
uploadPath := GetEnvWithDefault("upload.path", finalPath)
|
|
||||||
if err := os.MkdirAll(uploadPath, 0755); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return uploadPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetFileSize(size string) (uint64, error) {
|
|
||||||
return humanize.ParseBytes(size)
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ package utils
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Option interface {
|
type Option interface {
|
||||||
@@ -36,7 +36,7 @@ func (o WithData) applyTo(props *HTTPBaseResponse) {
|
|||||||
props.data = o
|
props.data = o
|
||||||
}
|
}
|
||||||
|
|
||||||
func HTTPBaseHandler(c echo.Context, options ...Option) error {
|
func HTTPBaseHandler(c *echo.Context, options ...Option) error {
|
||||||
props := HTTPBaseResponse{code: http.StatusOK, message: "success", data: map[string]any{}}
|
props := HTTPBaseResponse{code: http.StatusOK, message: "success", data: map[string]any{}}
|
||||||
for _, option := range options {
|
for _, option := range options {
|
||||||
option.applyTo(&props)
|
option.applyTo(&props)
|
||||||
@@ -49,10 +49,10 @@ func HTTPBaseHandler(c echo.Context, options ...Option) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func HTTPSuccessHandler(c echo.Context, data map[string]any, options ...HTTPBaseResponseProps) error {
|
func HTTPSuccessHandler(c *echo.Context, data map[string]any, options ...HTTPBaseResponseProps) error {
|
||||||
return HTTPBaseHandler(c, WithData(data))
|
return HTTPBaseHandler(c, WithData(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
func HTTPErrorHandler(c echo.Context, err error, options ...HTTPBaseResponseProps) error {
|
func HTTPErrorHandler(c *echo.Context, err error, options ...HTTPBaseResponseProps) error {
|
||||||
return HTTPBaseHandler(c, WithMessage(err.Error()), WithCode(http.StatusBadRequest))
|
return HTTPBaseHandler(c, WithMessage(err.Error()), WithCode(http.StatusBadRequest))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,19 @@ package utils
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"pkg/utils"
|
||||||
|
|
||||||
"golang.org/x/crypto/argon2"
|
"golang.org/x/crypto/argon2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrPasswordSaltNotSet = errors.New("PasswordSaltNotSet")
|
||||||
|
)
|
||||||
|
|
||||||
func GeneratePasswordHash(password string) (string, error) {
|
func GeneratePasswordHash(password string) (string, error) {
|
||||||
salt := GetEnv("share.password_salt")
|
salt := utils.GetEnv("share.password_salt")
|
||||||
if salt == "" {
|
if salt == "" {
|
||||||
return "", errors.New("请配置PASSWORD_SALT")
|
return "", ErrPasswordSaltNotSet
|
||||||
}
|
}
|
||||||
hash := argon2.IDKey([]byte(password), []byte(salt), 1, 64*1024, 4, 32)
|
hash := argon2.IDKey([]byte(password), []byte(salt), 1, 64*1024, 4, 32)
|
||||||
return fmt.Sprintf("%x", hash), nil
|
return fmt.Sprintf("%x", hash), nil
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
)
|
|
||||||
|
|
||||||
var rdb *redis.Client = InitRedis()
|
|
||||||
var ctx = context.Background()
|
|
||||||
|
|
||||||
func InitRedis() *redis.Client {
|
|
||||||
opt, err := redis.ParseURL(GetEnv("redis.url"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return redis.NewClient(opt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetRedisClient() (*redis.Client, context.Context) {
|
|
||||||
return rdb, ctx
|
|
||||||
}
|
|
||||||
16
backend/internal/utils/session.go
Normal file
16
backend/internal/utils/session.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetSession(c *echo.Context, name string) (*sessions.Session, error) {
|
||||||
|
store, err := echo.ContextGet[sessions.Store](c, "_session_store")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get session store: %w", err)
|
||||||
|
}
|
||||||
|
return store.Get(c.Request(), name)
|
||||||
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"backend/internal/controllers"
|
|
||||||
"backend/internal/utils"
|
|
||||||
"backend/middleware"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"pkg/utils"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v5"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,30 +16,18 @@ func main() {
|
|||||||
} else {
|
} else {
|
||||||
logger, _ = zap.NewDevelopment()
|
logger, _ = zap.NewDevelopment()
|
||||||
}
|
}
|
||||||
defer logger.Sync()
|
defer logger.Sync() //nolint:errcheck
|
||||||
zap.ReplaceGlobals(logger)
|
zap.ReplaceGlobals(logger)
|
||||||
|
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
e.Use(middleware.ContextMiddleware())
|
for _, middleware := range middlewares {
|
||||||
e.Use(middleware.SessionMiddleware())
|
e.Use(middleware())
|
||||||
e.Use(middleware.AuthMiddleware())
|
}
|
||||||
e.Use(middleware.RateLimiterMiddleware())
|
|
||||||
e.Use(middleware.LoggerMiddleware())
|
|
||||||
|
|
||||||
e.POST("/file/create", controllers.CreateUploadTask)
|
for _, route := range routes {
|
||||||
e.POST("/file/slice", controllers.UploadFileSlice)
|
e.Match(route.Method, route.Path, route.Handler)
|
||||||
e.POST("/file/finish", controllers.FinishUploadTask)
|
}
|
||||||
e.GET("/share/:id", controllers.GetShareInfo)
|
if err := e.Start(fmt.Sprintf(":%s", utils.GetEnvWithDefault("api.port", "5001"))); err != nil {
|
||||||
e.POST("/share", controllers.CreateShareInfo)
|
logger.Fatal("server failed", zap.Error(err))
|
||||||
e.GET("/download", controllers.DownloadShare)
|
}
|
||||||
e.POST("/download", controllers.VaildateShare)
|
|
||||||
e.GET("/share/pickup/:code", controllers.GetShareByPickupCode)
|
|
||||||
|
|
||||||
e.POST("/image/compress", controllers.GenCompressImage)
|
|
||||||
e.GET("/image/compress/:id", controllers.GetCompressImage)
|
|
||||||
|
|
||||||
e.GET("/stat", controllers.GetStat)
|
|
||||||
e.GET("/config", controllers.GetConfig)
|
|
||||||
e.GET("/about", controllers.GetAbout)
|
|
||||||
e.Logger.Fatal(e.Start(fmt.Sprintf(":%s", utils.GetEnvWithDefault("api.port", "5001"))))
|
|
||||||
}
|
}
|
||||||
|
|||||||
14
backend/middleware.go
Normal file
14
backend/middleware.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/middleware"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
var middlewares = []func() echo.MiddlewareFunc{
|
||||||
|
middleware.SessionMiddleware,
|
||||||
|
middleware.AuthMiddleware,
|
||||||
|
middleware.RateLimiterMiddleware,
|
||||||
|
middleware.LoggerMiddleware,
|
||||||
|
}
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"backend/internal/utils"
|
||||||
|
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
"github.com/labstack/echo-contrib/session"
|
"github.com/labstack/echo/v5"
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CustomMiddleware 创建自定义中间件
|
// CustomMiddleware 创建自定义中间件
|
||||||
func AuthMiddleware() echo.MiddlewareFunc {
|
func AuthMiddleware() echo.MiddlewareFunc {
|
||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c *echo.Context) error {
|
||||||
sess, err := session.Get("session", c)
|
sess, err := utils.GetSession(c, "session")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -30,11 +31,8 @@ func AuthMiddleware() echo.MiddlewareFunc {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
c.Set("auth", sess.Values["auth"])
|
||||||
cc := c.(*CustomContext)
|
return next(c)
|
||||||
cc.Auth = sess.Values["auth"]
|
|
||||||
// 将自定义上下文传递给下一个处理器
|
|
||||||
return next(cc)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CustomContext 扩展 echo.Context 以添加自定义功能
|
|
||||||
type CustomContext struct {
|
|
||||||
echo.Context
|
|
||||||
Auth interface{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCustomContext 创建自定义上下文的构造函数
|
|
||||||
func NewCustomContext(c echo.Context) *CustomContext {
|
|
||||||
return &CustomContext{
|
|
||||||
Context: c,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContextMiddleware 中间件用于将标准 echo.Context 转换为 CustomContext
|
|
||||||
func ContextMiddleware() echo.MiddlewareFunc {
|
|
||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
||||||
return func(c echo.Context) error {
|
|
||||||
cc := NewCustomContext(c)
|
|
||||||
return next(cc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v5"
|
||||||
"github.com/labstack/echo/v4/middleware"
|
"github.com/labstack/echo/v5/middleware"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ func LoggerMiddleware() echo.MiddlewareFunc {
|
|||||||
return middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
|
return middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
|
||||||
LogURI: true,
|
LogURI: true,
|
||||||
LogStatus: true,
|
LogStatus: true,
|
||||||
LogValuesFunc: func(c echo.Context, v middleware.RequestLoggerValues) error {
|
LogValuesFunc: func(c *echo.Context, v middleware.RequestLoggerValues) error {
|
||||||
zap.L().Info("request",
|
zap.L().Info("request",
|
||||||
zap.String("url", v.URI),
|
zap.String("url", v.URI),
|
||||||
zap.Int("status", v.Status),
|
zap.Int("status", v.Status),
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v5"
|
||||||
echo_middleware "github.com/labstack/echo/v4/middleware"
|
echo_middleware "github.com/labstack/echo/v5/middleware"
|
||||||
"golang.org/x/time/rate"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type RateSkiper struct {
|
type RateSkiper struct {
|
||||||
@@ -23,22 +22,22 @@ var RateSkipList = []RateSkiper{
|
|||||||
|
|
||||||
func RateLimiterMiddleware() echo.MiddlewareFunc {
|
func RateLimiterMiddleware() echo.MiddlewareFunc {
|
||||||
config := echo_middleware.RateLimiterConfig{
|
config := echo_middleware.RateLimiterConfig{
|
||||||
Skipper: func(e echo.Context) bool {
|
Skipper: func(e *echo.Context) bool {
|
||||||
path := e.Path()
|
path := e.Path()
|
||||||
r := e.Request()
|
r := e.Request()
|
||||||
return slices.Contains(RateSkipList, RateSkiper{Path: path, Method: r.Method})
|
return slices.Contains(RateSkipList, RateSkiper{Path: path, Method: r.Method})
|
||||||
},
|
},
|
||||||
Store: echo_middleware.NewRateLimiterMemoryStoreWithConfig(
|
Store: echo_middleware.NewRateLimiterMemoryStoreWithConfig(
|
||||||
echo_middleware.RateLimiterMemoryStoreConfig{Rate: rate.Limit(10), Burst: 30, ExpiresIn: 3 * time.Minute},
|
echo_middleware.RateLimiterMemoryStoreConfig{Rate: 10, Burst: 30, ExpiresIn: 3 * time.Minute},
|
||||||
),
|
),
|
||||||
IdentifierExtractor: func(ctx echo.Context) (string, error) {
|
IdentifierExtractor: func(ctx *echo.Context) (string, error) {
|
||||||
id := ctx.RealIP()
|
id := ctx.RealIP()
|
||||||
return id, nil
|
return id, nil
|
||||||
},
|
},
|
||||||
ErrorHandler: func(context echo.Context, err error) error {
|
ErrorHandler: func(context *echo.Context, err error) error {
|
||||||
return context.JSON(http.StatusForbidden, nil)
|
return context.JSON(http.StatusForbidden, nil)
|
||||||
},
|
},
|
||||||
DenyHandler: func(context echo.Context, identifier string, err error) error {
|
DenyHandler: func(context *echo.Context, identifier string, err error) error {
|
||||||
return context.JSON(http.StatusTooManyRequests, nil)
|
return context.JSON(http.StatusTooManyRequests, nil)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
"github.com/labstack/echo-contrib/session"
|
"github.com/labstack/echo/v5"
|
||||||
"github.com/labstack/echo/v4"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func SessionMiddleware() echo.MiddlewareFunc {
|
func SessionMiddleware() echo.MiddlewareFunc {
|
||||||
return session.Middleware(sessions.NewCookieStore([]byte("secret")))
|
store := sessions.NewCookieStore([]byte("secret")) // TODO: 从配置中获取密钥
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c *echo.Context) error {
|
||||||
|
c.Set("_session_store", store)
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
33
backend/route.go
Normal file
33
backend/route.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/internal/controllers"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Route struct {
|
||||||
|
Method []string
|
||||||
|
Path string
|
||||||
|
Handler func(c *echo.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var routes = []Route{
|
||||||
|
{Method: []string{"POST"}, Path: "/file/create", Handler: controllers.CreateUploadTask},
|
||||||
|
{Method: []string{"POST"}, Path: "/file/slice", Handler: controllers.UploadFileSlice},
|
||||||
|
{Method: []string{"POST"}, Path: "/file/finish", Handler: controllers.FinishUploadTask},
|
||||||
|
|
||||||
|
{Method: []string{"GET"}, Path: "/share/:id", Handler: controllers.GetShareInfo},
|
||||||
|
{Method: []string{"POST"}, Path: "/share", Handler: controllers.CreateShareInfo},
|
||||||
|
|
||||||
|
{Method: []string{"GET"}, Path: "/download", Handler: controllers.DownloadShare},
|
||||||
|
{Method: []string{"POST"}, Path: "/download", Handler: controllers.VaildateShare},
|
||||||
|
{Method: []string{"GET"}, Path: "/share/pickup/:code", Handler: controllers.GetShareByPickupCode},
|
||||||
|
|
||||||
|
{Method: []string{"GET"}, Path: "/stat", Handler: controllers.GetStat},
|
||||||
|
{Method: []string{"GET"}, Path: "/config", Handler: controllers.GetConfig},
|
||||||
|
{Method: []string{"GET"}, Path: "/about", Handler: controllers.GetAbout},
|
||||||
|
|
||||||
|
{Method: []string{"POST"}, Path: "/task/:type", Handler: controllers.CreateTask},
|
||||||
|
{Method: []string{"GET"}, Path: "/task/:id", Handler: controllers.GetTask},
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"backend/internal/utils"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,7 +19,7 @@ func TestHTTPSuccessHandler(t *testing.T) {
|
|||||||
c := e.NewContext(req, rec)
|
c := e.NewContext(req, rec)
|
||||||
|
|
||||||
data := map[string]interface{}{"result": "success"}
|
data := map[string]interface{}{"result": "success"}
|
||||||
err := HTTPSuccessHandler(c, data)
|
err := utils.HTTPSuccessHandler(c, data)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, rec.Code)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
@@ -40,7 +42,7 @@ func TestHTTPErrorHandler(t *testing.T) {
|
|||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
c := e.NewContext(req, rec)
|
c := e.NewContext(req, rec)
|
||||||
|
|
||||||
err := HTTPErrorHandler(c, assert.AnError)
|
err := utils.HTTPErrorHandler(c, assert.AnError)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||||
@@ -1,30 +1,31 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"bytes"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"backend/internal/utils"
|
||||||
|
u "pkg/utils"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGeneratePasswordHash(t *testing.T) {
|
func TestGeneratePasswordHash(t *testing.T) {
|
||||||
// 保存原始环境变量
|
|
||||||
originalSalt := os.Getenv("share.password_salt")
|
|
||||||
defer os.Setenv("share.password_salt", originalSalt)
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
password string
|
password string
|
||||||
salt string
|
salt string
|
||||||
expectError bool
|
expectError bool
|
||||||
errorMsg string
|
err error
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "share.password_salt未配置",
|
name: "share.password_salt未配置",
|
||||||
password: "testpassword",
|
password: "testpassword",
|
||||||
salt: "",
|
salt: "",
|
||||||
expectError: true,
|
expectError: true,
|
||||||
errorMsg: "请配置share.password_salt",
|
err: utils.ErrPasswordSaltNotSet,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "正常生成哈希",
|
name: "正常生成哈希",
|
||||||
@@ -37,21 +38,23 @@ func TestGeneratePasswordHash(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// 设置环境变量
|
// 设置环境变量
|
||||||
if tt.salt != "" {
|
u.InitTestViper(u.EnvOption{
|
||||||
os.Setenv("share.password_salt", tt.salt)
|
ConfigData: bytes.NewBuffer([]byte(fmt.Sprintf(`
|
||||||
} else {
|
share:
|
||||||
os.Unsetenv("share.password_salt")
|
password_salt: %s
|
||||||
}
|
`, tt.salt))),
|
||||||
|
})
|
||||||
|
u.SetEnv("share.password_salt", tt.salt)
|
||||||
|
|
||||||
hash, err := GeneratePasswordHash(tt.password)
|
hash, err := utils.GeneratePasswordHash(tt.password)
|
||||||
|
|
||||||
if tt.expectError {
|
if tt.expectError {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("期望错误,但得到了 nil")
|
t.Errorf("期望错误,但得到了 nil")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err.Error() != tt.errorMsg {
|
if err != tt.err {
|
||||||
t.Errorf("期望错误信息 '%s',但得到了 '%s'", tt.errorMsg, err.Error())
|
t.Errorf("期望错误信息 '%s',但得到了 '%s'", tt.err.Error(), err.Error())
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,17 @@ redis:
|
|||||||
# (必填)redis 地址
|
# (必填)redis 地址
|
||||||
url: redis://redis:6379/0
|
url: redis://redis:6379/0
|
||||||
|
|
||||||
|
# 实例功能配置
|
||||||
|
features:
|
||||||
|
file-share:
|
||||||
|
enabled: true
|
||||||
|
text-share:
|
||||||
|
enabled: true
|
||||||
|
file-image-compress:
|
||||||
|
enabled: true
|
||||||
|
file-image-convert:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
# 站点基本信息
|
# 站点基本信息
|
||||||
site:
|
site:
|
||||||
# 必填,对应你的公网域名
|
# 必填,对应你的公网域名
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { Skeleton } from '@/components/ui/skeleton'
|
|||||||
import getFileSize from '~/lib/getFileSize'
|
import getFileSize from '~/lib/getFileSize'
|
||||||
import SparkMD5 from 'spark-md5'
|
import SparkMD5 from 'spark-md5'
|
||||||
import useMyAppConfig from '@/composables/useMyAppConfig'
|
import useMyAppConfig from '@/composables/useMyAppConfig'
|
||||||
|
import { useFeatureMeta } from '@/composables/useFeatureMeta'
|
||||||
import Progress from '~/components/ui/progress/Progress.vue'
|
import Progress from '~/components/ui/progress/Progress.vue'
|
||||||
import renderI18n from '~/lib/renderI18n'
|
import renderI18n from '~/lib/renderI18n'
|
||||||
import { I18nT } from 'vue-i18n'
|
import { I18nT } from 'vue-i18n'
|
||||||
|
|
||||||
const { locale } = useI18n()
|
const { locale } = useI18n()
|
||||||
const appConfig = useMyAppConfig()
|
const appConfig = useMyAppConfig()
|
||||||
|
const featureMeta = useFeatureMeta()
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['about'],
|
queryKey: ['about'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -52,14 +54,14 @@ const genUserAvatar = (email: string) => {
|
|||||||
<div class="flex flex-col gap-2 items-center">
|
<div class="flex flex-col gap-2 items-center">
|
||||||
<div class="text-xl">{{ renderI18n(appConfig?.site_title ?? {}, 'en', locale) }}</div>
|
<div class="text-xl">{{ renderI18n(appConfig?.site_title ?? {}, 'en', locale) }}</div>
|
||||||
<div class="text-sm opacity-75 text-center px-5">
|
<div class="text-sm opacity-75 text-center px-5">
|
||||||
<I18nT keypath="about.powerBy" tag="span">
|
<I18nT keypath="page.about.powerBy" tag="span">
|
||||||
<NuxtLink href="https://github.com/keven1024/015" target="_blank" class="text-primary hover:underline">015</NuxtLink>
|
<NuxtLink href="https://github.com/keven1024/015" target="_blank" class="text-primary hover:underline">015</NuxtLink>
|
||||||
</I18nT>
|
</I18nT>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="font-semibold">{{ t('about.systemInfo') }}</div>
|
<div class="font-semibold">{{ t('page.about.systemInfo') }}</div>
|
||||||
<template v-if="isLoading">
|
<template v-if="isLoading">
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-2">
|
||||||
<Skeleton class="w-full h-20 rounded-xl" v-for="i in 2" :key="i" />
|
<Skeleton class="w-full h-20 rounded-xl" v-for="i in 2" :key="i" />
|
||||||
@@ -68,7 +70,7 @@ const genUserAvatar = (email: string) => {
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
<div class="rounded-xl bg-white/50 flex-1 flex flex-col p-3 gap-2">
|
<div class="rounded-xl bg-white/50 flex-1 flex flex-col p-3 gap-2">
|
||||||
<div class="opacity-75 text-xs">{{ t('about.admin') }}</div>
|
<div class="opacity-75 text-xs">{{ t('page.about.admin') }}</div>
|
||||||
<div
|
<div
|
||||||
class="flex flex-row gap-2 items-center cursor-pointer"
|
class="flex flex-row gap-2 items-center cursor-pointer"
|
||||||
@click="
|
@click="
|
||||||
@@ -98,7 +100,7 @@ const genUserAvatar = (email: string) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl bg-white/50 flex-1 flex flex-col p-3 gap-2">
|
<div class="rounded-xl bg-white/50 flex-1 flex flex-col p-3 gap-2">
|
||||||
<div class="opacity-75 text-xs">{{ t('about.storage') }}</div>
|
<div class="opacity-75 text-xs">{{ t('page.about.storage') }}</div>
|
||||||
<div class="text-right flex flex-row items-baseline">
|
<div class="text-right flex flex-row items-baseline">
|
||||||
<span class="text-lg font-semibold">{{ getFileSize(data?.file?.current ?? 0) }}</span>
|
<span class="text-lg font-semibold">{{ getFileSize(data?.file?.current ?? 0) }}</span>
|
||||||
<span class="text-md opacity-75">/ {{ getFileSize(data?.file?.maximun ?? 0) }}</span>
|
<span class="text-md opacity-75">/ {{ getFileSize(data?.file?.maximun ?? 0) }}</span>
|
||||||
@@ -107,6 +109,29 @@ const genUserAvatar = (email: string) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-if="isLoading">
|
||||||
|
<Skeleton class="w-full h-24 rounded-xl" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="rounded-xl bg-white/50 flex flex-col p-3 gap-3">
|
||||||
|
<div class="font-semibold">{{ t('page.about.enabledFeatures') }}</div>
|
||||||
|
<div v-if="featureMeta.length" class="flex flex-row flex-wrap gap-2">
|
||||||
|
<div
|
||||||
|
v-for="feature in featureMeta"
|
||||||
|
:key="feature.key"
|
||||||
|
class="flex flex-row items-center gap-2 rounded-full bg-black/5 px-2 py-1 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<div class="flex size-6 items-center justify-center rounded-full text-black/80" :style="feature.style">
|
||||||
|
<component :is="feature.icon" class="size-3.5" />
|
||||||
|
</div>
|
||||||
|
<span>{{ feature.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm opacity-75">
|
||||||
|
{{ t('page.about.enabledFeaturesEmpty') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template v-if="isLoading">
|
<template v-if="isLoading">
|
||||||
<Skeleton class="w-full h-16 rounded-xl" />
|
<Skeleton class="w-full h-16 rounded-xl" />
|
||||||
</template>
|
</template>
|
||||||
@@ -115,7 +140,7 @@ const genUserAvatar = (email: string) => {
|
|||||||
<Accordion type="single" collapsible>
|
<Accordion type="single" collapsible>
|
||||||
<AccordionItem value="about">
|
<AccordionItem value="about">
|
||||||
<AccordionTrigger>
|
<AccordionTrigger>
|
||||||
<span class="font-semibold">{{ t('about.about') }}</span>
|
<span class="font-semibold">{{ t('page.about.about') }}</span>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent>
|
<AccordionContent>
|
||||||
<MarkdownRender class="max-w-full" :markdown="renderI18n(data?.content ?? {}, 'en', locale) ?? ''" />
|
<MarkdownRender class="max-w-full" :markdown="renderI18n(data?.content ?? {}, 'en', locale) ?? ''" />
|
||||||
|
|||||||
@@ -1,34 +1,33 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CurveType } from '@unovis/ts'
|
|
||||||
import { AreaChart } from '@/components/ui/chart-area'
|
|
||||||
import { cx } from 'class-variance-authority'
|
import { cx } from 'class-variance-authority'
|
||||||
import { useQuery } from '@tanstack/vue-query'
|
import { useQuery } from '@tanstack/vue-query'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import AboutChartTooltip from '@/components/AboutChartTooltip.vue'
|
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { times } from 'lodash-es'
|
import { times } from 'lodash-es'
|
||||||
|
import type { ChartConfig } from '@/components/ui/chart'
|
||||||
|
import { VisArea, VisAxis, VisLine, VisXYContainer } from '@unovis/vue'
|
||||||
|
import { ChartContainer, ChartTooltip, ChartCrosshair, ChartLegendContent, componentToString, ChartTooltipContent } from '@/components/ui/chart'
|
||||||
|
|
||||||
interface StatChartData {
|
interface StatChartData {
|
||||||
file_size: number
|
file_size: number
|
||||||
file_num: number
|
file_num: number
|
||||||
share_num: number
|
share_num: number
|
||||||
download_num: number
|
download_num: number
|
||||||
date: string
|
date: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QueueChartData {
|
interface QueueChartData {
|
||||||
processed: number
|
processed: number
|
||||||
failed: number
|
failed: number
|
||||||
date: string
|
date: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChartDataItem = StatChartData | QueueChartData
|
type ChartDataItem = StatChartData | QueueChartData
|
||||||
|
|
||||||
type ChartConfig = {
|
type AreaChartConfig = {
|
||||||
data: ChartDataItem[]
|
data: ChartDataItem[]
|
||||||
index: string
|
index: string
|
||||||
categories: string[]
|
config: ChartConfig
|
||||||
colors: string[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
@@ -51,28 +50,28 @@ const { t } = useI18n()
|
|||||||
const chartTabs = computed(() => {
|
const chartTabs = computed(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: t('about.file'),
|
label: t('page.about.file'),
|
||||||
value: 'storage',
|
value: 'storage',
|
||||||
total: data.value?.chart?.storage
|
total: data.value?.chart?.storage
|
||||||
? Object.values(data.value.chart.storage).reduce((acc: number, curr: StatChartData) => acc + curr.file_num, 0)
|
? Object.values(data.value.chart.storage).reduce((acc: number, curr: StatChartData) => acc + curr.file_num, 0)
|
||||||
: 0,
|
: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t('about.share'),
|
label: t('page.about.share'),
|
||||||
value: 'share',
|
value: 'share',
|
||||||
total: data.value?.chart?.storage
|
total: data.value?.chart?.storage
|
||||||
? Object.values(data.value.chart.storage).reduce((acc: number, curr: StatChartData) => acc + curr.share_num, 0)
|
? Object.values(data.value.chart.storage).reduce((acc: number, curr: StatChartData) => acc + curr.share_num, 0)
|
||||||
: 0,
|
: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t('about.download'),
|
label: t('page.about.download'),
|
||||||
value: 'download',
|
value: 'download',
|
||||||
total: data.value?.chart?.storage
|
total: data.value?.chart?.storage
|
||||||
? Object.values(data.value.chart.storage).reduce((acc: number, curr: StatChartData) => acc + curr.download_num, 0)
|
? Object.values(data.value.chart.storage).reduce((acc: number, curr: StatChartData) => acc + curr.download_num, 0)
|
||||||
: 0,
|
: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t('about.task'),
|
label: t('page.about.task'),
|
||||||
value: 'queue',
|
value: 'queue',
|
||||||
total: data.value?.chart?.queue
|
total: data.value?.chart?.queue
|
||||||
? Object.values(data.value.chart.queue).reduce((acc: number, curr: QueueChartData) => acc + curr.processed + curr.failed, 0)
|
? Object.values(data.value.chart.queue).reduce((acc: number, curr: QueueChartData) => acc + curr.processed + curr.failed, 0)
|
||||||
@@ -82,12 +81,12 @@ const chartTabs = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const currentChartTab = ref<'storage' | 'queue' | 'share' | 'download'>('storage')
|
const currentChartTab = ref<'storage' | 'queue' | 'share' | 'download'>('storage')
|
||||||
const currentChartData = computed((): ChartConfig => {
|
const currentChartData = computed((): AreaChartConfig => {
|
||||||
const { storage, queue } = data.value?.chart || {}
|
const { storage, queue } = data.value?.chart || {}
|
||||||
if (currentChartTab.value === 'queue') {
|
if (currentChartTab.value === 'queue') {
|
||||||
const queueData = times(30, (i) => {
|
const queueData = times(30, (i) => {
|
||||||
return {
|
return {
|
||||||
date: dayjs().subtract(i, 'day').format('YYYY-MM-DD'),
|
date: dayjs().subtract(i, 'day').toDate(),
|
||||||
processed: queue?.[dayjs().subtract(i, 'day').format('YYYY-MM-DD')]?.processed || 0,
|
processed: queue?.[dayjs().subtract(i, 'day').format('YYYY-MM-DD')]?.processed || 0,
|
||||||
failed: queue?.[dayjs().subtract(i, 'day').format('YYYY-MM-DD')]?.failed || 0,
|
failed: queue?.[dayjs().subtract(i, 'day').format('YYYY-MM-DD')]?.failed || 0,
|
||||||
}
|
}
|
||||||
@@ -95,12 +94,14 @@ const currentChartData = computed((): ChartConfig => {
|
|||||||
return {
|
return {
|
||||||
data: queueData,
|
data: queueData,
|
||||||
index: 'date' as const,
|
index: 'date' as const,
|
||||||
categories: ['processed', 'failed'] as const,
|
config: {
|
||||||
colors: ['#4ade80', '#f87171'],
|
processed: { color: '#4ade80', label: t('page.about.processed') },
|
||||||
|
failed: { color: '#f87171', label: t('page.about.failed') },
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const storageData = times(30, (i) => {
|
const storageData = times(30, (i) => {
|
||||||
const base = { date: dayjs().subtract(i, 'day').format('YYYY-MM-DD') }
|
const base = { date: dayjs().subtract(i, 'day').toDate() }
|
||||||
if (currentChartTab.value === 'share') {
|
if (currentChartTab.value === 'share') {
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
@@ -120,31 +121,37 @@ const currentChartData = computed((): ChartConfig => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
let categories = ['file_size', 'file_num']
|
|
||||||
if (currentChartTab.value === 'share') {
|
if (currentChartTab.value === 'share') {
|
||||||
categories = ['share_num']
|
return {
|
||||||
|
data: storageData as ChartDataItem[],
|
||||||
|
index: 'date' as const,
|
||||||
|
config: {
|
||||||
|
share_num: { color: '#ea580c', label: t('page.about.share') },
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (currentChartTab.value === 'download') {
|
if (currentChartTab.value === 'download') {
|
||||||
categories = ['download_num']
|
return {
|
||||||
}
|
data: storageData as ChartDataItem[],
|
||||||
let colors = ['#38bdf8', '#a78bfa']
|
index: 'date' as const,
|
||||||
if (currentChartTab.value === 'share') {
|
config: {
|
||||||
colors = ['#ea580c']
|
download_num: { color: '#a3e635', label: t('page.about.download') },
|
||||||
}
|
},
|
||||||
if (currentChartTab.value === 'download') {
|
}
|
||||||
colors = ['#a3e635']
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
data: storageData as ChartDataItem[],
|
data: storageData as ChartDataItem[],
|
||||||
index: 'date' as const,
|
index: 'date' as const,
|
||||||
categories,
|
config: {
|
||||||
colors,
|
file_size: { color: '#38bdf8', label: t('page.about.fileSize') },
|
||||||
|
file_num: { color: '#a78bfa', label: t('page.about.fileNum') },
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="font-semibold">{{ t('about.analysis') }}</div>
|
<div class="font-semibold">{{ t('page.about.analysis') }}</div>
|
||||||
<template v-if="isLoading">
|
<template v-if="isLoading">
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-2">
|
||||||
<Skeleton class="w-full h-96 rounded-xl" />
|
<Skeleton class="w-full h-96 rounded-xl" />
|
||||||
@@ -167,21 +174,51 @@ const currentChartData = computed((): ChartConfig => {
|
|||||||
<div class="text-lg font-semibold">{{ tab.total }}</div>
|
<div class="text-lg font-semibold">{{ tab.total }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AreaChart
|
<ChartContainer :config="currentChartData.config" class="h-64 w-full p-5" :cursor="false">
|
||||||
v-if="currentChartData"
|
<VisXYContainer :data="currentChartData.data" :x-domain="[dayjs().toDate(), dayjs().subtract(29, 'day').toDate()]">
|
||||||
class="h-64 w-full"
|
<VisArea
|
||||||
:key="currentChartTab"
|
:key="currentChartTab"
|
||||||
:index="currentChartData.index"
|
:x="(d: ChartDataItem) => d.date"
|
||||||
:data="currentChartData.data"
|
:y="Object.keys(currentChartData.config).map((key) => (d: ChartDataItem) => d?.[key as keyof ChartDataItem])"
|
||||||
:categories="currentChartData.categories"
|
:color="Object.values(currentChartData.config).map((c) => c.color)"
|
||||||
:show-grid-line="false"
|
:opacity="0.6"
|
||||||
:show-legend="false"
|
/>
|
||||||
:show-y-axis="true"
|
<VisLine
|
||||||
:show-x-axis="true"
|
:key="currentChartTab"
|
||||||
:colors="currentChartData.colors"
|
:x="(d: ChartDataItem) => d.date"
|
||||||
:custom-tooltip="AboutChartTooltip"
|
:y="Object.keys(currentChartData.config).map((key) => (d: ChartDataItem) => d?.[key as keyof ChartDataItem])"
|
||||||
:curve-type="CurveType.CatmullRom"
|
:color="Object.values(currentChartData.config).map((c) => c.color)"
|
||||||
/>
|
:line-width="1"
|
||||||
|
/>
|
||||||
|
<VisAxis
|
||||||
|
:key="currentChartTab"
|
||||||
|
type="x"
|
||||||
|
:tick-line="false"
|
||||||
|
:domain-line="false"
|
||||||
|
:grid-line="false"
|
||||||
|
:num-ticks="6"
|
||||||
|
:tick-format="
|
||||||
|
(d: Date) => {
|
||||||
|
return dayjs(d).format('MMM')
|
||||||
|
}
|
||||||
|
"
|
||||||
|
:tick-values="currentChartData.data.map((d) => d.date)"
|
||||||
|
/>
|
||||||
|
<ChartTooltip />
|
||||||
|
<ChartCrosshair
|
||||||
|
:key="currentChartTab"
|
||||||
|
:template="
|
||||||
|
componentToString(currentChartData.config, ChartTooltipContent, {
|
||||||
|
labelFormatter: (d) => {
|
||||||
|
return dayjs(d).format('MMM D')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
"
|
||||||
|
:color="(d: any, i: number) => Object.values(currentChartData.config).map((c) => c.color as string)[i]"
|
||||||
|
/>
|
||||||
|
</VisXYContainer>
|
||||||
|
<ChartLegendContent />
|
||||||
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import getFileSize from '~/lib/getFileSize'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
data: { name: string; value: string; color: string }[]
|
|
||||||
title: string
|
|
||||||
}>()
|
|
||||||
const dataKeyMap = {
|
|
||||||
file_size: {
|
|
||||||
'zh-CN': '文件大小',
|
|
||||||
en: 'File Size',
|
|
||||||
},
|
|
||||||
file_num: {
|
|
||||||
'zh-CN': '文件数量',
|
|
||||||
en: 'File Num',
|
|
||||||
},
|
|
||||||
processed: {
|
|
||||||
'zh-CN': '处理数量',
|
|
||||||
en: 'Processed',
|
|
||||||
},
|
|
||||||
failed: {
|
|
||||||
'zh-CN': '失败数量',
|
|
||||||
en: 'Failed',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="rounded-md bg-white p-2 flex flex-col gap-2">
|
|
||||||
<div class="text-sm font-medium">{{ title }}</div>
|
|
||||||
<div v-for="(item, index) in data" :key="index">
|
|
||||||
<div class="flex flex-row items-center gap-2">
|
|
||||||
<div class="h-5 w-2 rounded-full" :style="{ backgroundColor: item.color ?? '#222' }"></div>
|
|
||||||
<div class="text-xs font-medium">
|
|
||||||
{{ dataKeyMap?.[item.name as keyof typeof dataKeyMap]?.['en'] ?? item.name }}
|
|
||||||
</div>
|
|
||||||
<div class="text-sm">
|
|
||||||
{{ ['file_size']?.includes(item?.name) ? getFileSize(item.value) : item.value }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
29
front/components/BaseCard.vue
Normal file
29
front/components/BaseCard.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
title?: string
|
||||||
|
showBackButton?: boolean
|
||||||
|
}>()
|
||||||
|
const router = useRouter()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rounded-xl p-5 bg-white/50 backdrop-blur-xl w-full lg:w-200">
|
||||||
|
<div v-if="title" class="flex flex-row justify-between">
|
||||||
|
<h1 class="text-xl font-normal">{{ title }}</h1>
|
||||||
|
<Button
|
||||||
|
v-if="!!showBackButton"
|
||||||
|
variant="outline"
|
||||||
|
class="bg-white/70"
|
||||||
|
size="icon"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<LucideHome />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,19 +1,21 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { VisSingleContainer, VisDonut } from '@unovis/vue'
|
import { VisSingleContainer, VisDonut } from '@unovis/vue'
|
||||||
import { withDefaults, defineProps } from 'vue'
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(
|
||||||
value?: number
|
defineProps<{
|
||||||
size?: number
|
value?: number
|
||||||
color?: string
|
size?: number
|
||||||
}>(), {
|
color?: string
|
||||||
value: 0,
|
}>(),
|
||||||
size: 40,
|
{
|
||||||
color: '#06b6d4'
|
value: 0,
|
||||||
})
|
size: 40,
|
||||||
|
color: '#06b6d4',
|
||||||
|
}
|
||||||
|
)
|
||||||
const data = computed(() => {
|
const data = computed(() => {
|
||||||
const progress = Math.min(Math.max(props.value, 0), 100)
|
const progress = Math.min(Math.max(props.value, 0), 100)
|
||||||
return [progress, 100 - progress ]
|
return [progress, 100 - progress]
|
||||||
})
|
})
|
||||||
const getValue = (d: number) => d
|
const getValue = (d: number) => d
|
||||||
const getColor = (d: number, i: number) => [props?.color, 'transparent'][i]
|
const getColor = (d: number, i: number) => [props?.color, 'transparent'][i]
|
||||||
|
|||||||
@@ -1,22 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
|
||||||
LucideShare,
|
|
||||||
LucideImage,
|
|
||||||
LucideBot,
|
|
||||||
LucideLanguages,
|
|
||||||
LucideFileText,
|
|
||||||
LucideImageMinus,
|
|
||||||
LucideArrowRightLeft,
|
|
||||||
LucideImagePlus,
|
|
||||||
LucideAudioLines,
|
|
||||||
LucideListMusic,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
import { cx } from 'class-variance-authority'
|
|
||||||
import { isObject } from 'lodash-es'
|
|
||||||
import showDrawer from '@/lib/showDrawer'
|
import showDrawer from '@/lib/showDrawer'
|
||||||
import FileShareHandle from '@/components/Preprocessing/FileShareHandle.vue'
|
import FileShareHandle from '@/components/Preprocessing/FileShareHandle.vue'
|
||||||
|
import ImageConvertHandle from '@/components/Preprocessing/ImageConvertHandle.vue'
|
||||||
|
import { useFeatureMeta, type FeatureKey } from '@/composables/useFeatureMeta'
|
||||||
import type { FileShareHandleProps } from '../Preprocessing/types'
|
import type { FileShareHandleProps } from '../Preprocessing/types'
|
||||||
const { t } = useI18n()
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
hide: () => void
|
hide: () => void
|
||||||
file: File[]
|
file: File[]
|
||||||
@@ -24,33 +12,25 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isImage = computed(() => props.file.every((r) => r?.type?.startsWith('image/')))
|
const isImage = computed(() => props.file.every((r) => r?.type?.startsWith('image/')))
|
||||||
const isVideo = computed(() => props.file.every((r) => r?.type?.startsWith('video/')))
|
|
||||||
const isAudio = computed(() => props.file.every((r) => r?.type?.startsWith('audio/')))
|
|
||||||
const isMedia = computed(() => isImage.value || isVideo.value || isAudio.value)
|
|
||||||
|
|
||||||
const isPDF = computed(() => props.file.every((r) => r?.type?.startsWith('application/pdf')))
|
const featureMeta = useFeatureMeta()
|
||||||
const isDOC = computed(() => props.file.every((r) => r?.type?.startsWith('application/msword')))
|
|
||||||
const isXLS = computed(() => props.file.every((r) => r?.type?.startsWith('application/vnd.ms-excel')))
|
type ActionHandler = {
|
||||||
const isPPT = computed(() => props.file.every((r) => r?.type?.startsWith('application/vnd.ms-powerpoint')))
|
condition?: () => boolean
|
||||||
const isDocument = computed(() => isPDF.value || isDOC.value || isXLS.value || isPPT.value)
|
onClick: () => void
|
||||||
const actions = [
|
}
|
||||||
{
|
|
||||||
label: t('file.handleType.file-share'),
|
const actionHandlers: Partial<Record<FeatureKey, ActionHandler>> = {
|
||||||
icon: LucideShare,
|
'file-share': {
|
||||||
className: 'bg-green-300',
|
onClick: () => showDrawer({ render: ({ hide }) => h(FileShareHandle, { ...props, hide }) }),
|
||||||
onClick: () => {
|
|
||||||
showDrawer({
|
|
||||||
render: ({ hide }) => h(FileShareHandle, { ...props, hide }),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
isImage.value && {
|
'file-image-compress': {
|
||||||
label: t('file.handleType.file-image-compress'),
|
condition: () => isImage.value,
|
||||||
icon: LucideImageMinus,
|
onClick: () => props.onFileHandle({ type: 'file-image-compress', config: {} }),
|
||||||
className: 'bg-red-300',
|
},
|
||||||
onClick: () => {
|
'file-image-convert': {
|
||||||
props.onFileHandle({ type: 'file-image-compress', config: {} })
|
condition: () => isImage.value,
|
||||||
},
|
onClick: () => showDrawer({ render: ({ hide }) => h(ImageConvertHandle, { ...props, hide }) }),
|
||||||
},
|
},
|
||||||
// isImage.value && {
|
// isImage.value && {
|
||||||
// label: '图片翻译', icon: LucideLanguages, className: 'bg-orange-300', onClick: () => {
|
// label: '图片翻译', icon: LucideLanguages, className: 'bg-orange-300', onClick: () => {
|
||||||
@@ -77,19 +57,24 @@ const actions = [
|
|||||||
// console.log('复制链接')
|
// console.log('复制链接')
|
||||||
// }
|
// }
|
||||||
// },
|
// },
|
||||||
]?.filter(isObject) as {
|
}
|
||||||
label: string
|
|
||||||
icon: any
|
const actions = computed(() =>
|
||||||
className: string
|
featureMeta.value
|
||||||
onClick: () => void
|
.filter((meta) => {
|
||||||
}[]
|
const { key } = meta || {}
|
||||||
|
const handler = actionHandlers?.[key]
|
||||||
|
return handler && (!handler.condition || handler.condition())
|
||||||
|
})
|
||||||
|
.map((meta) => ({ ...meta, onClick: actionHandlers[meta.key]!.onClick }))
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-5 p-5">
|
<div class="flex flex-col gap-5 p-5 overflow-x-auto">
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-2">
|
||||||
<div
|
<div
|
||||||
v-for="item in actions"
|
v-for="item in actions"
|
||||||
:key="item.label"
|
:key="item.key"
|
||||||
class="flex flex-col items-center gap-2 max-w-20"
|
class="flex flex-col items-center gap-2 max-w-20"
|
||||||
@click="
|
@click="
|
||||||
() => {
|
() => {
|
||||||
@@ -98,7 +83,7 @@ const actions = [
|
|||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div :class="cx('size-14 flex justify-center items-center rounded-full mx-3', item?.className)">
|
<div class="size-14 flex justify-center items-center rounded-full mx-3" :style="item?.style">
|
||||||
<component :is="item?.icon" />
|
<component :is="item?.icon" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs truncate w-full text-center">{{ item?.label }}</div>
|
<div class="text-xs truncate w-full text-center">{{ item?.label }}</div>
|
||||||
|
|||||||
@@ -1,41 +1,37 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { cx } from "class-variance-authority";
|
import { cx } from 'class-variance-authority'
|
||||||
|
|
||||||
const { availableLocales, setLocale, locale: currentLocale, t } = useI18n();
|
const props = defineProps<{
|
||||||
const route = useRoute();
|
hide: () => void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { availableLocales, setLocale, locale: currentLocale, t } = useI18n()
|
||||||
|
|
||||||
const localeMap = {
|
const localeMap = {
|
||||||
"zh-CN": "简体中文",
|
'zh-CN': '简体中文',
|
||||||
en: "English",
|
en: 'English',
|
||||||
// 'ja': '日本語',
|
// 'ja': '日本語',
|
||||||
// 'ko': '한국어',
|
// 'ko': '한국어',
|
||||||
// 'fr': 'Français',
|
// 'fr': 'Français',
|
||||||
// 'de': 'Deutsch',
|
// 'de': 'Deutsch',
|
||||||
};
|
}
|
||||||
|
|
||||||
const switchLocale = async (locale: string) => {
|
const switchLocale = async (locale: string) => {
|
||||||
await setLocale(locale as keyof typeof localeMap);
|
await setLocale(locale as keyof typeof localeMap)
|
||||||
navigateTo(route.path, {
|
props.hide()
|
||||||
external: true,
|
}
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-1 py-2">
|
<div class="flex flex-col gap-1 py-2">
|
||||||
<div class="text-xl font-bold mb-3">{{ t("i18n.switchLocale") }}</div>
|
<div class="text-xl font-bold mb-3">{{ t('i18n.switchLocale') }}</div>
|
||||||
<div
|
<div
|
||||||
v-for="locale in availableLocales"
|
v-for="locale in availableLocales"
|
||||||
:key="locale"
|
:key="locale"
|
||||||
:class="
|
:class="cx('rounded-md hover:bg-black/10 p-2 cursor-pointer', currentLocale === locale && 'bg-black/10 font-bold')"
|
||||||
cx(
|
@click="() => switchLocale(locale)"
|
||||||
'rounded-md hover:bg-black/10 p-2 cursor-pointer',
|
>
|
||||||
currentLocale === locale && 'bg-black/10 font-bold',
|
{{ localeMap?.[locale as keyof typeof localeMap] }}
|
||||||
)
|
</div>
|
||||||
"
|
|
||||||
@click="() => switchLocale(locale)"
|
|
||||||
>
|
|
||||||
{{ localeMap?.[locale as keyof typeof localeMap] }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import FormButton from '@/components/Field/FormButton.vue'
|
|||||||
import InputField from '@/components/Field/InputField.vue'
|
import InputField from '@/components/Field/InputField.vue'
|
||||||
import type { FormContext, GenericObject } from 'vee-validate'
|
import type { FormContext, GenericObject } from 'vee-validate'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
|
const { t } = useI18n()
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
share_id: string
|
share_id: string
|
||||||
hide: any
|
hide: any
|
||||||
@@ -15,14 +16,14 @@ const handleSubmit = async (form: FormContext<GenericObject, GenericObject>) =>
|
|||||||
const password = form.values.password
|
const password = form.values.password
|
||||||
const token = await getShareToken(props.share_id, { password })
|
const token = await getShareToken(props.share_id, { password })
|
||||||
if (!token) {
|
if (!token) {
|
||||||
toast.error('密码错误')
|
toast.error(t('page.shareView.passwall.passwordError'))
|
||||||
form.resetForm()
|
form.resetForm()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
props?.hide(token)
|
props?.hide(token)
|
||||||
return
|
return
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('密码错误')
|
toast.error(t('page.shareView.passwall.passwordError'))
|
||||||
form.resetForm()
|
form.resetForm()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,9 +32,9 @@ const handleSubmit = async (form: FormContext<GenericObject, GenericObject>) =>
|
|||||||
<template>
|
<template>
|
||||||
<VeeForm>
|
<VeeForm>
|
||||||
<div class="flex flex-col gap-5">
|
<div class="flex flex-col gap-5">
|
||||||
<div class="text-xl font-bold">输入密码</div>
|
<div class="text-xl font-bold">{{ t('page.shareView.passwall.title') }}</div>
|
||||||
<InputField name="password" type="password" rules="required" placeholder="请输入密码" />
|
<InputField name="password" type="password" rules="required" :placeholder="t('page.shareView.passwall.passwordPlaceholder')" />
|
||||||
<FormButton @click="handleSubmit">提交</FormButton>
|
<FormButton @click="handleSubmit">{{ t('btn.submit') }}</FormButton>
|
||||||
</div>
|
</div>
|
||||||
</VeeForm>
|
</VeeForm>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const handleSubmit = async (form: FormContext<GenericObject, GenericObject>) =>
|
|||||||
}
|
}
|
||||||
}>(`/api/share/pickup/${code}`)
|
}>(`/api/share/pickup/${code}`)
|
||||||
if (!data.data.share_id) {
|
if (!data.data.share_id) {
|
||||||
toast.error(t('pickup.codeError'))
|
toast.error(t('page.upload.pickup.codeError'))
|
||||||
form.resetForm()
|
form.resetForm()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ const handleSubmit = async (form: FormContext<GenericObject, GenericObject>) =>
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t('pickup.codeError'))
|
toast.error(t('page.upload.pickup.codeError'))
|
||||||
form.resetForm()
|
form.resetForm()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@ const handleSubmit = async (form: FormContext<GenericObject, GenericObject>) =>
|
|||||||
<template>
|
<template>
|
||||||
<VeeForm>
|
<VeeForm>
|
||||||
<div class="flex flex-col gap-5">
|
<div class="flex flex-col gap-5">
|
||||||
<div class="text-xl font-bold">{{ t('pickup.title') }}</div>
|
<div class="text-xl font-bold">{{ t('page.upload.pickup.title') }}</div>
|
||||||
<PinInputField
|
<PinInputField
|
||||||
name="code"
|
name="code"
|
||||||
:rules="
|
:rules="
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import QRCode from "qrcode";
|
import QRCode from 'qrcode'
|
||||||
|
const { t } = useI18n()
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
hide: () => void;
|
hide: () => void
|
||||||
data: any;
|
data: any
|
||||||
}>();
|
}>()
|
||||||
const { state } = useAsyncState(async () => {
|
const { state } = useAsyncState(async () => {
|
||||||
return await QRCode.toDataURL(props.data);
|
return await QRCode.toDataURL(props.data)
|
||||||
}, null);
|
}, null)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-5">
|
<div class="flex flex-col gap-5">
|
||||||
<div class="text-xl font-bold">分享二维码</div>
|
<div class="text-xl font-bold">{{ t('page.result.qrCode.title') }}</div>
|
||||||
<div class="flex flex-row justify-center">
|
<div class="flex flex-row justify-center">
|
||||||
<img :src="state" v-if="!!state" />
|
<img :src="state" v-if="!!state" />
|
||||||
<Skeleton class="size-20" v-else />
|
<Skeleton class="size-20" v-else />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,74 +1,68 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import showDrawer from '@/lib/showDrawer'
|
||||||
LucideShare,
|
import TextShareHandle from '@/components/Preprocessing/TextShareHandle.vue'
|
||||||
LucideImage,
|
import { useFeatureMeta, type FeatureKey } from '@/composables/useFeatureMeta'
|
||||||
LucideBot,
|
|
||||||
LucideLanguages,
|
|
||||||
} from "lucide-vue-next";
|
|
||||||
import { cx } from "class-variance-authority";
|
|
||||||
import showDrawer from "@/lib/showDrawer";
|
|
||||||
import TextShareHandle from "@/components/Preprocessing/TextShareHandle.vue";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
hide: () => void;
|
hide: () => void
|
||||||
text: string;
|
text: string
|
||||||
onTextHandle: ({ type, config }: { type: string; config: any }) => void;
|
onTextHandle: ({ type, config }: { type: string; config: any }) => void
|
||||||
}>();
|
}>()
|
||||||
const { t } = useI18n();
|
|
||||||
const actions = [
|
const featureMeta = useFeatureMeta()
|
||||||
{
|
|
||||||
label: t("text.handleType.text-share"),
|
type ActionHandler = {
|
||||||
icon: LucideShare,
|
condition?: () => boolean
|
||||||
className: "bg-green-300",
|
onClick: () => void
|
||||||
onClick: () => {
|
}
|
||||||
showDrawer({
|
|
||||||
render: ({ hide }) => h(TextShareHandle, { ...props, hide }),
|
const actionHandlers: Partial<Record<FeatureKey, ActionHandler>> = {
|
||||||
});
|
'text-share': {
|
||||||
|
onClick: () => showDrawer({ render: ({ hide }) => h(TextShareHandle, { ...props, hide }) }),
|
||||||
},
|
},
|
||||||
},
|
// 'text-image-generate': {
|
||||||
// {
|
// label: '生成配图', icon: LucideImage, className: 'bg-red-300',
|
||||||
// label: '生成配图', icon: LucideImage, className: 'bg-red-300', onClick: () => {
|
// onClick: () => { console.log('复制链接') }
|
||||||
// console.log('复制链接')
|
// },
|
||||||
// }
|
// 'text-ai-ask': {
|
||||||
// },
|
// label: '问大模型', icon: LucideBot, className: 'bg-blue-300',
|
||||||
// {
|
// onClick: () => { console.log('复制链接') }
|
||||||
// label: '问大模型', icon: LucideBot, className: 'bg-blue-300', onClick: () => {
|
// },
|
||||||
// console.log('复制链接')
|
// 'text-translate': {
|
||||||
// }
|
// label: '文本翻译', icon: LucideLanguages, className: 'bg-orange-300',
|
||||||
// },
|
// onClick: () => { console.log('复制链接') }
|
||||||
// {
|
// },
|
||||||
// label: '文本翻译', icon: LucideLanguages, className: 'bg-orange-300', onClick: () => {
|
}
|
||||||
// console.log('复制链接')
|
|
||||||
// }
|
const actions = computed(() =>
|
||||||
// },
|
featureMeta.value
|
||||||
];
|
.filter((meta) => {
|
||||||
|
const { key } = meta || {}
|
||||||
|
const handler = actionHandlers?.[key]
|
||||||
|
return handler && (!handler.condition || handler.condition())
|
||||||
|
})
|
||||||
|
.map((meta) => ({ ...meta, onClick: actionHandlers[meta.key]!.onClick }))
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-5 p-5">
|
<div class="flex flex-col gap-5 p-5 overflow-x-auto">
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-2">
|
||||||
<div
|
<div
|
||||||
v-for="item in actions"
|
v-for="item in actions"
|
||||||
:key="item.label"
|
:key="item.key"
|
||||||
class="flex flex-col items-center gap-2 max-w-20"
|
class="flex flex-col items-center gap-2 max-w-20"
|
||||||
@click="
|
@click="
|
||||||
() => {
|
() => {
|
||||||
props?.hide();
|
props?.hide()
|
||||||
item?.onClick();
|
item?.onClick()
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div
|
<div class="size-14 flex justify-center items-center rounded-full mx-3" :style="item?.style">
|
||||||
:class="
|
<component :is="item?.icon" />
|
||||||
cx(
|
</div>
|
||||||
'size-14 flex justify-center items-center rounded-full mx-3',
|
<div class="text-xs truncate w-full text-center">{{ item?.label }}</div>
|
||||||
item?.className,
|
</div>
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<component :is="item?.icon" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs truncate w-full text-center">{{ item?.label }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -12,14 +12,25 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
const { value, setValue } = useField<File[]>(props?.name, props?.rules)
|
const { value, setValue } = useField<File[]>(props?.name, props?.rules)
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const filterOutSameFile = (files: File[] | undefined, targetFile: File[] | undefined) => {
|
||||||
|
return files?.filter((file) => !targetFile?.some((r) => r?.name === file?.name && r?.type === file?.type && r?.size === file?.size)) || []
|
||||||
|
}
|
||||||
|
|
||||||
|
useEventListener(document, 'paste', (evt: ClipboardEvent) => {
|
||||||
|
const { files } = evt.clipboardData || {}
|
||||||
|
if (files?.length) {
|
||||||
|
setValue([...filterOutSameFile(value?.value, Array.from(files)), ...Array.from(files)])
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<FileUpload
|
<FileUpload
|
||||||
@onChange="
|
@onChange="
|
||||||
(file) => {
|
(files) => {
|
||||||
// 这里没hash,我们姑且认为name和size,type都一样的为同一个文件
|
// 这里没hash,我们姑且认为name和size,type都一样的为同一个文件
|
||||||
setValue([...(value?.filter((r) => r?.name !== file?.name || r?.type !== file?.type || r?.size !== file?.size) || []), file])
|
setValue([...filterOutSameFile(value, files), ...files])
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
v-slot="{ isOverDropZone }"
|
v-slot="{ isOverDropZone }"
|
||||||
@@ -41,9 +52,7 @@ const { t } = useI18n()
|
|||||||
@click="
|
@click="
|
||||||
(e: any) => {
|
(e: any) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setValue(
|
setValue(filterOutSameFile(value, [item]))
|
||||||
value?.filter((r) => r?.name !== item?.name || r?.type !== item?.type || r?.size !== item?.size) || []
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@@ -56,14 +65,14 @@ const { t } = useI18n()
|
|||||||
<div class="size-16 flex justify-center items-center rounded-xl bg-white/80">
|
<div class="size-16 flex justify-center items-center rounded-xl bg-white/80">
|
||||||
<PlusIcon class="size-7" />
|
<PlusIcon class="size-7" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">{{ t('file.addMore') }}</div>
|
<div class="mb-3">{{ t('page.upload.file.addMore') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<LucideUpload class="size-10" />
|
<LucideUpload class="size-10" />
|
||||||
<div class="text-sm select-none">
|
<div class="text-sm select-none">
|
||||||
{{ t('file.uploadFilePlaceholder') }}
|
{{ t('page.upload.file.uploadFilePlaceholder') }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,10 +7,16 @@ export type filePreview = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
import { LucideFileAudio, LucideFileVideo, LucideFile, LucideFileCode, LucideFileArchive, LucideFileText } from 'lucide-vue-next'
|
import { LucideFileAudio, LucideFileVideo, LucideFile, LucideFileCode, LucideFileArchive, LucideFileText } from 'lucide-vue-next'
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
file: File | filePreview
|
defineProps<{
|
||||||
class?: string
|
file: File | filePreview
|
||||||
}>()
|
class?: string
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
size: 'md',
|
||||||
|
}
|
||||||
|
)
|
||||||
const imageUrl = computed(() => {
|
const imageUrl = computed(() => {
|
||||||
if (props?.file?.type?.startsWith('image/') && props?.file instanceof File) {
|
if (props?.file?.type?.startsWith('image/') && props?.file instanceof File) {
|
||||||
return URL.createObjectURL(props?.file)
|
return URL.createObjectURL(props?.file)
|
||||||
@@ -32,7 +38,7 @@ const fileIcon = computed(() => {
|
|||||||
if (baseType === 'audio') {
|
if (baseType === 'audio') {
|
||||||
return LucideFileAudio
|
return LucideFileAudio
|
||||||
}
|
}
|
||||||
if (baseType === 'text' || ['json', 'ld+json', 'html']?.includes(type)) {
|
if (baseType === 'text' || ['json', 'ld+json', 'html']?.includes(type ?? '')) {
|
||||||
return LucideFileCode
|
return LucideFileCode
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -44,11 +50,11 @@ const fileIcon = computed(() => {
|
|||||||
'vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
'vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
'vnd.ms-powerpoint',
|
'vnd.ms-powerpoint',
|
||||||
'vnd.openxmlformats-officedocument.presentationml.presentation',
|
'vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
].includes(type)
|
].includes(type ?? '')
|
||||||
) {
|
) {
|
||||||
return LucideFileText
|
return LucideFileText
|
||||||
}
|
}
|
||||||
if (['zip', 'vnd.rar', 'x-tar', 'gz', 'bz2', 'x-7z-compressed'].includes(type)) {
|
if (['zip', 'vnd.rar', 'x-tar', 'gz', 'bz2', 'x-7z-compressed'].includes(type ?? '')) {
|
||||||
return LucideFileArchive
|
return LucideFileArchive
|
||||||
}
|
}
|
||||||
return LucideFile
|
return LucideFile
|
||||||
@@ -56,12 +62,20 @@ const fileIcon = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="!!imageUrl" class="flex max-w-30 max-h-20">
|
<div v-if="!!imageUrl" :class="cx('flex overflow-hidden', size === 'sm' && 'max-w-20 max-h-16', size === 'md' && 'max-w-30 max-h-20')">
|
||||||
<div class="object-contain m-auto h-full">
|
<img :src="imageUrl" class="block max-w-full max-h-full object-contain border border-black/20 rounded" />
|
||||||
<img :src="imageUrl" class="w-full h-full border border-black/20 rounded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!imageUrl" :class="cx('flex justify-center items-center rounded-xl bg-white/80 size-16', props?.class)">
|
<div
|
||||||
|
v-if="!imageUrl"
|
||||||
|
:class="
|
||||||
|
cx(
|
||||||
|
'flex justify-center items-center bg-white/80',
|
||||||
|
size === 'sm' && 'size-7 rounded-md',
|
||||||
|
size === 'md' && 'size-16 rounded-xl',
|
||||||
|
props?.class
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
<component :is="fileIcon" class="size-[62.5%]" />
|
<component :is="fileIcon" class="size-[62.5%]" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,63 +3,63 @@ import { useDropZone } from '@vueuse/core'
|
|||||||
const dropZoneRef = ref()
|
const dropZoneRef = ref()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
accept?: string[]
|
accept?: string[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const accept = computed(() => (props?.accept || ['*'])?.join(','))
|
const accept = computed(() => (props?.accept || ['*'])?.join(','))
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'onChange', file: File): void
|
(e: 'onChange', file: File[]): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { isOverDropZone } = useDropZone(dropZoneRef, {
|
const { isOverDropZone } = useDropZone(dropZoneRef, {
|
||||||
onDrop: (file) => {
|
onDrop: (file) => {
|
||||||
if (file?.[0]) {
|
if (!!file && file?.length > 0) {
|
||||||
emit('onChange', file?.[0])
|
emit('onChange', file)
|
||||||
}
|
|
||||||
},
|
|
||||||
// 指定要接收的数据类型
|
|
||||||
dataTypes: (types) => {
|
|
||||||
for (const type of types) {
|
|
||||||
for (const acceptType of accept.value.split(',')) {
|
|
||||||
if (acceptType === '*') {
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
if (acceptType?.endsWith('*')) {
|
},
|
||||||
const [acceptTypePrefix,] = acceptType?.split('/')
|
// 指定要接收的数据类型
|
||||||
if (!acceptTypePrefix) {
|
dataTypes: (types) => {
|
||||||
return true
|
for (const type of types) {
|
||||||
}
|
for (const acceptType of accept.value.split(',')) {
|
||||||
if (type?.startsWith(acceptTypePrefix)) {
|
if (acceptType === '*') {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if (acceptType?.endsWith('*')) {
|
||||||
|
const [acceptTypePrefix] = acceptType?.split('/')
|
||||||
|
if (!acceptTypePrefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (type?.startsWith(acceptTypePrefix)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (acceptType === type) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (acceptType === type) {
|
return false
|
||||||
return true
|
},
|
||||||
}
|
// 控制多文件拖放
|
||||||
}
|
multiple: true,
|
||||||
}
|
// 是否阻止未处理事件的默认行为
|
||||||
return false
|
preventDefaultForUnhandled: false,
|
||||||
},
|
|
||||||
// 控制多文件拖放
|
|
||||||
multiple: false,
|
|
||||||
// 是否阻止未处理事件的默认行为
|
|
||||||
preventDefaultForUnhandled: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const { open, onChange } = useFileDialog({
|
const { open, onChange } = useFileDialog({
|
||||||
accept: accept.value, // Set to accept only image files
|
accept: accept.value, // Set to accept only image files
|
||||||
directory: false,
|
directory: false,
|
||||||
})
|
})
|
||||||
onChange((files) => {
|
onChange((files) => {
|
||||||
if (files?.[0]) {
|
if (!!files && files?.length > 0) {
|
||||||
emit('onChange', files?.[0])
|
emit('onChange', Array.from(files))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="dropZoneRef" @click="open">
|
<div ref="dropZoneRef" @click="() => open()">
|
||||||
<slot :isOverDropZone="isOverDropZone" />
|
<slot :isOverDropZone="isOverDropZone" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -12,3 +12,5 @@ watchEffect(() => {
|
|||||||
dayjs.locale(locale.value.toLowerCase())
|
dayjs.locale(locale.value.toLowerCase())
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template></template>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const Children = () =>
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<DrawerContent>
|
<DrawerContent>
|
||||||
<div class="mx-auto w-full max-w-lg pb-10 px-3">
|
<div class="mx-auto min-w-lg max-w-[80vw] pb-10 px-3">
|
||||||
<Children />
|
<Children />
|
||||||
</div>
|
</div>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import VeeForm from '@/components/VeeForm.vue'
|
import VeeForm from '@/components/VeeForm.vue'
|
||||||
import FileUploadInputFileView from './FileUploadInputFileView.vue'
|
import FileUploadInputFileView from './FileUploadInputFileView.vue'
|
||||||
import FileUploadProgressView from './FileUploadProgressView.vue'
|
import FileUploadProgressView from './FileUploadProgressView/index.vue'
|
||||||
import ResultIndexView from '@/components/Result/ResultIndexView.vue'
|
import ResultIndexView from '@/components/Result/ResultIndexView.vue'
|
||||||
|
|
||||||
const fileStepList = [
|
const fileStepList = [
|
||||||
@@ -16,19 +16,26 @@ const renderComponent = computed(() => {
|
|||||||
return fileStepList.find((item) => item.key === step.value)?.component
|
return fileStepList.find((item) => item.key === step.value)?.component
|
||||||
})
|
})
|
||||||
const formRef = ref<InstanceType<typeof VeeForm>>()
|
const formRef = ref<InstanceType<typeof VeeForm>>()
|
||||||
watch(() => step.value, (newVal) => {
|
watch(
|
||||||
if (newVal === 'input') {
|
() => step.value,
|
||||||
formRef.value?.form?.resetForm()
|
(newVal) => {
|
||||||
formRef.value?.form?.setValues({ file: null })
|
if (newVal === 'input') {
|
||||||
|
formRef.value?.form?.resetForm()
|
||||||
|
formRef.value?.form?.setValues({ file: null })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<VeeForm ref="formRef" v-slot="{ values }" :keepValues="true">
|
<VeeForm ref="formRef" v-slot="{ values }" :keepValues="true">
|
||||||
<div class="rounded-xl p-5 bg-white/50 backdrop-blur-xl w-full lg:w-200">
|
<component
|
||||||
<component :is="renderComponent" :data="values" @change="(key: string) => {
|
:is="renderComponent"
|
||||||
step = key
|
:data="values"
|
||||||
}" />
|
@change="
|
||||||
</div>
|
(key: string) => {
|
||||||
|
step = key
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
</VeeForm>
|
</VeeForm>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,42 +1,39 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import showDrawer from "@/lib/showDrawer";
|
import showDrawer from '@/lib/showDrawer'
|
||||||
import FileShareDrawer from "@/components/Drawer/FileShareDrawer.vue";
|
import FileShareDrawer from '@/components/Drawer/FileShareDrawer.vue'
|
||||||
import FileUploadField from "@/components/Field/FileUploadField.vue";
|
import FileUploadField from '@/components/Field/FileUploadField.vue'
|
||||||
import FormButton from "@/components/Field/FormButton.vue";
|
import FormButton from '@/components/Field/FormButton.vue'
|
||||||
import PickupShareBtn from "@/components/PickupShareBtn.vue";
|
import PickupShareBtn from '@/components/PickupShareBtn.vue'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "change", key: string): void;
|
(e: 'change', key: string): void
|
||||||
}>();
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n()
|
||||||
|
|
||||||
const handleFormSubmit = async (form: any) => {
|
const handleFormSubmit = async (form: any) => {
|
||||||
const { file } = form?.values || {};
|
const { file } = form?.values || {}
|
||||||
showDrawer({
|
showDrawer({
|
||||||
render: ({ hide }) =>
|
render: ({ hide }) =>
|
||||||
h(FileShareDrawer, {
|
h(FileShareDrawer, {
|
||||||
hide,
|
hide,
|
||||||
file,
|
file,
|
||||||
onFileHandle: ({ type, config }) => {
|
onFileHandle: ({ type, config }) => {
|
||||||
form.setFieldValue("handle_type", type);
|
form.setFieldValue('handle_type', type)
|
||||||
form.setFieldValue("config", config);
|
form.setFieldValue('config', config)
|
||||||
emit("change", "progress");
|
emit('change', 'progress')
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="gap-5 flex flex-col">
|
<BaseCard class="gap-5 flex flex-col" :title="t('page.upload.file.uploadFile')">
|
||||||
<div class="text-xl font-normal">{{ t("file.uploadFile") }}</div>
|
<FileUploadField name="file" rules="required" />
|
||||||
<FileUploadField name="file" rules="required" />
|
<div class="flex flex-row gap-3">
|
||||||
<div class="flex flex-row gap-3">
|
<FormButton @click="handleFormSubmit"> <LucideShare class="size-4" />{{ t('btn.submit') }} </FormButton>
|
||||||
<FormButton @click="handleFormSubmit">
|
<PickupShareBtn />
|
||||||
<LucideShare class="size-4" />{{ t("btn.submit") }}
|
</div>
|
||||||
</FormButton>
|
</BaseCard>
|
||||||
<PickupShareBtn />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import getFileSize from '~/lib/getFileSize'
|
||||||
|
import { clamp } from 'lodash-es'
|
||||||
|
import FileUploadBlockProgressView from '@/components/FileUploadBlockProgressView.vue'
|
||||||
|
import FileUploadHeatMapView from '@/components/FileUploadHeatMapView.vue'
|
||||||
|
import type { SelectedFile, Uploadfile } from './types'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
uploadfiles: Uploadfile[]
|
||||||
|
selectedFile: SelectedFile
|
||||||
|
}>()
|
||||||
|
const selectedUploadfile = computed(() => props.uploadfiles.find((item) => item.fileId === props.selectedFile))
|
||||||
|
const selectedUploadfileChunk = computed(() => Object.values(selectedUploadfile.value?.uploadInfo?.chunks || {}))
|
||||||
|
const selectedUploadfileViewMode = ref<'progress' | 'heatmap'>('progress')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="col-span-4 flex flex-col bg-white/80 rounded-xl p-3 gap-5" v-if="selectedFile">
|
||||||
|
<div class="flex flex-row gap-2 items-center">
|
||||||
|
<LucideInfo class="size-4" />
|
||||||
|
{{ t('page.progress.file.uploadDetails') }}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 text-sm gap-3">
|
||||||
|
<div>
|
||||||
|
{{ t('page.progress.file.chunk') }}: {{ selectedUploadfile?.uploadInfo?.chunkLength }} x
|
||||||
|
{{ getFileSize(selectedUploadfile?.uploadInfo?.ChunkSize as number) }}
|
||||||
|
</div>
|
||||||
|
<div class="truncate col-span-2">Hash: {{ selectedUploadfile?.hash }}</div>
|
||||||
|
<div>{{ t('page.progress.file.completed') }}: {{ selectedUploadfileChunk?.filter((r) => r.status === 'success')?.length || 0 }}</div>
|
||||||
|
<div>{{ t('page.progress.file.discarded') }}: {{ selectedUploadfileChunk?.filter((r) => r.status === 'error')?.length || 0 }}</div>
|
||||||
|
<div>
|
||||||
|
{{ t('page.progress.file.pending') }}:
|
||||||
|
{{ (selectedUploadfile?.uploadInfo?.chunkLength || 0) - (selectedUploadfileChunk?.length || 0) }}
|
||||||
|
</div>
|
||||||
|
<div class="col-span-3 flex flex-row justify-between items-center">
|
||||||
|
<div class="text-md font-bold">
|
||||||
|
{{ selectedUploadfileViewMode === 'progress' ? t('page.progress.file.chunkProgress') : t('page.progress.file.chunkHeatmap') }}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
class="ml-auto text-xs"
|
||||||
|
@click="selectedUploadfileViewMode = selectedUploadfileViewMode === 'progress' ? 'heatmap' : 'progress'"
|
||||||
|
>
|
||||||
|
<LucideArrowDownUp class="size-4" />
|
||||||
|
{{ selectedUploadfileViewMode === 'progress' ? t('page.progress.file.heatmap') : t('page.progress.file.progressBar') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="h-7 col-span-3 flex flex-row gap-2 items-center" v-if="selectedUploadfileViewMode === 'progress'">
|
||||||
|
<div class="flex-1 h-full">
|
||||||
|
<FileUploadBlockProgressView :data="selectedUploadfile?.uploadInfo" />
|
||||||
|
</div>
|
||||||
|
{{ clamp(((selectedUploadfileChunk?.length || 0) / (selectedUploadfile?.uploadInfo?.chunkLength || 0)) * 100, 0, 100)?.toFixed(2) }}%
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedUploadfileViewMode === 'heatmap'" class="col-span-3 bg-black/5 rounded p-2">
|
||||||
|
<FileUploadHeatMapView :size="12" :data="selectedUploadfile?.uploadInfo" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2 pt-3">
|
||||||
|
<div class="text-xl font-bold">{{ t('page.progress.file.uploadSpeedInfo.title') }}</div>
|
||||||
|
<div class="opacity-75">
|
||||||
|
<i18n-t keypath="page.progress.file.uploadSpeedInfo.desc.base">
|
||||||
|
<template #chunkNum>
|
||||||
|
<b>{{ t('page.progress.file.uploadSpeedInfo.desc.chunkNum') }}</b>
|
||||||
|
</template>
|
||||||
|
<template #chunkSize>
|
||||||
|
<b>{{ t('page.progress.file.uploadSpeedInfo.desc.chunkSize') }}</b>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Uploadfile } from './types'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const { uploadfiles } = defineProps<{
|
||||||
|
uploadfiles: Uploadfile[]
|
||||||
|
}>()
|
||||||
|
const totalTaskStatus = computed(() => {
|
||||||
|
if (uploadfiles.some((r) => r.status === 'start')) {
|
||||||
|
return 'start'
|
||||||
|
}
|
||||||
|
if (uploadfiles.some((r) => r.status === 'pause')) {
|
||||||
|
return 'pause'
|
||||||
|
}
|
||||||
|
return 'disabled'
|
||||||
|
})
|
||||||
|
const totalUploadProgress = computed(() => {
|
||||||
|
const successCount = uploadfiles.reduce((acc, curr) => {
|
||||||
|
const { status, uploadInfo } = curr || {}
|
||||||
|
if (status === 'finish') return acc
|
||||||
|
const { chunks } = uploadInfo || {}
|
||||||
|
return acc + Object.entries(chunks || {}).filter(([index, chunk]) => chunk.status === 'success').length
|
||||||
|
}, 0)
|
||||||
|
const totalCount = uploadfiles.reduce((acc, curr) => {
|
||||||
|
const { status, uploadInfo } = curr || {}
|
||||||
|
if (status === 'finish') return acc
|
||||||
|
const { chunkLength } = uploadInfo || {}
|
||||||
|
return acc + (chunkLength || 0)
|
||||||
|
}, 0)
|
||||||
|
return ((successCount || 0) / (totalCount || 0)) * 100
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rounded-xl col-span-4 md:col-span-1 bg-white/80 h-32 md:h-auto md:aspect-square flex flex-col gap-2 relative overflow-hidden">
|
||||||
|
<div class="absolute top-0 left-0 w-full h-full z-[0] flex flex-col justify-end">
|
||||||
|
<div class="w-full bg-green-100 border-t border-green-500" :style="`height: ${totalUploadProgress || 0}%`"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2 justify-between p-3 h-full relative z-[1]">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="text-xs opacity-70">{{ t('page.progress.file.totalUploadProgress') }}</div>
|
||||||
|
<div class="text-4xl font-bold">{{ (totalUploadProgress || 0).toFixed(1) }}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row gap-2">
|
||||||
|
<Button
|
||||||
|
class="aspect-square hover:text-white p-0 bg-green-200 hover:bg-green-300 text-green-500"
|
||||||
|
:disabled="['start', 'disabled'].includes(totalTaskStatus)"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
uploadfiles.forEach((r) => {
|
||||||
|
r.status = 'start'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<LucidePlay class="size-1/2" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
class="aspect-square hover:text-white p-0 bg-orange-200 hover:bg-orange-300 text-orange-500"
|
||||||
|
:disabled="['pause', 'disabled'].includes(totalTaskStatus)"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
uploadfiles.forEach((r) => {
|
||||||
|
r.status = 'pause'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<LucideSquare class="size-1/2" />
|
||||||
|
</Button>
|
||||||
|
<!-- <Button class="aspect-square bg-blue-200 hover:bg-blue-300 text-blue-500 hover:text-white p-0">
|
||||||
|
<LucideSettings class="size-1/2" />
|
||||||
|
</Button> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { motion } from 'motion-v'
|
||||||
|
import getFileSize from '~/lib/getFileSize'
|
||||||
|
import showDrawer from '~/lib/showDrawer'
|
||||||
|
import FileUploadSpeedInfoView from './FileUploadSpeedInfoView.vue'
|
||||||
|
import { clamp } from 'lodash-es'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
speedChartData: Record<string, { fileId: string; index: number; value: number }[]>
|
||||||
|
}>()
|
||||||
|
const speedChartList = ref<{ timestamp: number; value: number }[]>([])
|
||||||
|
useIntervalFn(() => {
|
||||||
|
speedChartList.value.push({
|
||||||
|
timestamp: dayjs().unix() - 1,
|
||||||
|
value: props.speedChartData[dayjs().unix() - 1]?.reduce((acc, curr) => acc + curr.value, 0) ?? 0,
|
||||||
|
})
|
||||||
|
if (speedChartList.value.length > 60) {
|
||||||
|
speedChartList.value.shift()
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
const handleShowSpeedInfo = () => {
|
||||||
|
showDrawer({
|
||||||
|
render: ({ hide }) => h(FileUploadSpeedInfoView, { hide }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="rounded-xl p-3 bg-white/80 flex flex-col gap-2 col-span-4 md:col-span-3 h-32 md:h-auto">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div @click="handleShowSpeedInfo" class="flex flex-row gap-1 items-center text-xs opacity-70">
|
||||||
|
{{ t('page.progress.file.totalUploadProgress') }}
|
||||||
|
<LucideInfo class="size-3" />
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold">
|
||||||
|
{{ `${getFileSize(speedChartData[dayjs().unix() - 1]?.reduce((acc, curr) => acc + curr.value, 0) ?? 0) ?? 0}/s` }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 relative overflow-hidden flex flex-row gap-0.5 justify-end items-end">
|
||||||
|
<motion.div
|
||||||
|
class="w-2 shrink-0 bg-primary relative"
|
||||||
|
:style="{
|
||||||
|
height: `${clamp((i.value / Math.max(...(speedChartList?.map((r) => r.value) || [1]))) * 100, 1, 100)}%`,
|
||||||
|
}"
|
||||||
|
:layoutId="String(i.timestamp)"
|
||||||
|
v-for="i in speedChartList"
|
||||||
|
:key="i.timestamp"
|
||||||
|
:initial="{ x: 10, opacity: 0 }"
|
||||||
|
:animate="{ x: 0, opacity: 1 }"
|
||||||
|
:transition="{ duration: 1 }"
|
||||||
|
>
|
||||||
|
</motion.div>
|
||||||
|
<!-- <BarChart class="h-full" :data="data" index="time" :categories="['value']" :showTooltip="false"
|
||||||
|
:showLegend="false" :showXAxis="false" :showYAxis="false" :showGrid="false" :groupMaxWidth="10" /> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,21 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { LucidePlay, LucideSettings, LucideSquare } from 'lucide-vue-next'
|
import { LucideSquare, LucideInfo, LucideFolders, LucideArrowUpFromLine, LucideCircleX, LucideCheckCircle, LucideLoaderCircle } from 'lucide-vue-next'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import FileUploadBlockProgressView from '@/components/FileUploadBlockProgressView.vue'
|
|
||||||
import { motion } from 'motion-v'
|
|
||||||
import getFileSize from '~/lib/getFileSize'
|
import getFileSize from '~/lib/getFileSize'
|
||||||
import { cx } from 'class-variance-authority'
|
import { cx } from 'class-variance-authority'
|
||||||
import asyncWorker from '@/lib/asyncWorker'
|
import asyncWorker from '@/lib/asyncWorker'
|
||||||
import calcFileHashWorker from '@/lib/calcFileHashWorker?worker'
|
import calcFileHashWorker from '@/lib/calcFileHashWorker?worker'
|
||||||
import { clamp, get, groupBy, isEmpty, isNumber, isString, sample, shuffle, times } from 'lodash-es'
|
import { clamp, get, isEmpty, isNumber, sample, shuffle, throttle, times } from 'lodash-es'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import showDrawer from '~/lib/showDrawer'
|
import showDrawer from '~/lib/showDrawer'
|
||||||
|
import FileUploadTotalSpeedView from './FileUploadTotalSpeedView.vue'
|
||||||
|
import FileUploadTotalProgressControlView from './FileUploadTotalProgressControlView.vue'
|
||||||
import FileUploadSpeedInfoView from './FileUploadSpeedInfoView.vue'
|
import FileUploadSpeedInfoView from './FileUploadSpeedInfoView.vue'
|
||||||
import getFileChunk from '~/lib/getFileChunk'
|
import getFileChunk from '~/lib/getFileChunk'
|
||||||
import type { FileHandleKey } from '~/components/Preprocessing/types'
|
import type { FileHandleKey } from '~/components/Preprocessing/types'
|
||||||
import asyncWait from '@/lib/asyncWait'
|
import asyncWait from '@/lib/asyncWait'
|
||||||
|
import axios from 'axios'
|
||||||
|
import type { SelectedFile, Uploadfile } from './types'
|
||||||
|
import FileUploadDetailView from './FileUploadDetailView.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
data: { file: File[]; config: Record<string, any>; handle_type: FileHandleKey }
|
data: { file: File[]; config: Record<string, any>; handle_type: FileHandleKey }
|
||||||
@@ -24,81 +28,17 @@ const emit = defineEmits<{
|
|||||||
(e: 'change', key: string): void
|
(e: 'change', key: string): void
|
||||||
}>()
|
}>()
|
||||||
const form = useFormContext()
|
const form = useFormContext()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const selectedFile = ref()
|
const selectedFile = ref<SelectedFile>(null)
|
||||||
const uploadfiles = ref<
|
const uploadfiles = ref<Uploadfile[]>([])
|
||||||
{
|
|
||||||
fileId: string
|
|
||||||
id?: string // 后端文件id
|
|
||||||
file: File
|
|
||||||
status: 'start' | 'pause' | 'finish' | 'error'
|
|
||||||
hash?: string
|
|
||||||
procressType: 'hash' | 'create' | 'upload' | 'finish'
|
|
||||||
uploadInfo?: {
|
|
||||||
chunks: Record<number, { status: 'success' | 'error' | 'processing'; createdAt: number }>
|
|
||||||
chunkLength: number
|
|
||||||
ChunkSize: number
|
|
||||||
}
|
|
||||||
queue: {
|
|
||||||
taskId: string
|
|
||||||
taskType: 'hash' | 'create' | 'chunk' | 'upload' | 'finish'
|
|
||||||
queueType: 'sync' | 'async' // sync任务禁止并发
|
|
||||||
index?: number
|
|
||||||
retry?: number
|
|
||||||
}[]
|
|
||||||
}[]
|
|
||||||
>([])
|
|
||||||
const selectedUploadfile = computed(() => uploadfiles.value.find((item) => item.fileId === selectedFile.value))
|
|
||||||
const selectedUploadfileChunk = computed(() => Object.values(selectedUploadfile.value?.uploadInfo?.chunks || {}))
|
|
||||||
const selectedUploadfileViewMode = ref<'progress' | 'heatmap'>('progress')
|
|
||||||
|
|
||||||
const procressTaskList = ref<Map<string, any>>(new Map())
|
const procressTaskList = ref<Map<string, any>>(new Map())
|
||||||
const activeTaskList = computed(() => uploadfiles.value.filter((r) => r.queue.length > 0 && r.status === 'start'))
|
const activeTaskList = computed(() => uploadfiles.value.filter((r) => r.queue.length > 0 && r.status === 'start'))
|
||||||
const activeTaskAllQueue = computed(() => activeTaskList.value.flatMap((r) => r.queue))
|
const activeTaskAllQueue = computed(() => activeTaskList.value.flatMap((r) => r.queue))
|
||||||
const batchNum = ref(3)
|
const batchNum = ref(3)
|
||||||
|
|
||||||
const totalTaskStatus = computed(() => {
|
const speedChartData = ref<Record<number, { fileId: string; index: number; value: number }[]>>({})
|
||||||
if (uploadfiles.value.some((r) => r.status === 'start')) {
|
|
||||||
return 'start'
|
|
||||||
}
|
|
||||||
if (uploadfiles.value.some((r) => r.status === 'pause')) {
|
|
||||||
return 'pause'
|
|
||||||
}
|
|
||||||
return 'disabled'
|
|
||||||
})
|
|
||||||
const totalUploadProgress = computed(() => {
|
|
||||||
const successCount = uploadfiles.value.reduce((acc, curr) => {
|
|
||||||
const { status, uploadInfo } = curr || {}
|
|
||||||
if (status === 'finish') return acc
|
|
||||||
const { chunks } = uploadInfo || {}
|
|
||||||
return acc + Object.entries(chunks || {}).filter(([index, chunk]) => chunk.status === 'success').length
|
|
||||||
}, 0)
|
|
||||||
const totalCount = uploadfiles.value.reduce((acc, curr) => {
|
|
||||||
const { status, uploadInfo } = curr || {}
|
|
||||||
if (status === 'finish') return acc
|
|
||||||
const { chunkLength } = uploadInfo || {}
|
|
||||||
return acc + (chunkLength || 0)
|
|
||||||
}, 0)
|
|
||||||
return ((successCount || 0) / (totalCount || 0)) * 100
|
|
||||||
})
|
|
||||||
|
|
||||||
const counter = useInterval(1000)
|
|
||||||
const speedChartData = ref<Record<number, { createdAt: number; value: number }[]>>({})
|
|
||||||
watch(counter, () => {
|
|
||||||
const speed = uploadfiles.value?.flatMap((item) => {
|
|
||||||
const { chunks, ChunkSize } = item?.uploadInfo || {}
|
|
||||||
return Object.entries(chunks || {})
|
|
||||||
.filter(([index, chunk]) => chunk.status === 'success' && dayjs().unix() - 60 < chunk.createdAt)
|
|
||||||
?.map(([index, chunk]) => {
|
|
||||||
const { createdAt } = chunk || {}
|
|
||||||
return {
|
|
||||||
createdAt,
|
|
||||||
value: ChunkSize || 0,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
speedChartData.value = groupBy(speed, 'createdAt')
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
props.data.file.forEach((file) => {
|
props.data.file.forEach((file) => {
|
||||||
@@ -136,10 +76,10 @@ const { error, execute, isLoading } = useAsyncState(
|
|||||||
|
|
||||||
if (queueType === 'async') {
|
if (queueType === 'async') {
|
||||||
if (!!retry && retry >= 3) {
|
if (!!retry && retry >= 3) {
|
||||||
toast.error('上传错误', {
|
toast.error(t('page.progress.file.uploadError'), {
|
||||||
description: `文件 ${file?.file?.name} 的${index}分块经过多次尝试依然上传失败, 我们已经终止该文件上传`,
|
description: t('page.progress.file.chunkUploadFailed', [file?.file?.name, index]),
|
||||||
})
|
})
|
||||||
uploadfiles.value[uploadFileIndex].status = 'error'
|
uploadfiles.value[uploadFileIndex]!.status = 'error'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uploadfiles.value[uploadFileIndex]?.queue.shift()
|
uploadfiles.value[uploadFileIndex]?.queue.shift()
|
||||||
@@ -170,14 +110,14 @@ const { error, execute, isLoading } = useAsyncState(
|
|||||||
// todo 重新加入队列
|
// todo 重新加入队列
|
||||||
if (queueType === 'async') {
|
if (queueType === 'async') {
|
||||||
uploadfiles.value[uploadFileIndex]?.queue.push({ ...task, retry: (task?.retry || 0) + 1 })
|
uploadfiles.value[uploadFileIndex]?.queue.push({ ...task, retry: (task?.retry || 0) + 1 })
|
||||||
toast.warning('上传错误', {
|
toast.warning(t('page.progress.file.uploadError'), {
|
||||||
description: `文件 ${file?.file?.name} 的${index}分块上传失败, 我们将在稍后再次尝试上传`,
|
description: t('page.progress.file.chunkUploadRetry', [file?.file?.name, index]),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (queueType === 'sync') {
|
if (queueType === 'sync') {
|
||||||
uploadfiles.value[uploadFileIndex].status = 'error'
|
uploadfiles.value[uploadFileIndex]!.status = 'error'
|
||||||
toast.error('上传错误', {
|
toast.error(t('page.progress.file.uploadError'), {
|
||||||
description: `文件${file?.file?.name}上传失败, 请重试`,
|
description: t('page.progress.file.fileUploadFailed', [file?.file?.name]),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -218,6 +158,7 @@ const handleCreate = async (fileId: string) => {
|
|||||||
id: string
|
id: string
|
||||||
type: 'init' | 'already'
|
type: 'init' | 'already'
|
||||||
chunk_size: number
|
chunk_size: number
|
||||||
|
chunks: number[]
|
||||||
}
|
}
|
||||||
}>('/api/file/create', {
|
}>('/api/file/create', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -227,10 +168,10 @@ const handleCreate = async (fileId: string) => {
|
|||||||
hash,
|
hash,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const { id, chunk_size, type: createType } = createData?.data || {}
|
const { id, chunk_size, type: createType, chunks = [] } = createData?.data || {}
|
||||||
uploadfile.id = id
|
uploadfile.id = id
|
||||||
uploadfile.uploadInfo = {
|
uploadfile.uploadInfo = {
|
||||||
chunks: {},
|
chunks: Object.fromEntries(chunks.map((index: number) => [index - 1, { status: 'success', createdAt: dayjs().unix() }])),
|
||||||
chunkLength: Math.ceil(size / chunk_size),
|
chunkLength: Math.ceil(size / chunk_size),
|
||||||
ChunkSize: chunk_size,
|
ChunkSize: chunk_size,
|
||||||
}
|
}
|
||||||
@@ -277,17 +218,18 @@ const handleUpload = async (fileId: string, index: number) => {
|
|||||||
formData.append('file', new Blob([chunk]))
|
formData.append('file', new Blob([chunk]))
|
||||||
formData.append('index', (index + 1).toString())
|
formData.append('index', (index + 1).toString())
|
||||||
formData.append('id', id)
|
formData.append('id', id)
|
||||||
const res = await $fetch<{
|
const { data: res } = await axios.post('/api/file/slice', formData, {
|
||||||
code: number
|
onUploadProgress: throttle((progressEvent) => {
|
||||||
}>('/api/file/slice', {
|
const { rate } = progressEvent || {}
|
||||||
method: 'POST',
|
const timestamp = dayjs().unix()
|
||||||
body: formData,
|
speedChartData.value[timestamp] = [...(speedChartData.value[timestamp] || []), { fileId, index, value: rate || 0 }]
|
||||||
|
}, 1000),
|
||||||
})
|
})
|
||||||
const { code } = res || {}
|
const { code } = res || {}
|
||||||
if (code !== 200) {
|
if (code !== 200) {
|
||||||
throw new Error('上传失败')
|
throw new Error(t('page.progress.file.uploadFailed'))
|
||||||
}
|
}
|
||||||
uploadfile.uploadInfo!.chunks[index].status = 'success'
|
uploadfile.uploadInfo!.chunks![index]!.status = 'success'
|
||||||
if (Object.entries(uploadfile.uploadInfo!.chunks || {}).filter(([index, chunk]) => chunk.status === 'success').length === chunkLength) {
|
if (Object.entries(uploadfile.uploadInfo!.chunks || {}).filter(([index, chunk]) => chunk.status === 'success').length === chunkLength) {
|
||||||
uploadfile.queue.push({ taskType: 'finish', queueType: 'sync', taskId: nanoid() })
|
uploadfile.queue.push({ taskType: 'finish', queueType: 'sync', taskId: nanoid() })
|
||||||
}
|
}
|
||||||
@@ -307,7 +249,7 @@ const handleFinish = async (fileId: string) => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (res?.code !== 200) {
|
if (res?.code !== 200) {
|
||||||
throw new Error('上传失败')
|
throw new Error(t('page.progress.file.uploadFailed'))
|
||||||
}
|
}
|
||||||
uploadfile.status = 'finish'
|
uploadfile.status = 'finish'
|
||||||
}
|
}
|
||||||
@@ -341,102 +283,29 @@ const handleShowSpeedInfo = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="grid grid-cols-4 gap-5">
|
<BaseCard class="grid grid-cols-4 gap-5">
|
||||||
<div class="rounded-xl p-3 bg-white/80 flex flex-col gap-2 col-span-4 md:col-span-3 h-32 md:h-auto">
|
<FileUploadTotalSpeedView :speedChartData="speedChartData" />
|
||||||
<div class="flex flex-col gap-1">
|
<FileUploadTotalProgressControlView :uploadfiles="uploadfiles" />
|
||||||
<div @click="handleShowSpeedInfo" class="flex flex-row gap-1 items-center text-xs opacity-70">
|
|
||||||
总上传进度
|
|
||||||
<LucideInfo class="size-3" />
|
|
||||||
</div>
|
|
||||||
<div class="text-2xl font-bold">
|
|
||||||
{{
|
|
||||||
`${
|
|
||||||
getFileSize(
|
|
||||||
Object.entries(speedChartData)
|
|
||||||
?.filter((r) => dayjs().unix() - 60 < parseInt(r[0]))
|
|
||||||
?.reduce((acc, curr) => acc + curr[1]?.reduce((_acc, _curr) => _acc + _curr.value, 0), 0) / 60
|
|
||||||
) ?? 0
|
|
||||||
}/s`
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 relative overflow-hidden flex flex-row gap-0.5 justify-end items-end">
|
|
||||||
<motion.div
|
|
||||||
class="w-2 shrink-0 bg-primary relative"
|
|
||||||
:style="{
|
|
||||||
height: `${(i[1]?.reduce((acc, curr) => acc + curr.value, 0) / Math.max(...Object.entries(speedChartData)?.map((r) => r[1]?.reduce((acc, curr) => acc + curr.value, 0)))) * 100}%`,
|
|
||||||
}"
|
|
||||||
:layoutId="i[0]"
|
|
||||||
v-for="i in Object.entries(speedChartData)"
|
|
||||||
:key="i[0]"
|
|
||||||
:initial="{ x: 10, opacity: 0 }"
|
|
||||||
:animate="{ x: 0, opacity: 1 }"
|
|
||||||
:transition="{ duration: 1 }"
|
|
||||||
>
|
|
||||||
</motion.div>
|
|
||||||
<!-- <BarChart class="h-full" :data="data" index="time" :categories="['value']" :showTooltip="false"
|
|
||||||
:showLegend="false" :showXAxis="false" :showYAxis="false" :showGrid="false" :groupMaxWidth="10" /> -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-xl col-span-4 md:col-span-1 bg-white/80 h-32 md:h-auto md:aspect-square flex flex-col gap-2 relative overflow-hidden">
|
|
||||||
<div class="absolute top-0 left-0 w-full h-full z-[0] flex flex-col justify-end">
|
|
||||||
<div class="w-full bg-green-100 border-t border-green-500" :style="`height: ${totalUploadProgress || 0}%`"></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-2 justify-between p-3 h-full relative z-[1]">
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<div class="text-xs opacity-70">总上传进度</div>
|
|
||||||
<div class="text-4xl font-bold">{{ (totalUploadProgress || 0).toFixed(1) }}%</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row gap-2">
|
|
||||||
<Button
|
|
||||||
class="aspect-square hover:text-white p-0 bg-green-200 hover:bg-green-300 text-green-500"
|
|
||||||
:disabled="['start', 'disabled'].includes(totalTaskStatus)"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
uploadfiles.forEach((r) => {
|
|
||||||
r.status = 'start'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<LucidePlay class="size-1/2" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
class="aspect-square hover:text-white p-0 bg-orange-200 hover:bg-orange-300 text-orange-500"
|
|
||||||
:disabled="['pause', 'disabled'].includes(totalTaskStatus)"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
uploadfiles.forEach((r) => {
|
|
||||||
r.status = 'pause'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<LucideSquare class="size-1/2" />
|
|
||||||
</Button>
|
|
||||||
<!-- <Button class="aspect-square bg-blue-200 hover:bg-blue-300 text-blue-500 hover:text-white p-0">
|
|
||||||
<LucideSettings class="size-1/2" />
|
|
||||||
</Button> -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-4 flex flex-col bg-white/80 rounded-xl p-3 text-md gap-5">
|
<div class="col-span-4 flex flex-col bg-white/80 rounded-xl p-3 text-md gap-5">
|
||||||
<div>文件列表</div>
|
<div class="flex flex-row gap-2 items-center">
|
||||||
|
<LucideFolders class="size-4" />
|
||||||
|
{{ t('page.progress.file.fileList') }}
|
||||||
|
</div>
|
||||||
<div class="flex flex-col -mx-3 text-sm">
|
<div class="flex flex-col -mx-3 text-sm">
|
||||||
<div class="grid grid-cols-[2fr_6rem_6rem] md:grid-cols-[2fr_6rem_6rem_4fr] gap-2 border-b border-black/20 pb-2 px-3">
|
<div class="grid grid-cols-[2fr_6rem_6rem] md:grid-cols-[2fr_6rem_6rem_4fr] gap-2 border-b border-black/20 pb-2 px-3">
|
||||||
<div>文件名</div>
|
<div>{{ t('page.progress.file.fileName') }}</div>
|
||||||
<div>文件大小</div>
|
<div>{{ t('page.progress.file.fileSize') }}</div>
|
||||||
<div @click="handleShowSpeedInfo" class="flex flex-row gap-1 items-center">
|
<div @click="handleShowSpeedInfo" class="flex flex-row gap-1 items-center">
|
||||||
上传速度
|
{{ t('page.progress.file.uploadSpeed') }}
|
||||||
<LucideInfo class="size-3" />
|
<LucideInfo class="size-3" />
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden md:block">进度</div>
|
<div class="hidden md:block">{{ t('page.progress.file.progress') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
:class="
|
:class="
|
||||||
cx(
|
cx(
|
||||||
'grid grid-cols-[2fr_6rem_6rem] md:grid-cols-[2fr_6rem_6rem_4fr] gap-2 py-2 border-b border-black/20 items-center hover:bg-primary/30 px-3 cursor-pointer',
|
'grid grid-cols-[2fr_6rem_6rem] md:grid-cols-[2fr_6rem_6rem_4fr] gap-2 py-2 border-b border-black/20 items-center hover:bg-primary/30 px-3 cursor-pointer',
|
||||||
selectedFile === item?.fileId && 'bg-primary text-white hover:!bg-primary'
|
selectedFile === item?.fileId && 'bg-primary text-white hover:bg-primary!'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
v-for="(item, index) in uploadfiles"
|
v-for="(item, index) in uploadfiles"
|
||||||
@@ -459,9 +328,9 @@ const handleShowSpeedInfo = () => {
|
|||||||
(e: Event) => {
|
(e: Event) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (item?.status === 'start') {
|
if (item?.status === 'start') {
|
||||||
uploadfiles[index].status = 'pause'
|
uploadfiles[index]!.status = 'pause'
|
||||||
} else {
|
} else {
|
||||||
uploadfiles[index].status = 'start'
|
uploadfiles[index]!.status = 'start'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
@@ -475,26 +344,26 @@ const handleShowSpeedInfo = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>{{ getFileSize(item?.file?.size) }}</div>
|
<div>{{ getFileSize(item?.file?.size) }}</div>
|
||||||
<div>
|
<div>
|
||||||
{{
|
<template v-if="item?.status === 'start' && item?.procressType === 'upload'">
|
||||||
`${getFileSize(
|
{{
|
||||||
(Object.entries(item?.uploadInfo?.chunks || {})?.filter(
|
`${getFileSize(
|
||||||
([, chunk]) => chunk.status === 'success' && dayjs().unix() - 60 < chunk.createdAt
|
Object.entries(speedChartData)
|
||||||
)?.length /
|
?.filter(([key, value]) => value.some((r) => r.fileId === item?.fileId) && dayjs().unix() - 1 === Number(key))
|
||||||
60) *
|
?.reduce((acc, curr) => acc + curr[1]?.reduce((acc, curr) => acc + curr.value, 0), 0) ?? 0
|
||||||
(item?.uploadInfo?.ChunkSize || 0)
|
)} /s`
|
||||||
)} /s`
|
}}
|
||||||
}}
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex flex-row gap-2 items-center col-span-3 md:col-span-1"
|
class="flex flex-row gap-2 items-center col-span-3 md:col-span-1"
|
||||||
v-if="['hash', 'create', 'chunk']?.includes(item?.procressType)"
|
v-if="['hash', 'create', 'chunk']?.includes(item?.procressType)"
|
||||||
>
|
>
|
||||||
<LucideLoaderCircle class="size-4 animate-spin" />
|
<LucideLoaderCircle class="size-4 animate-spin" />
|
||||||
<div>正在{{ item?.procressType }}中...</div>
|
<div>{{ t(`page.progress.file.processing.${item?.procressType}`) }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row gap-2 items-center col-span-3 md:col-span-1" v-if="item?.procressType === 'finish'">
|
<div class="flex flex-row gap-2 items-center col-span-3 md:col-span-1" v-if="item?.procressType === 'finish'">
|
||||||
{{ item?.status === 'finish' ? '云端已有相同hash文件, 秒传成功' : null }}
|
{{ item?.status === 'finish' ? t('page.progress.file.instantUploadSuccess') : null }}
|
||||||
{{ item?.status === 'error' ? '上传失败,请稍后重试' : null }}
|
{{ item?.status === 'error' ? t('page.progress.file.uploadFailedRetry') : null }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row gap-2 items-center col-span-3 md:col-span-1" v-if="item?.procressType === 'upload'">
|
<div class="flex flex-row gap-2 items-center col-span-3 md:col-span-1" v-if="item?.procressType === 'upload'">
|
||||||
<div class="rounded-full bg-white/50 w-full h-2 overflow-hidden border border-white">
|
<div class="rounded-full bg-white/50 w-full h-2 overflow-hidden border border-white">
|
||||||
@@ -526,44 +395,6 @@ const handleShowSpeedInfo = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-4 flex flex-col bg-white/80 rounded-xl p-3 gap-5" v-if="selectedFile">
|
<FileUploadDetailView :uploadfiles="uploadfiles" :selectedFile="selectedFile" />
|
||||||
<div>上传详情</div>
|
</BaseCard>
|
||||||
<div class="grid grid-cols-3 text-sm gap-3">
|
|
||||||
<div>
|
|
||||||
区块: {{ selectedUploadfile?.uploadInfo?.chunkLength }} x {{ getFileSize(selectedUploadfile?.uploadInfo?.ChunkSize as number) }}
|
|
||||||
</div>
|
|
||||||
<div class="truncate col-span-2">hash: {{ selectedUploadfile?.hash }}</div>
|
|
||||||
<div>已完成: {{ selectedUploadfileChunk?.filter((r) => r.status === 'success')?.length || 0 }}</div>
|
|
||||||
<div>已丢弃: {{ selectedUploadfileChunk?.filter((r) => r.status === 'error')?.length || 0 }}</div>
|
|
||||||
<div>
|
|
||||||
待完成:
|
|
||||||
{{ (selectedUploadfile?.uploadInfo?.chunkLength || 0) - (selectedUploadfileChunk?.length || 0) }}
|
|
||||||
</div>
|
|
||||||
<div class="col-span-3 flex flex-row justify-between items-center">
|
|
||||||
<div class="text-md font-bold">{{ selectedUploadfileViewMode === 'progress' ? '区块进度条' : '区块热力图' }}</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
class="ml-auto text-xs"
|
|
||||||
@click="selectedUploadfileViewMode = selectedUploadfileViewMode === 'progress' ? 'heatmap' : 'progress'"
|
|
||||||
>
|
|
||||||
<LucideArrowDownUp class="size-4" />
|
|
||||||
{{ selectedUploadfileViewMode === 'progress' ? '热力图' : '进度条' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div class="h-7 col-span-3 flex flex-row gap-2 items-center" v-if="selectedUploadfileViewMode === 'progress'">
|
|
||||||
<div class="flex-1 h-full">
|
|
||||||
<FileUploadBlockProgressView :data="selectedUploadfile?.uploadInfo" />
|
|
||||||
</div>
|
|
||||||
{{
|
|
||||||
clamp(((selectedUploadfileChunk?.length || 0) / (selectedUploadfile?.uploadInfo?.chunkLength || 0)) * 100, 0, 100)?.toFixed(
|
|
||||||
2
|
|
||||||
)
|
|
||||||
}}%
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedUploadfileViewMode === 'heatmap'" class="col-span-3 bg-black/5 rounded p-2">
|
|
||||||
<FileUploadHeatMapView :size="12" :data="selectedUploadfile?.uploadInfo" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
24
front/components/Home/File/FileUploadProgressView/types.ts
Normal file
24
front/components/Home/File/FileUploadProgressView/types.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
type Uploadfile = {
|
||||||
|
fileId: string
|
||||||
|
id?: string // 后端文件id
|
||||||
|
file: File
|
||||||
|
status: 'start' | 'pause' | 'finish' | 'error'
|
||||||
|
hash?: string
|
||||||
|
procressType: 'hash' | 'create' | 'upload' | 'finish'
|
||||||
|
uploadInfo?: {
|
||||||
|
chunks: Record<number, { status: 'success' | 'error' | 'processing'; createdAt: number }>
|
||||||
|
chunkLength: number
|
||||||
|
ChunkSize: number
|
||||||
|
}
|
||||||
|
queue: {
|
||||||
|
taskId: string
|
||||||
|
taskType: 'hash' | 'create' | 'chunk' | 'upload' | 'finish'
|
||||||
|
queueType: 'sync' | 'async' // sync任务禁止并发
|
||||||
|
index?: number
|
||||||
|
retry?: number
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type SelectedFile = string | null
|
||||||
|
|
||||||
|
export type { Uploadfile, SelectedFile }
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col gap-2 pt-3">
|
|
||||||
<div class="text-xl font-bold">上传速度如何计算</div>
|
|
||||||
<div class="opacity-75">
|
|
||||||
上传速度根据当前秒上传了 <b>文件区块的数量</b> * <b>每个文件区块的大小</b> 估算而来,可能与真实上传速度有一定的误差,仅供参考
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -14,19 +14,26 @@ const renderComponent = computed(() => {
|
|||||||
return textStepList.find((item) => item.key === step.value)?.component
|
return textStepList.find((item) => item.key === step.value)?.component
|
||||||
})
|
})
|
||||||
const formRef = ref<InstanceType<typeof VeeForm>>()
|
const formRef = ref<InstanceType<typeof VeeForm>>()
|
||||||
watch(() => step.value, (newVal) => {
|
watch(
|
||||||
if (newVal === 'input') {
|
() => step.value,
|
||||||
formRef.value?.form?.resetForm()
|
(newVal) => {
|
||||||
// formRef.value?.form?.setValues({ file: null })
|
if (newVal === 'input') {
|
||||||
|
formRef.value?.form?.resetForm()
|
||||||
|
// formRef.value?.form?.setValues({ file: null })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<VeeForm ref="formRef" v-slot="{ values }" :keepValues="true">
|
<VeeForm ref="formRef" v-slot="{ values }" :keepValues="true">
|
||||||
<div class="rounded-xl p-5 bg-white/50 backdrop-blur-xl w-full lg:w-200">
|
<component
|
||||||
<component :is="renderComponent" :data="values" @change="(key: string) => {
|
:is="renderComponent"
|
||||||
step = key
|
:data="values"
|
||||||
}" />
|
@change="
|
||||||
</div>
|
(key: string) => {
|
||||||
|
step = key
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
</VeeForm>
|
</VeeForm>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -21,13 +21,12 @@ const handleTextShare = ({ type, config }: { type: string; config: any }) => {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="gap-5 flex flex-col">
|
<BaseCard class="gap-5 flex flex-col" :title="t('page.upload.text.uploadText')">
|
||||||
<div class="text-xl font-normal">{{ t('text.uploadText') }}</div>
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<MarkdownInputField
|
<MarkdownInputField
|
||||||
name="text"
|
name="text"
|
||||||
:placeholder="t('text.uploadTextPlaceholder')"
|
:placeholder="t('page.upload.text.uploadTextPlaceholder')"
|
||||||
class="max-h-[50vh] min-h-40 overflow-y-auto max-w-full [&>*]:pr-10 flex flex-col"
|
class="max-h-[50vh] min-h-40 overflow-y-auto max-w-full *:pr-10 flex flex-col"
|
||||||
rules="required"
|
rules="required"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -68,5 +67,5 @@ const handleTextShare = ({ type, config }: { type: string; config: any }) => {
|
|||||||
</FormButton>
|
</FormButton>
|
||||||
<PickupShareBtn />
|
<PickupShareBtn />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</BaseCard>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const renderHtml = computed(() => {
|
|||||||
<div
|
<div
|
||||||
:class="
|
:class="
|
||||||
cx(
|
cx(
|
||||||
'prose prose-sm [&>*]:outline-none prose-p:my-1 prose-headings:my-2 prose-pre:mb-0 prose-blockquote:border-black/50 selection:bg-primary/20',
|
'prose prose-sm [&>*]:outline-none prose-p:my-1 prose-headings:my-2 prose-pre:mb-0 prose-blockquote:border-black/50 selection:bg-primary/20 break-all',
|
||||||
props?.class
|
props?.class
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -1,90 +1,81 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="flex flex-row bg-white/50 backdrop-blur-xl p-2 rounded-full gap-1 sticky top-0 z-10">
|
||||||
class="flex flex-row bg-white/50 backdrop-blur-xl p-2 rounded-full gap-1 sticky top-0 z-10"
|
<div
|
||||||
>
|
v-for="item in routes"
|
||||||
<div
|
:key="item.key"
|
||||||
v-for="item in routes"
|
:class="
|
||||||
:key="item.key"
|
cx(
|
||||||
:class="
|
'flex flex-row items-center text-sm px-4 py-2 font-bold rounded-full relative select-none cursor-pointer',
|
||||||
cx(
|
!isActive(item) && 'hover:bg-black/5',
|
||||||
'flex flex-row items-center text-sm px-4 py-2 font-bold rounded-full relative select-none cursor-pointer',
|
item?.name && 'gap-2',
|
||||||
!isActive(item) && 'hover:bg-black/5',
|
item?.className
|
||||||
item?.name && 'gap-2',
|
)
|
||||||
item?.className,
|
"
|
||||||
)
|
@click="handleClick(item)"
|
||||||
"
|
>
|
||||||
@click="handleClick(item)"
|
<motion.div v-if="isActive(item)" layoutId="navbar-active" class="absolute inset-0 rounded-full w-full h-full bg-black/10" />
|
||||||
>
|
<component :is="item.icon" />
|
||||||
<motion.div
|
<div class="hidden sm:block">{{ item.name }}</div>
|
||||||
v-if="isActive(item)"
|
</div>
|
||||||
layoutId="navbar-active"
|
|
||||||
class="absolute inset-0 rounded-full w-full h-full bg-black/10"
|
|
||||||
/>
|
|
||||||
<component :is="item.icon" />
|
|
||||||
<div class="hidden sm:block">{{ item.name }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { cx } from "class-variance-authority";
|
import { cx } from 'class-variance-authority'
|
||||||
import { LucideClipboardType, LucidePaperclip } from "#components";
|
import { LucideClipboardType, LucidePaperclip } from '#components'
|
||||||
import { motion } from "motion-v";
|
import { motion } from 'motion-v'
|
||||||
import { LucideGlobe } from "lucide-vue-next";
|
import { LucideGlobe } from 'lucide-vue-next'
|
||||||
import showDrawer from "@/lib/showDrawer";
|
import showDrawer from '@/lib/showDrawer'
|
||||||
import I18nSwitchDrawer from "./Drawer/I18nSwitchDrawer.vue";
|
import I18nSwitchDrawer from './Drawer/I18nSwitchDrawer.vue'
|
||||||
const { t } = useI18n();
|
const { t } = useI18n()
|
||||||
const routes = [
|
const routes = computed(() => [
|
||||||
{
|
{
|
||||||
key: "about",
|
key: 'about',
|
||||||
icon: () =>
|
icon: () =>
|
||||||
h("img", {
|
h('img', {
|
||||||
class: "size-10 rounded-full border-2 border-white/50",
|
class: 'size-10 rounded-full border-2 border-white/50',
|
||||||
src: "/logo.png",
|
src: '/logo.png',
|
||||||
}),
|
}),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
router.push("/about");
|
router.push('/about')
|
||||||
|
},
|
||||||
|
isActive: (item: { key: string }) => route.path?.endsWith(item.key),
|
||||||
|
className: '!p-1.5',
|
||||||
},
|
},
|
||||||
isActive: (item: { key: string }) => route.path?.endsWith(item.key),
|
{ name: t('navbar.file'), key: 'file', icon: LucidePaperclip },
|
||||||
className: "!p-1.5",
|
{ name: t('navbar.text'), key: 'text', icon: LucideClipboardType },
|
||||||
},
|
{
|
||||||
{ name: t("navbar.file"), key: "file", icon: LucidePaperclip },
|
key: 'i18n',
|
||||||
{ name: t("navbar.text"), key: "text", icon: LucideClipboardType },
|
icon: LucideGlobe,
|
||||||
{
|
onClick: () => {
|
||||||
key: "i18n",
|
showDrawer({
|
||||||
icon: LucideGlobe,
|
render: (props) => h(I18nSwitchDrawer, { ...props }),
|
||||||
onClick: () => {
|
})
|
||||||
showDrawer({
|
},
|
||||||
render: () => h(I18nSwitchDrawer),
|
className: 'size-12 !p-1.5 justify-center items-center',
|
||||||
});
|
|
||||||
},
|
},
|
||||||
className: "size-12 !p-1.5 justify-center items-center",
|
])
|
||||||
},
|
const route = useRoute()
|
||||||
];
|
const router = useRouter()
|
||||||
const route = useRoute();
|
const type = computed(() => route?.query?.type)
|
||||||
const router = useRouter();
|
|
||||||
const type = computed(() => route?.query?.type);
|
|
||||||
|
|
||||||
const isActive = (item: {
|
const isActive = (item: { key: string; isActive?: (item: { key: string }) => boolean }) => {
|
||||||
key: string;
|
const { key, isActive } = item || {}
|
||||||
isActive?: (item: { key: string }) => boolean;
|
return isActive ? isActive(item) : type.value === key
|
||||||
}) => {
|
}
|
||||||
const { key, isActive } = item || {};
|
|
||||||
return isActive ? isActive(item) : type.value === key;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClick = (item: { key: string; onClick?: () => void }) => {
|
const handleClick = (item: { key: string; onClick?: () => void }) => {
|
||||||
const { key, onClick } = item || {};
|
const { key, onClick } = item || {}
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
onClick();
|
onClick()
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
router.push({
|
router.push({
|
||||||
path: "/",
|
path: '/',
|
||||||
query: {
|
query: {
|
||||||
...route.query,
|
...route.query,
|
||||||
type: key,
|
type: key,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -16,6 +16,6 @@ const { t } = useI18n()
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<LucideArchive class="size-4" />
|
<LucideArchive class="size-4" />
|
||||||
{{ t('pickup.btn') }}
|
{{ t('page.upload.pickup.btn') }}
|
||||||
</AsyncButton>
|
</AsyncButton>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,37 +15,37 @@ const props = defineProps<{
|
|||||||
<template>
|
<template>
|
||||||
<VeeForm v-slot="{ values, setFieldValue }" :initialValues="{ download_nums: 1, expire_time: 1440 }">
|
<VeeForm v-slot="{ values, setFieldValue }" :initialValues="{ download_nums: 1, expire_time: 1440 }">
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<h2 class="text-lg font-bold">{{ t('fileshare.title') }}</h2>
|
<h2 class="text-lg font-bold">{{ t('page.shareOptions.file.title') }}</h2>
|
||||||
<div class="flex flex-row items-center gap-2 text-sm">
|
<div class="flex flex-row items-center gap-2 text-sm">
|
||||||
<SelectField
|
<SelectField
|
||||||
name="download_nums"
|
name="download_nums"
|
||||||
:label="t('fileshare.downloadNums')"
|
:label="t('page.shareOptions.file.downloadNums')"
|
||||||
:options="[
|
:options="[
|
||||||
{ label: t('fileshare.downloadOptions.1time'), value: 1 },
|
{ label: t('page.shareOptions.file.downloadOptions.xdownload', [1]), value: 1 },
|
||||||
{ label: t('fileshare.downloadOptions.2times'), value: 2 },
|
{ label: t('page.shareOptions.file.downloadOptions.xdownload', [2]), value: 2 },
|
||||||
{ label: t('fileshare.downloadOptions.3times'), value: 3 },
|
{ label: t('page.shareOptions.file.downloadOptions.xdownload', [3]), value: 3 },
|
||||||
{ label: t('fileshare.downloadOptions.5times'), value: 5 },
|
{ label: t('page.shareOptions.file.downloadOptions.xdownload', [5]), value: 5 },
|
||||||
{ label: t('fileshare.downloadOptions.10times'), value: 10 },
|
{ label: t('page.shareOptions.file.downloadOptions.xdownload', [10]), value: 10 },
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
{{ t('fileshare.or') }}
|
{{ t('page.shareOptions.file.or') }}
|
||||||
<SelectField
|
<SelectField
|
||||||
name="expire_time"
|
name="expire_time"
|
||||||
:label="t('fileshare.expireTime')"
|
:label="t('page.shareOptions.file.expireTime')"
|
||||||
:options="[
|
:options="[
|
||||||
{ label: t('fileshare.expireOptions.5min'), value: 5 },
|
{ label: t('page.shareOptions.file.expireOptions.5min'), value: 5 },
|
||||||
{ label: t('fileshare.expireOptions.1hour'), value: 60 },
|
{ label: t('page.shareOptions.file.expireOptions.1hour'), value: 60 },
|
||||||
{ label: t('fileshare.expireOptions.1day'), value: 1440 },
|
{ label: t('page.shareOptions.file.expireOptions.1day'), value: 1440 },
|
||||||
{ label: t('fileshare.expireOptions.3days'), value: 4320 },
|
{ label: t('page.shareOptions.file.expireOptions.3days'), value: 4320 },
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
{{ t('fileshare.expireAfter') }}
|
{{ t('page.shareOptions.file.expireAfter') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<div class="flex flex-row gap-3 min-h-9">
|
<div class="flex flex-row gap-3 min-h-9">
|
||||||
<SwitchField
|
<SwitchField
|
||||||
name="has_pickup_code"
|
name="has_pickup_code"
|
||||||
:label="t('fileshare.pickupCode')"
|
:label="t('page.shareOptions.file.pickupCode')"
|
||||||
:rules="
|
:rules="
|
||||||
(value: boolean) => {
|
(value: boolean) => {
|
||||||
if (!!value) {
|
if (!!value) {
|
||||||
@@ -59,7 +59,7 @@ const props = defineProps<{
|
|||||||
<div class="flex flex-row gap-3 min-h-9">
|
<div class="flex flex-row gap-3 min-h-9">
|
||||||
<SwitchField
|
<SwitchField
|
||||||
name="has_password"
|
name="has_password"
|
||||||
:label="t('fileshare.passwordProtection')"
|
:label="t('page.shareOptions.file.passwordProtection')"
|
||||||
:rules="
|
:rules="
|
||||||
(value: boolean) => {
|
(value: boolean) => {
|
||||||
if (!!value) {
|
if (!!value) {
|
||||||
@@ -69,11 +69,21 @@ const props = defineProps<{
|
|||||||
}
|
}
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<InputField v-if="!!values.has_password" name="password" :placeholder="t('fileshare.passwordPlaceholder')" rules="required" />
|
<InputField
|
||||||
|
v-if="!!values.has_password"
|
||||||
|
name="password"
|
||||||
|
:placeholder="t('page.shareOptions.file.passwordPlaceholder')"
|
||||||
|
rules="required"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row gap-3 min-h-9">
|
<div class="flex flex-row gap-3 min-h-9">
|
||||||
<SwitchField name="has_notify" :label="t('fileshare.downloadNotify')" />
|
<SwitchField name="has_notify" :label="t('page.shareOptions.file.downloadNotify')" />
|
||||||
<InputField v-if="!!values.has_notify" name="notify_email" :placeholder="t('fileshare.emailPlaceholder')" rules="required" />
|
<InputField
|
||||||
|
v-if="!!values.has_notify"
|
||||||
|
name="notify_email"
|
||||||
|
:placeholder="t('page.shareOptions.file.emailPlaceholder')"
|
||||||
|
rules="required"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FormButton
|
<FormButton
|
||||||
|
|||||||
43
front/components/Preprocessing/ImageConvertHandle.vue
Normal file
43
front/components/Preprocessing/ImageConvertHandle.vue
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SelectField from '../Field/SelectField.vue'
|
||||||
|
import FormButton from '../Field/FormButton.vue'
|
||||||
|
import type { FileShareHandleProps } from './types'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const props = defineProps<{
|
||||||
|
hide: () => void
|
||||||
|
file: File[]
|
||||||
|
onFileHandle: (props: FileShareHandleProps) => void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VeeForm v-slot="{ values }" :initialValues="{ target_ext: 'jpg' }">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<h2 class="text-lg font-bold">{{ t('page.shareOptions.imageConvert.title') }}</h2>
|
||||||
|
<div class="flex flex-row gap-2 items-center">
|
||||||
|
<Label>{{ t('page.shareOptions.imageConvert.targetFormat') }}</Label>
|
||||||
|
<SelectField
|
||||||
|
name="target_ext"
|
||||||
|
:label="t('page.shareOptions.imageConvert.targetFormat')"
|
||||||
|
:options="[
|
||||||
|
{ label: 'JPG', value: 'jpg' },
|
||||||
|
{ label: 'PNG', value: 'png' },
|
||||||
|
{ label: 'WEBP', value: 'webp' },
|
||||||
|
]"
|
||||||
|
rules="required"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormButton
|
||||||
|
@click="
|
||||||
|
async (form) => {
|
||||||
|
onFileHandle({ type: 'file-image-convert', config: values })
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>{{ t('btn.submit') }}</FormButton
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</VeeForm>
|
||||||
|
</template>
|
||||||
@@ -15,37 +15,37 @@ const props = defineProps<{
|
|||||||
<template>
|
<template>
|
||||||
<VeeForm v-slot="{ values, setFieldValue }" :initialValues="{ download_nums: 1, expire_time: 1440 }">
|
<VeeForm v-slot="{ values, setFieldValue }" :initialValues="{ download_nums: 1, expire_time: 1440 }">
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<h2 class="text-lg font-bold">{{ t('textshare.title') }}</h2>
|
<h2 class="text-lg font-bold">{{ t('page.shareOptions.text.title') }}</h2>
|
||||||
<div class="flex flex-row items-center gap-2 text-sm">
|
<div class="flex flex-row items-center gap-2 text-sm">
|
||||||
<SelectField
|
<SelectField
|
||||||
name="download_nums"
|
name="download_nums"
|
||||||
:label="t('textshare.viewNums')"
|
:label="t('page.shareOptions.text.viewNums')"
|
||||||
:options="[
|
:options="[
|
||||||
{ label: t('textshare.viewOptions.1time'), value: 1 },
|
{ label: t('page.shareOptions.text.viewOptions.xview', [1]), value: 1 },
|
||||||
{ label: t('textshare.viewOptions.2times'), value: 2 },
|
{ label: t('page.shareOptions.text.viewOptions.xview', [2]), value: 2 },
|
||||||
{ label: t('textshare.viewOptions.3times'), value: 3 },
|
{ label: t('page.shareOptions.text.viewOptions.xview', [3]), value: 3 },
|
||||||
{ label: t('textshare.viewOptions.5times'), value: 5 },
|
{ label: t('page.shareOptions.text.viewOptions.xview', [5]), value: 5 },
|
||||||
{ label: t('textshare.viewOptions.10times'), value: 10 },
|
{ label: t('page.shareOptions.text.viewOptions.xview', [10]), value: 10 },
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
{{ t('textshare.or') }}
|
{{ t('page.shareOptions.text.or') }}
|
||||||
<SelectField
|
<SelectField
|
||||||
name="expire_time"
|
name="expire_time"
|
||||||
:label="t('textshare.expireTime')"
|
:label="t('page.shareOptions.text.expireTime')"
|
||||||
:options="[
|
:options="[
|
||||||
{ label: t('textshare.expireOptions.5min'), value: 5 },
|
{ label: t('page.shareOptions.text.expireOptions.5min'), value: 5 },
|
||||||
{ label: t('textshare.expireOptions.1hour'), value: 60 },
|
{ label: t('page.shareOptions.text.expireOptions.1hour'), value: 60 },
|
||||||
{ label: t('textshare.expireOptions.1day'), value: 1440 },
|
{ label: t('page.shareOptions.text.expireOptions.1day'), value: 1440 },
|
||||||
{ label: t('textshare.expireOptions.3days'), value: 4320 },
|
{ label: t('page.shareOptions.text.expireOptions.3days'), value: 4320 },
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
{{ t('textshare.expireAfter') }}
|
{{ t('page.shareOptions.text.expireAfter') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<div class="flex flex-row gap-3 min-h-9">
|
<div class="flex flex-row gap-3 min-h-9">
|
||||||
<SwitchField
|
<SwitchField
|
||||||
name="has_pickup_code"
|
name="has_pickup_code"
|
||||||
:label="t('textshare.pickupCode')"
|
:label="t('page.shareOptions.text.pickupCode')"
|
||||||
:rules="
|
:rules="
|
||||||
(value: boolean) => {
|
(value: boolean) => {
|
||||||
if (!!value) {
|
if (!!value) {
|
||||||
@@ -59,7 +59,7 @@ const props = defineProps<{
|
|||||||
<div class="flex flex-row gap-3 min-h-9">
|
<div class="flex flex-row gap-3 min-h-9">
|
||||||
<SwitchField
|
<SwitchField
|
||||||
name="has_password"
|
name="has_password"
|
||||||
:label="t('textshare.passwordProtection')"
|
:label="t('page.shareOptions.text.passwordProtection')"
|
||||||
:rules="
|
:rules="
|
||||||
(value: boolean) => {
|
(value: boolean) => {
|
||||||
if (!!value) {
|
if (!!value) {
|
||||||
@@ -69,11 +69,21 @@ const props = defineProps<{
|
|||||||
}
|
}
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<InputField v-if="!!values.has_password" name="password" :placeholder="t('textshare.passwordPlaceholder')" rules="required" />
|
<InputField
|
||||||
|
v-if="!!values.has_password"
|
||||||
|
name="password"
|
||||||
|
:placeholder="t('page.shareOptions.text.passwordPlaceholder')"
|
||||||
|
rules="required"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row gap-3 min-h-9">
|
<div class="flex flex-row gap-3 min-h-9">
|
||||||
<SwitchField name="has_notify" :label="t('textshare.readNotify')" />
|
<SwitchField name="has_notify" :label="t('page.shareOptions.text.readNotify')" />
|
||||||
<InputField v-if="!!values.has_notify" name="notify_email" :placeholder="t('textshare.emailPlaceholder')" rules="required" />
|
<InputField
|
||||||
|
v-if="!!values.has_notify"
|
||||||
|
name="notify_email"
|
||||||
|
:placeholder="t('page.shareOptions.text.emailPlaceholder')"
|
||||||
|
rules="required"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FormButton
|
<FormButton
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type FileHandleKey = 'file-share' | 'file-image-compress'
|
export type FileHandleKey = 'file-share' | 'file-image-compress' | 'file-image-convert'
|
||||||
export type FileShareHandleProps = { type: FileHandleKey; config: Record<string, any> }
|
export type FileShareHandleProps = { type: FileHandleKey; config: Record<string, any> }
|
||||||
|
|
||||||
export type TextHandleKey = 'text-share'
|
export type TextHandleKey = 'text-share'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import FilePreviewView from '@/components/FilePreviewView.vue'
|
import FilePreviewView from '@/components/FilePreviewView.vue'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { useClipboard } from '@vueuse/core'
|
import { useClipboard, useShare } from '@vueuse/core'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { useQuery } from '@tanstack/vue-query'
|
import { useQuery } from '@tanstack/vue-query'
|
||||||
import useMyAppShare from '@/composables/useMyAppShare'
|
import useMyAppShare from '@/composables/useMyAppShare'
|
||||||
@@ -12,11 +12,9 @@ import showDrawer from '@/lib/showDrawer'
|
|||||||
import QrCoreDrawer from '@/components/Drawer/QrCoreDrawer.vue'
|
import QrCoreDrawer from '@/components/Drawer/QrCoreDrawer.vue'
|
||||||
import { h } from 'vue'
|
import { h } from 'vue'
|
||||||
import { cx } from 'class-variance-authority'
|
import { cx } from 'class-variance-authority'
|
||||||
import type { FileHandleKey } from '../Preprocessing/types'
|
import type { handleFileComponentProps } from './types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<handleFileComponentProps>()
|
||||||
data: { files: { id: string; file: File }[]; config: Record<string, any>; handle_type: FileHandleKey }
|
|
||||||
}>()
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'change', key: string): void
|
(e: 'change', key: string): void
|
||||||
}>()
|
}>()
|
||||||
@@ -46,27 +44,40 @@ watchEffect(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watchEffect(() => {
|
|
||||||
console.log('data', data?.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const appConfig = useMyAppConfig()
|
const appConfig = useMyAppConfig()
|
||||||
const getShareUrl = (id: string) => {
|
const getShareUrl = (id: string) => {
|
||||||
return `${appConfig?.value?.site_url}/s/${id}`
|
return `${appConfig?.value?.site_url}/s/${id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const { copy } = useClipboard()
|
const { copy } = useClipboard()
|
||||||
|
const { share, isSupported: isShareSupported } = useShare()
|
||||||
|
|
||||||
|
const handleShare = async (id: string, fileName?: string) => {
|
||||||
|
await share({
|
||||||
|
title: fileName || 'File Share',
|
||||||
|
url: getShareUrl(id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShowQrCode = (id: string) => {
|
||||||
|
showDrawer({
|
||||||
|
render: ({ ...rest }) =>
|
||||||
|
h(QrCoreDrawer, {
|
||||||
|
...rest,
|
||||||
|
data: getShareUrl(id),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-3">
|
<BaseCard class="flex flex-col gap-3" :title="t('page.result.file.title')" :showBackButton="true">
|
||||||
<h2 class="text-lg">{{ t('fileshareresult.title') }}</h2>
|
|
||||||
<div class="flex flex-col gap-3 items-center">
|
<div class="flex flex-col gap-3 items-center">
|
||||||
<div v-if="data?.length === 1" class="flex flex-col h-30 items-center">
|
<div v-if="data?.length === 1" class="flex flex-col h-30 items-center">
|
||||||
<FilePreviewView :value="props?.data?.files?.[0]?.file as File" />
|
<FilePreviewView :value="props?.data?.files?.[0]?.file as File" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex flex-col gap-2 w-full p-5 bg-white/20 backdrop-blur-xl rounded-md">
|
<div v-else class="flex flex-col gap-2 w-full p-5 bg-white/20 backdrop-blur-xl rounded-md">
|
||||||
<div class="text-sm font-semibold">{{ t('fileshareresult.fileList') }}</div>
|
<div class="text-sm font-semibold">{{ t('page.result.file.fileList') }}</div>
|
||||||
<div
|
<div
|
||||||
v-for="file in data"
|
v-for="file in data"
|
||||||
:class="
|
:class="
|
||||||
@@ -80,19 +91,29 @@ const { copy } = useClipboard()
|
|||||||
<div class="flex flex-row items-center gap-2 flex-1 min-w-0">
|
<div class="flex flex-row items-center gap-2 flex-1 min-w-0">
|
||||||
<FileIcon
|
<FileIcon
|
||||||
:file="props?.data?.files?.[data?.findIndex((i) => i?.id === file?.id) as number]?.file as File"
|
:file="props?.data?.files?.[data?.findIndex((i) => i?.id === file?.id) as number]?.file as File"
|
||||||
:class="cx('!size-7 !rounded-md shrink-0', selectedFile === file?.id && '!bg-white/50')"
|
size="sm"
|
||||||
|
:class="cx('shrink-0', selectedFile === file?.id && 'bg-white/50!')"
|
||||||
/>
|
/>
|
||||||
<div class="text-sm flex-1 truncate">{{ file?.file_name }}</div>
|
<div class="text-sm flex-1 truncate">{{ file?.file_name }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2 shrink-0">
|
<div class="flex flex-row items-center gap-2 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
|
v-if="isShareSupported"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:class="cx('bg-white/70', selectedFile === file?.id && '!bg-white/30 border-none hover:text-white/80')"
|
:class="cx('bg-white/70', selectedFile === file?.id && '!bg-white/30 border-none hover:text-white/80')"
|
||||||
size="icon"
|
size="icon"
|
||||||
@click="
|
@click.stop="handleShare(file?.id as string, file?.file_name)"
|
||||||
|
>
|
||||||
|
<LucideShare />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:class="cx('bg-white/70', selectedFile === file?.id && '!bg-white/30 border-none hover:text-white/80')"
|
||||||
|
size="icon"
|
||||||
|
@click.stop="
|
||||||
() => {
|
() => {
|
||||||
copy(getShareUrl(file?.id as string))
|
copy(getShareUrl(file?.id as string))
|
||||||
toast.success(t('fileshareresult.copySuccess'))
|
toast.success(t('page.result.file.copySuccess'))
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@@ -102,17 +123,7 @@ const { copy } = useClipboard()
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
:class="cx('bg-white/70', selectedFile === file?.id && '!bg-white/30 border-none hover:text-white/80')"
|
:class="cx('bg-white/70', selectedFile === file?.id && '!bg-white/30 border-none hover:text-white/80')"
|
||||||
size="icon"
|
size="icon"
|
||||||
@click="
|
@click.stop="handleShowQrCode(file?.id as string)"
|
||||||
() => {
|
|
||||||
showDrawer({
|
|
||||||
render: ({ ...rest }) =>
|
|
||||||
h(QrCoreDrawer, {
|
|
||||||
...rest,
|
|
||||||
data: getShareUrl(file?.id as string),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<LucideQrCode />
|
<LucideQrCode />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -121,21 +132,21 @@ const { copy } = useClipboard()
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="!!selectedFileShare" class="flex flex-col md:flex-row gap-5 rounded-md p-5 bg-white/20 backdrop-blur-xl w-full">
|
<div v-if="!!selectedFileShare" class="flex flex-col md:flex-row gap-5 rounded-md p-5 bg-white/20 backdrop-blur-xl w-full">
|
||||||
<div class="flex flex-col gap-2 flex-1">
|
<div class="flex flex-col gap-2 flex-1">
|
||||||
<div class="text-sm font-semibold">{{ t('fileshareresult.info') }}</div>
|
<div class="text-sm font-semibold">{{ t('page.result.file.info') }}</div>
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<div class="rounded-xl flex flex-col bg-black/10 px-3 py-2 gap-1">
|
<div class="rounded-xl flex flex-col bg-black/10 px-3 py-2 gap-1">
|
||||||
<div class="text-xs font-semibold">{{ t('fileshareresult.downloadNums') }}</div>
|
<div class="text-xs font-semibold">{{ t('page.result.file.downloadNums') }}</div>
|
||||||
<div class="text-3xl font-light">{{ selectedFileShare?.download_nums }}</div>
|
<div class="text-3xl font-light">{{ selectedFileShare?.download_nums }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl flex flex-col bg-black/10 px-3 py-2 gap-1">
|
<div class="rounded-xl flex flex-col bg-black/10 px-3 py-2 gap-1">
|
||||||
<div class="text-xs font-semibold">{{ t('fileshareresult.expireTime') }}</div>
|
<div class="text-xs font-semibold">{{ t('page.result.file.expireTime') }}</div>
|
||||||
<div class="text-md font-light">
|
<div class="text-md font-light">
|
||||||
{{ dayjs((selectedFileShare?.expire_at || 0) * 1000).format('YYYY-MM-DD HH:mm:ss') }}
|
{{ dayjs((selectedFileShare?.expire_at || 0) * 1000).format('YYYY-MM-DD HH:mm:ss') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl flex flex-col bg-black/10 px-3 py-2 gap-1" v-if="selectedFileShare?.pickup_code">
|
<div class="rounded-xl flex flex-col bg-black/10 px-3 py-2 gap-1" v-if="selectedFileShare?.pickup_code">
|
||||||
<div class="flex flex-row justify-between w-full items-center">
|
<div class="flex flex-row justify-between w-full items-center">
|
||||||
<div class="text-xs font-semibold">{{ t('fileshareresult.pickupCode') }}</div>
|
<div class="text-xs font-semibold">{{ t('page.result.file.pickupCode') }}</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="bg-white/70 p-0 size-6"
|
class="bg-white/70 p-0 size-6"
|
||||||
@@ -143,7 +154,7 @@ const { copy } = useClipboard()
|
|||||||
@click="
|
@click="
|
||||||
() => {
|
() => {
|
||||||
copy(selectedFileShare?.pickup_code as string)
|
copy(selectedFileShare?.pickup_code as string)
|
||||||
toast.success(t('fileshareresult.copySuccess'))
|
toast.success(t('page.result.file.copySuccess'))
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@@ -159,9 +170,23 @@ const { copy } = useClipboard()
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-5 flex-1">
|
<div class="flex flex-col gap-5 flex-1">
|
||||||
<div class="text-sm font-semibold">{{ t('fileshareresult.link') }}</div>
|
<div class="text-sm font-semibold">{{ t('page.result.file.link') }}</div>
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-2">
|
||||||
<Input :model-value="getShareUrl(selectedFileShare?.id as string)" class="bg-white/70" readonly />
|
<Input :model-value="getShareUrl(selectedFileShare?.id as string)" class="bg-white/70" readonly />
|
||||||
|
<Button
|
||||||
|
v-if="isShareSupported"
|
||||||
|
variant="outline"
|
||||||
|
class="bg-white/70"
|
||||||
|
size="icon"
|
||||||
|
@click="
|
||||||
|
handleShare(
|
||||||
|
selectedFileShare?.id as string,
|
||||||
|
props?.data?.files?.[data?.findIndex((item) => item?.id === selectedFileShare?.id) as number]?.file?.name
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<LucideShare />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="bg-white/70"
|
class="bg-white/70"
|
||||||
@@ -169,44 +194,19 @@ const { copy } = useClipboard()
|
|||||||
@click="
|
@click="
|
||||||
() => {
|
() => {
|
||||||
copy(getShareUrl(selectedFileShare?.id as string))
|
copy(getShareUrl(selectedFileShare?.id as string))
|
||||||
toast.success(t('fileshareresult.copySuccess'))
|
toast.success(t('page.result.file.copySuccess'))
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<LucideCopy />
|
<LucideCopy />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button variant="outline" class="bg-white/70" size="icon" @click="handleShowQrCode(selectedFileShare?.id as string)">
|
||||||
variant="outline"
|
|
||||||
class="bg-white/70"
|
|
||||||
size="icon"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
showDrawer({
|
|
||||||
render: ({ ...rest }) =>
|
|
||||||
h(QrCoreDrawer, {
|
|
||||||
...rest,
|
|
||||||
data: getShareUrl(selectedFileShare?.id as string),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<LucideQrCode />
|
<LucideQrCode />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
class="w-40 hover:bg-primary/90"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
emit('change', 'input')
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ t('btn.backToHome') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</BaseCard>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,66 +1,92 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuery } from '@tanstack/vue-query'
|
import { useQueries, useQuery } from '@tanstack/vue-query'
|
||||||
import { AsyncButton, Button } from '@/components/ui/button'
|
import { AsyncButton } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { filesize } from 'filesize'
|
import { filesize } from 'filesize'
|
||||||
import useMyAppShare from '@/composables/useMyAppShare'
|
import useMyAppShare from '@/composables/useMyAppShare'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
|
import type { handleFileComponentProps } from './types'
|
||||||
|
import { get } from 'lodash-es'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'change', key: string): void
|
(e: 'change', key: string): void
|
||||||
}>()
|
}>()
|
||||||
const props = defineProps<{
|
const props = defineProps<handleFileComponentProps>()
|
||||||
data: { file: File; config: any; handle_type: string; file_id: string }
|
const fileIds = computed(() => props?.data?.files?.map((item) => item.id))
|
||||||
}>()
|
const { data: taskIds } = useQuery({
|
||||||
|
queryKey: ['create-image-compress', fileIds.value],
|
||||||
const { data } = useQuery({
|
|
||||||
queryKey: ['create-image-compress', props?.data?.file_id],
|
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { file_id } = props?.data || {}
|
return await Promise.all(
|
||||||
const data = await $fetch<{
|
props?.data?.files?.map(async (file) => {
|
||||||
code: number
|
const { id } = file || {}
|
||||||
data: {
|
if (!id) return
|
||||||
id?: string
|
const data = await $fetch<{
|
||||||
}
|
code: number
|
||||||
}>(`/api/image/compress`, {
|
data: {
|
||||||
method: 'POST',
|
id?: string
|
||||||
body: {
|
}
|
||||||
file_id,
|
}>(`/api/task/image:compress`, {
|
||||||
},
|
method: 'POST',
|
||||||
})
|
body: {
|
||||||
return data?.data
|
file_id: id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return data?.data?.id
|
||||||
|
})
|
||||||
|
)
|
||||||
},
|
},
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
|
enabled: !!fileIds.value && fileIds.value?.length > 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
const taskId = computed(() => data?.value?.id)
|
const taskResults = useQueries({
|
||||||
|
queries: computed(
|
||||||
const { data: taskData, refetch } = useQuery({
|
() =>
|
||||||
queryKey: ['image-compress-task', taskId],
|
taskIds?.value?.filter(Boolean).map((taskId) => {
|
||||||
queryFn: async () => {
|
return {
|
||||||
const data = await $fetch<{
|
queryKey: ['task-image-compress', taskId],
|
||||||
code: number
|
queryFn: async () => {
|
||||||
data: {
|
const data = await $fetch<{
|
||||||
result: {
|
code: number
|
||||||
old_file: {
|
data: {
|
||||||
id: string
|
result: {
|
||||||
size: number
|
old_file: {
|
||||||
}
|
id: string
|
||||||
new_file: {
|
size: number
|
||||||
id: string
|
}
|
||||||
size: number
|
new_file: {
|
||||||
}
|
id: string
|
||||||
}[]
|
size: number
|
||||||
status: 'success' | 'retry' | 'archived'
|
}
|
||||||
err?: {
|
}[]
|
||||||
message?: string
|
status: 'success' | 'retry' | 'archived'
|
||||||
retry?: number
|
err?: {
|
||||||
max_retry?: number
|
message?: string
|
||||||
|
retry?: number
|
||||||
|
max_retry?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>(`/api/task/${taskId}`)
|
||||||
|
return data?.data
|
||||||
|
},
|
||||||
|
enabled: !!taskId,
|
||||||
}
|
}
|
||||||
|
}) ?? []
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalSize = computed(() => {
|
||||||
|
return taskResults.value.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
const { new_file, old_file } = get(item, 'data.result.0') || {}
|
||||||
|
return {
|
||||||
|
oldSize: acc.oldSize + (old_file?.size ?? 0),
|
||||||
|
newSize: acc.newSize + (new_file?.size ?? 0),
|
||||||
}
|
}
|
||||||
}>(`/api/image/compress/${taskId.value}`)
|
},
|
||||||
return data?.data
|
{ oldSize: 0, newSize: 0 }
|
||||||
},
|
)
|
||||||
enabled: !!taskId.value,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const { downloadFileByShareId, createFileShare } = useMyAppShare()
|
const { downloadFileByShareId, createFileShare } = useMyAppShare()
|
||||||
@@ -70,66 +96,98 @@ const { counter, pause } = useInterval(2000, { controls: true })
|
|||||||
watch(
|
watch(
|
||||||
() => counter.value,
|
() => counter.value,
|
||||||
() => {
|
() => {
|
||||||
if (['success', 'archived'].includes(taskData.value?.status ?? '')) {
|
taskResults.value.forEach((item) => {
|
||||||
pause()
|
if (['success', 'archived'].includes(item.data?.status ?? '')) return
|
||||||
return
|
item.refetch()
|
||||||
}
|
})
|
||||||
refetch()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
if (taskResults.value.every((item) => ['success', 'archived'].includes(item.data?.status ?? ''))) {
|
||||||
() => taskData.value?.err?.retry,
|
pause()
|
||||||
(newVal, oldVal) => {
|
|
||||||
if (!oldVal || !newVal || !taskData.value?.err?.max_retry) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (newVal <= taskData.value?.err?.max_retry) {
|
|
||||||
toast.error(`处理错误: ${taskData.value?.err?.message}, 将再次重试`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-3">
|
<BaseCard class="flex flex-col gap-3" :title="t('page.result.imageCompress.title')" :showBackButton="true">
|
||||||
<h2 class="text-lg">上传成功</h2>
|
<div class="flex flex-row gap-3">
|
||||||
<div class="flex flex-col gap-1 items-center">
|
<div class="rounded-xl flex flex-col bg-white/70 px-3 py-2 gap-1 basis-2/3">
|
||||||
<div class="flex flex-col h-30 items-center justify-center">
|
<div class="text-sm font-semibold">{{ t('page.result.imageCompress.totalSize') }}</div>
|
||||||
<FilePreviewView :value="props?.data?.file" />
|
<div class="text-2xl font-light flex flex-row items-center gap-1">
|
||||||
</div>
|
<span v-if="totalSize.oldSize > 0" class="opacity-75">{{ filesize(totalSize.oldSize) }}</span>
|
||||||
</div>
|
<span v-else><Skeleton class="w-12 h-10" /></span>
|
||||||
<div v-if="taskData?.status === 'success'" class="flex flex-col gap-2" v-for="item in taskData?.result">
|
<LucideChevronsRight class="size-6" />
|
||||||
<div class="bg-white/80 p-2 rounded-md w-full flex flex-row items-center justify-between gap-2">
|
<span v-if="totalSize.newSize > 0">{{ filesize(totalSize.newSize) }}</span>
|
||||||
<div class="flex flex-row gap-2 items-center max-w-2/3">
|
<span v-else><Skeleton class="w-12 h-10" /></span>
|
||||||
<div class="flex flex-row items-center justify-center rounded-md bg-black/5 p-2">
|
<div
|
||||||
<LucideImage />
|
v-if="totalSize.oldSize > 0 && totalSize.newSize > 0"
|
||||||
</div>
|
class="rounded flex flex-row items-center bg-green-100 text-green-600 p-1 py-0.5 text-sm"
|
||||||
<div class="truncate w-auto">{{ props?.data?.file?.name }}</div>
|
>
|
||||||
<div class="flex flex-row gap-2 items-center text-sm shrink-0">
|
<LucideArrowDown class="size-4" />
|
||||||
<span class="opacity-75">{{ filesize(item.new_file.size ?? 0) }}</span>
|
{{ ((1 - totalSize.newSize / totalSize.oldSize) * 100).toFixed(2) }}%
|
||||||
<span class="bg-green-200 text-green-600 rounded-md px-1 py-0.5 flex flex-row gap-1 items-center text-xs">
|
|
||||||
<LucideChevronDown class="size-4" />
|
|
||||||
{{ ((1 - item.new_file.size / item.old_file.size) * 100).toFixed(2) }}%
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl flex flex-col bg-white/70 px-3 py-2 gap-1 basis-1/3">
|
||||||
|
<div class="text-sm font-semibold">{{ t('page.result.imageCompress.task') }}</div>
|
||||||
|
<div class="text-3xl font-light">{{ taskResults.length }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-for="(item, index) in props?.data?.files" class="flex flex-row rounded-xl bg-white/70 p-3 justify-between w-full gap-3">
|
||||||
|
<div class="flex flex-row gap-2 items-center w-full overflow-hidden">
|
||||||
|
<div class="*:h-12 overflow-hidden">
|
||||||
|
<FileIcon :file="item?.file" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-0.5 flex-1 overflow-hidden">
|
||||||
|
<div class="truncate w-auto">{{ item?.file?.name }}</div>
|
||||||
|
<div class="text-xs opacity-50">{{ filesize(item?.file?.size ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center" v-if="!taskResults?.[index]?.data">
|
||||||
|
<Skeleton class="w-16 h-12" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row gap-1 items-center text-sm" v-if="taskResults?.[index]?.data?.status === 'retry'">
|
||||||
|
<LucideLoader2 class="size-4 animate-spin" />
|
||||||
|
{{
|
||||||
|
t('page.result.imageCompress.retry', [
|
||||||
|
taskResults?.[index]?.data?.err?.retry ?? 0,
|
||||||
|
taskResults?.[index]?.data?.err?.max_retry ?? 0,
|
||||||
|
])
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center" v-if="taskResults?.[index]?.data?.status === 'archived'">
|
||||||
|
<div class="text-sm text-red-500 px-2 py-1 rounded-md bg-red-100">{{ t('page.result.imageCompress.failed') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row gap-2 items-center shrink-0" v-if="taskResults?.[index]?.data?.status === 'success'">
|
||||||
|
<div class="flex flex-col gap-1 items-center">
|
||||||
|
<div class="rounded flex flex-row items-center bg-green-100 text-green-600 px-1 text-xs">
|
||||||
|
<LucideArrowDown class="size-4" />
|
||||||
|
{{
|
||||||
|
(
|
||||||
|
(1 -
|
||||||
|
(taskResults?.[index]?.data?.result?.[0]?.new_file?.size ?? 0) /
|
||||||
|
(taskResults?.[index]?.data?.result?.[0]?.old_file?.size ?? 0)) *
|
||||||
|
100
|
||||||
|
).toFixed(2)
|
||||||
|
}}%
|
||||||
|
</div>
|
||||||
|
<div class="text-xs opacity-50">{{ filesize(taskResults?.[index]?.data?.result?.[0]?.new_file?.size ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
<AsyncButton
|
<AsyncButton
|
||||||
variant="outline"
|
|
||||||
class="bg-black/5"
|
|
||||||
size="icon"
|
size="icon"
|
||||||
@click="
|
@click="
|
||||||
async () => {
|
async () => {
|
||||||
|
const { new_file } = taskResults?.[index]?.data?.result?.[0] || {}
|
||||||
|
if (!new_file?.id) return
|
||||||
const data = await createFileShare({
|
const data = await createFileShare({
|
||||||
file_id: item.new_file.id,
|
files: [{ id: new_file?.id as string, name: item?.file?.name }],
|
||||||
config: {
|
config: {
|
||||||
download_nums: 1,
|
download_nums: 1,
|
||||||
expire_time: 60,
|
expire_time: 60,
|
||||||
has_pickup_code: false,
|
has_pickup_code: false,
|
||||||
has_password: false,
|
has_password: false,
|
||||||
},
|
},
|
||||||
file_name: props?.data?.file?.name,
|
|
||||||
})
|
})
|
||||||
const { id } = data?.data || {}
|
const { id } = data?.[0]?.data || {}
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -140,27 +198,9 @@ watch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
><LucideArrowDown
|
||||||
<LucideDownload />
|
/></AsyncButton>
|
||||||
</AsyncButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="taskData?.status !== 'retry' && !!taskData?.err?.message" class="flex flex-col gap-2">
|
</BaseCard>
|
||||||
<div class="w-full h-16 flex flex-row items-center gap-3 bg-white/80 rounded-md p-2">
|
|
||||||
<div class="size-10 flex items-center justify-center rounded-md bg-red-200">
|
|
||||||
<LucideAlertTriangle class="size-5 text-red-600" />
|
|
||||||
</div>
|
|
||||||
<div class="text-sm">
|
|
||||||
{{ `经过 ${taskData?.err?.retry} 次重试后任务处理失败: ${taskData?.err?.message}` }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row justify-center">
|
|
||||||
<Button @click="emit('change', 'input')"> 返回首页 </Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else="taskData?.status !== 'retry' && !!taskData?.err?.message" class="flex flex-col gap-2">
|
|
||||||
<Skeleton class="w-full h-16 flex flex-row items-center justify-between" v-for="i in 3" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
190
front/components/Result/ImageConvertResult.vue
Normal file
190
front/components/Result/ImageConvertResult.vue
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useQueries, useQuery } from '@tanstack/vue-query'
|
||||||
|
import { AsyncButton } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { filesize } from 'filesize'
|
||||||
|
import useMyAppShare from '@/composables/useMyAppShare'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import type { handleFileComponentProps } from './types'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'change', key: string): void
|
||||||
|
}>()
|
||||||
|
const props = defineProps<handleFileComponentProps>()
|
||||||
|
const fileIds = computed(() => props?.data?.files?.map((item) => item.id))
|
||||||
|
const targetExt = computed(() => props?.data?.config?.target_ext)
|
||||||
|
const { data: taskIds } = useQuery({
|
||||||
|
queryKey: ['create-image-convert', fileIds.value, targetExt.value],
|
||||||
|
queryFn: async () => {
|
||||||
|
return await Promise.all(
|
||||||
|
props?.data?.files?.map(async (file) => {
|
||||||
|
const { id } = file || {}
|
||||||
|
if (!id) return
|
||||||
|
const data = await $fetch<{
|
||||||
|
code: number
|
||||||
|
data: {
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
}>(`/api/task/image:convert`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
file_id: id,
|
||||||
|
target_ext: targetExt.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return data?.data?.id
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
|
staleTime: Infinity,
|
||||||
|
enabled: !!fileIds.value && fileIds.value?.length > 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const taskResults = useQueries({
|
||||||
|
queries: computed(
|
||||||
|
() =>
|
||||||
|
taskIds?.value?.filter(Boolean).map((taskId) => {
|
||||||
|
return {
|
||||||
|
queryKey: ['task-image-convert', taskId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const data = await $fetch<{
|
||||||
|
code: number
|
||||||
|
data: {
|
||||||
|
result: {
|
||||||
|
old_file: {
|
||||||
|
id: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
new_file: {
|
||||||
|
id: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
}[]
|
||||||
|
status: 'success' | 'retry' | 'archived'
|
||||||
|
err?: {
|
||||||
|
message?: string
|
||||||
|
retry?: number
|
||||||
|
max_retry?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>(`/api/task/${taskId}`)
|
||||||
|
return data?.data
|
||||||
|
},
|
||||||
|
enabled: !!taskId,
|
||||||
|
}
|
||||||
|
}) ?? []
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { downloadFileByShareId, createFileShare } = useMyAppShare()
|
||||||
|
|
||||||
|
const { counter, pause } = useInterval(2000, { controls: true })
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => counter.value,
|
||||||
|
() => {
|
||||||
|
taskResults.value.forEach((item) => {
|
||||||
|
if (['success', 'archived'].includes(item.data?.status ?? '')) return
|
||||||
|
item.refetch()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (taskResults.value.every((item) => ['success', 'archived'].includes(item.data?.status ?? ''))) {
|
||||||
|
pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalExt = computed(() => {
|
||||||
|
return props?.data?.files?.reduce<string[]>((acc, item) => {
|
||||||
|
const [, originalName, originalExt] = item?.file?.name?.match(/(.*?)\.([^.]+(?:\.[^.]+)*)$/) ?? []
|
||||||
|
if (originalExt) acc.push(originalExt)
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<BaseCard class="flex flex-col gap-3" :title="t('page.result.imageConvert.title')" :showBackButton="true">
|
||||||
|
<div class="flex flex-row gap-3">
|
||||||
|
<div class="rounded-xl flex flex-col bg-white/70 px-3 py-2 gap-1 basis-2/3">
|
||||||
|
<div class="text-sm font-semibold">{{ t('page.result.imageConvert.convert') }}</div>
|
||||||
|
<div class="text-2xl font-light flex flex-row items-center gap-1">
|
||||||
|
<div v-for="ext in totalExt" class="rounded flex flex-row items-center bg-primary/20 text-primary p-1 py-0.5 text-sm">
|
||||||
|
{{ ext?.toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
<LucideChevronsRight class="size-6" />
|
||||||
|
<div class="rounded flex flex-row items-center bg-primary/20 text-primary p-1 py-0.5 text-sm">
|
||||||
|
{{ targetExt?.toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl flex flex-col bg-white/70 px-3 py-2 gap-1 basis-1/3">
|
||||||
|
<div class="text-sm font-semibold">{{ t('page.result.imageConvert.task') }}</div>
|
||||||
|
<div class="text-3xl font-light">{{ taskResults.length }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-for="(item, index) in props?.data?.files" class="flex flex-row rounded-xl bg-white/70 p-3 justify-between w-full gap-3">
|
||||||
|
<div class="flex flex-row gap-2 items-center w-full overflow-hidden">
|
||||||
|
<div class="*:h-12 overflow-hidden">
|
||||||
|
<FileIcon :file="item?.file" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-0.5 flex-1 overflow-hidden">
|
||||||
|
<div class="truncate w-auto">{{ item?.file?.name }}</div>
|
||||||
|
<div class="text-xs opacity-50">{{ filesize(item?.file?.size ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center" v-if="!taskResults?.[index]?.data">
|
||||||
|
<Skeleton class="w-16 h-12" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row gap-1 items-center text-sm" v-if="taskResults?.[index]?.data?.status === 'retry'">
|
||||||
|
<LucideLoader2 class="size-4 animate-spin" />
|
||||||
|
{{
|
||||||
|
t('page.result.imageConvert.retry', [
|
||||||
|
taskResults?.[index]?.data?.err?.retry ?? 0,
|
||||||
|
taskResults?.[index]?.data?.err?.max_retry ?? 0,
|
||||||
|
])
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center" v-if="taskResults?.[index]?.data?.status === 'archived'">
|
||||||
|
<div class="text-sm text-red-500 px-2 py-1 rounded-md bg-red-100">{{ t('page.result.imageConvert.failed') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row gap-2 items-center shrink-0" v-if="taskResults?.[index]?.data?.status === 'success'">
|
||||||
|
<div class="flex flex-col gap-1 items-center">
|
||||||
|
<div class="rounded flex flex-row items-center bg-primary/20 text-primary px-1 text-xs">
|
||||||
|
{{ targetExt.toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs opacity-50">{{ filesize(taskResults?.[index]?.data?.result?.[0]?.new_file?.size ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<AsyncButton
|
||||||
|
size="icon"
|
||||||
|
@click="
|
||||||
|
async () => {
|
||||||
|
const { new_file } = taskResults?.[index]?.data?.result?.[0] || {}
|
||||||
|
if (!new_file?.id) return
|
||||||
|
const [, originalName, originalExt] = item?.file?.name?.match(/(.*?)\.([^.]+(?:\.[^.]+)*)$/) ?? []
|
||||||
|
const data = await createFileShare({
|
||||||
|
files: [{ id: new_file?.id as string, name: `${originalName}.${targetExt}` }],
|
||||||
|
config: {
|
||||||
|
download_nums: 1,
|
||||||
|
expire_time: 60,
|
||||||
|
has_pickup_code: false,
|
||||||
|
has_password: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const { id } = data?.[0]?.data || {}
|
||||||
|
if (!id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await downloadFileByShareId(id)
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.data?.message || error?.message || error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"
|
||||||
|
><LucideArrowDown
|
||||||
|
/></AsyncButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BaseCard>
|
||||||
|
</template>
|
||||||
@@ -2,13 +2,8 @@
|
|||||||
import FileShareResult from '@/components/Result/FileShareResult.vue'
|
import FileShareResult from '@/components/Result/FileShareResult.vue'
|
||||||
import TextShareResult from '@/components/Result/TextShareResult.vue'
|
import TextShareResult from '@/components/Result/TextShareResult.vue'
|
||||||
import ImageCompressResult from '@/components/Result/ImageCompressResult.vue'
|
import ImageCompressResult from '@/components/Result/ImageCompressResult.vue'
|
||||||
import type { FileHandleKey, TextHandleKey } from '../Preprocessing/types'
|
import ImageConvertResult from '@/components/Result/ImageConvertResult.vue'
|
||||||
|
import type { filehandleData, handleComponent, handleKey, texthandleData } from './types'
|
||||||
type basehandleData = { config: Record<string, any> }
|
|
||||||
|
|
||||||
type filehandleData = { files: { id: string; file: File }[]; handle_type: FileHandleKey } & basehandleData
|
|
||||||
type texthandleData = { text: string; handle_type: TextHandleKey } & basehandleData
|
|
||||||
type handleKey = 'file-share' | 'text-share' | 'file-image-compress'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
data: filehandleData | texthandleData
|
data: filehandleData | texthandleData
|
||||||
@@ -18,10 +13,11 @@ const emit = defineEmits<{
|
|||||||
(e: 'change', key: string): void
|
(e: 'change', key: string): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const handleList: { component: Component<{ data: filehandleData | texthandleData }>; key: handleKey }[] = [
|
const handleList: { component: handleComponent; key: handleKey }[] = [
|
||||||
{ component: FileShareResult, key: 'file-share' },
|
{ component: FileShareResult, key: 'file-share' },
|
||||||
{ component: TextShareResult, key: 'text-share' },
|
{ component: TextShareResult, key: 'text-share' },
|
||||||
{ component: ImageCompressResult, key: 'file-image-compress' },
|
{ component: ImageCompressResult, key: 'file-image-compress' },
|
||||||
|
{ component: ImageConvertResult, key: 'file-image-convert' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const activeHandle = computed(() => {
|
const activeHandle = computed(() => {
|
||||||
@@ -30,8 +26,6 @@ const activeHandle = computed(() => {
|
|||||||
// vue这个ts蠢的没边了,本来想写component: FileShareResult | TextShareResult,结果不行
|
// vue这个ts蠢的没边了,本来想写component: FileShareResult | TextShareResult,结果不行
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<component v-if="'files' in data" :is="activeHandle?.component" :data="data" @change="(key: string) => emit('change', key)" />
|
||||||
<component v-if="'file' in data" :is="activeHandle?.component" :data="data" @change="(key: string) => emit('change', key)" />
|
<component v-if="'text' in data" :is="activeHandle?.component" :data="data" @change="(key: string) => emit('change', key)" />
|
||||||
<component v-if="'text' in data" :is="activeHandle?.component" :data="data" @change="(key: string) => emit('change', key)" />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -10,11 +10,9 @@ import showDrawer from '@/lib/showDrawer'
|
|||||||
import QrCoreDrawer from '@/components/Drawer/QrCoreDrawer.vue'
|
import QrCoreDrawer from '@/components/Drawer/QrCoreDrawer.vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { h } from 'vue'
|
import { h } from 'vue'
|
||||||
import type { TextHandleKey } from '../Preprocessing/types'
|
import type { handleTextComponentProps } from './types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<handleTextComponentProps>()
|
||||||
data: { text: string; config: Record<string, any>; handle_type: TextHandleKey }
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'change', key: string): void
|
(e: 'change', key: string): void
|
||||||
@@ -44,41 +42,24 @@ const { t } = useI18n()
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-3">
|
<BaseCard class="flex flex-col gap-3" :title="t('page.result.text.title')" :showBackButton="true">
|
||||||
<div class="flex flex-row gap-2">
|
|
||||||
<div class="flex flex-row justify-between w-full">
|
|
||||||
<h2 class="text-lg">{{ t('textshareresult.title') }}</h2>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
class="bg-white/70"
|
|
||||||
size="icon"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
emit('change', 'input')
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<LucideHome />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col md:flex-row gap-5 rounded-md p-5 bg-white/20 backdrop-blur-xl w-full">
|
<div class="flex flex-col md:flex-row gap-5 rounded-md p-5 bg-white/20 backdrop-blur-xl w-full">
|
||||||
<div class="flex flex-col gap-2 flex-1">
|
<div class="flex flex-col gap-2 flex-1">
|
||||||
<div class="text-sm font-semibold">{{ t('textshareresult.info') }}</div>
|
<div class="text-sm font-semibold">{{ t('page.result.text.info') }}</div>
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<div class="rounded-xl flex flex-col bg-black/10 px-3 py-2 gap-1">
|
<div class="rounded-xl flex flex-col bg-black/10 px-3 py-2 gap-1">
|
||||||
<div class="text-xs font-semibold">{{ t('textshareresult.viewNums') }}</div>
|
<div class="text-xs font-semibold">{{ t('page.result.text.viewNums') }}</div>
|
||||||
<div class="text-3xl font-light">{{ data?.download_nums }}</div>
|
<div class="text-3xl font-light">{{ data?.download_nums }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl flex flex-col bg-black/5 px-3 py-2 gap-1">
|
<div class="rounded-xl flex flex-col bg-black/5 px-3 py-2 gap-1">
|
||||||
<div class="text-xs font-semibold">{{ t('textshareresult.expireTime') }}</div>
|
<div class="text-xs font-semibold">{{ t('page.result.text.expireTime') }}</div>
|
||||||
<div class="text-md font-light">
|
<div class="text-md font-light">
|
||||||
{{ dayjs((data?.expire_at ?? 0) * 1000).format('YYYY-MM-DD HH:mm:ss') }}
|
{{ dayjs((data?.expire_at ?? 0) * 1000).format('YYYY-MM-DD HH:mm:ss') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl flex flex-col bg-black/10 px-3 py-2 gap-1" v-if="data?.pickup_code">
|
<div class="rounded-xl flex flex-col bg-black/10 px-3 py-2 gap-1" v-if="data?.pickup_code">
|
||||||
<div class="flex flex-row justify-between w-full items-center">
|
<div class="flex flex-row justify-between w-full items-center">
|
||||||
<div class="text-xs font-semibold">{{ t('textshareresult.pickupCode') }}</div>
|
<div class="text-xs font-semibold">{{ t('page.result.text.pickupCode') }}</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="bg-white/70 p-0 size-6"
|
class="bg-white/70 p-0 size-6"
|
||||||
@@ -86,7 +67,7 @@ const { t } = useI18n()
|
|||||||
@click="
|
@click="
|
||||||
() => {
|
() => {
|
||||||
copy(data?.pickup_code as string)
|
copy(data?.pickup_code as string)
|
||||||
toast.success(t('textshareresult.copySuccess'))
|
toast.success(t('page.result.text.copySuccess'))
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@@ -102,7 +83,7 @@ const { t } = useI18n()
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-5 flex-1">
|
<div class="flex flex-col gap-5 flex-1">
|
||||||
<div class="text-sm font-semibold">{{ t('textshareresult.link') }}</div>
|
<div class="text-sm font-semibold">{{ t('page.result.text.link') }}</div>
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-2">
|
||||||
<Input v-model="url" class="bg-white/70" readonly />
|
<Input v-model="url" class="bg-white/70" readonly />
|
||||||
<Button
|
<Button
|
||||||
@@ -112,7 +93,7 @@ const { t } = useI18n()
|
|||||||
@click="
|
@click="
|
||||||
() => {
|
() => {
|
||||||
copy(url)
|
copy(url)
|
||||||
toast.success(t('textshareresult.copySuccess'))
|
toast.success(t('page.result.text.copySuccess'))
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@@ -140,7 +121,7 @@ const { t } = useI18n()
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-md">{{ t('textshareresult.content') }}</h2>
|
<h2 class="text-md">{{ t('page.result.text.content') }}</h2>
|
||||||
<MarkdownRender class="prose rounded-md bg-white/70 p-3 w-full max-w-full min-h-[30vh]" :markdown="props?.data?.text" />
|
<MarkdownRender class="prose rounded-md bg-white/70 p-3 w-full max-w-full min-h-[30vh]" :markdown="props?.data?.text" />
|
||||||
</div>
|
</BaseCard>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
11
front/components/Result/types.ts
Normal file
11
front/components/Result/types.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { FileHandleKey, TextHandleKey } from '../Preprocessing/types'
|
||||||
|
|
||||||
|
type basehandleData = { config: Record<string, any> }
|
||||||
|
|
||||||
|
export type filehandleData = { files: { id: string; file: File }[]; handle_type: FileHandleKey } & basehandleData
|
||||||
|
export type texthandleData = { text: string; handle_type: TextHandleKey } & basehandleData
|
||||||
|
export type handleKey = FileHandleKey | TextHandleKey
|
||||||
|
|
||||||
|
export type handleTextComponentProps = { data: texthandleData }
|
||||||
|
export type handleFileComponentProps = { data: filehandleData }
|
||||||
|
export type handleComponent = Component<handleTextComponentProps | handleFileComponentProps>
|
||||||
@@ -12,6 +12,7 @@ import PasswallShareDrawer from '~/components/Drawer/PasswallShareDrawer.vue'
|
|||||||
dayjs.extend(duration)
|
dayjs.extend(duration)
|
||||||
dayjs.extend(relativeTime)
|
dayjs.extend(relativeTime)
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
data: any
|
data: any
|
||||||
}>()
|
}>()
|
||||||
@@ -31,7 +32,7 @@ const handleDownload = async () => {
|
|||||||
token = await getShareToken(id)
|
token = await getShareToken(id)
|
||||||
}
|
}
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('获取token失败')
|
throw new Error(t('page.shareView.fileShare.getTokenFailed'))
|
||||||
}
|
}
|
||||||
downloadFile(token)
|
downloadFile(token)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -53,19 +54,19 @@ onMounted(() => {
|
|||||||
|
|
||||||
const fileShareInfo = computed(() => {
|
const fileShareInfo = computed(() => {
|
||||||
return [
|
return [
|
||||||
{ label: '需要密码', value: props?.data?.has_password ?? false },
|
{ label: t('page.shareView.fileShare.needPassword'), value: props?.data?.has_password ?? false },
|
||||||
{
|
{
|
||||||
label: '过期时间',
|
label: t('page.shareView.fileShare.expireTime'),
|
||||||
value: dayjs.duration(remaining.value, 'seconds').format(`D天 HH:mm:ss`),
|
value: dayjs.duration(remaining.value, 'seconds').format(t('page.shareView.fileShare.durationFormat')),
|
||||||
},
|
},
|
||||||
{ label: '剩余下载次数', value: props?.data?.download_nums ?? 0 },
|
{ label: t('page.shareView.fileShare.remainingDownloads'), value: props?.data?.download_nums ?? 0 },
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-5 items-center">
|
<div class="flex flex-col gap-5 items-center">
|
||||||
<h1 class="text-xl font-bold">下载文件</h1>
|
<h1 class="text-xl font-bold">{{ t('page.shareView.fileShare.title') }}</h1>
|
||||||
<FilePreviewView :value="props?.data" />
|
<FilePreviewView :value="props?.data" />
|
||||||
<div class="flex flex-col gap-2 md:flex-row w-full">
|
<div class="flex flex-col gap-2 md:flex-row w-full">
|
||||||
<div class="flex flex-row md:flex-col md:gap-1 justify-between items-center md:flex-1" v-for="item in fileShareInfo">
|
<div class="flex flex-row md:flex-col md:gap-1 justify-between items-center md:flex-1" v-for="item in fileShareInfo">
|
||||||
@@ -75,7 +76,7 @@ const fileShareInfo = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<AsyncButton @click="handleDownload" class="w-full">下载</AsyncButton>
|
<AsyncButton @click="handleDownload" class="w-full">{{ t('page.shareView.fileShare.downloadBtn') }}</AsyncButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import PasswallShareDrawer from '~/components/Drawer/PasswallShareDrawer.vue'
|
|||||||
dayjs.extend(duration)
|
dayjs.extend(duration)
|
||||||
dayjs.extend(relativeTime)
|
dayjs.extend(relativeTime)
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
data: any
|
data: any
|
||||||
}>()
|
}>()
|
||||||
@@ -37,12 +38,12 @@ onMounted(() => {
|
|||||||
|
|
||||||
const fileShareInfo = computed(() => {
|
const fileShareInfo = computed(() => {
|
||||||
return [
|
return [
|
||||||
{ label: '需要密码', value: props?.data?.has_password ?? false },
|
{ label: t('page.shareView.textShare.needPassword'), value: props?.data?.has_password ?? false },
|
||||||
{
|
{
|
||||||
label: '过期时间',
|
label: t('page.shareView.textShare.expireTime'),
|
||||||
value: dayjs.duration(remaining.value, 'seconds').format(`D天 HH:mm:ss`),
|
value: dayjs.duration(remaining.value, 'seconds').format(t('page.shareView.textShare.durationFormat')),
|
||||||
},
|
},
|
||||||
{ label: '剩余浏览次数', value: props?.data?.download_nums ?? 0 },
|
{ label: t('page.shareView.textShare.remainingViews'), value: props?.data?.download_nums ?? 0 },
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
const previewText = ref<string | null>(null)
|
const previewText = ref<string | null>(null)
|
||||||
@@ -72,7 +73,7 @@ const handlePreview = async () => {
|
|||||||
<template>
|
<template>
|
||||||
<div :class="cx('flex flex-col max-h-full', !!previewText ? 'gap-3' : 'gap-16 items-center')">
|
<div :class="cx('flex flex-col max-h-full', !!previewText ? 'gap-3' : 'gap-16 items-center')">
|
||||||
<div :class="cx('flex flex-row w-full', !!previewText ? 'justify-between' : 'justify-center')">
|
<div :class="cx('flex flex-row w-full', !!previewText ? 'justify-between' : 'justify-center')">
|
||||||
<h1 class="text-xl">查看文本</h1>
|
<h1 class="text-xl">{{ t('page.shareView.textShare.title') }}</h1>
|
||||||
<Button
|
<Button
|
||||||
v-if="!!previewText"
|
v-if="!!previewText"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -80,7 +81,7 @@ const handlePreview = async () => {
|
|||||||
@click="
|
@click="
|
||||||
() => {
|
() => {
|
||||||
copy(previewText as string)
|
copy(previewText as string)
|
||||||
toast.success('复制成功')
|
toast.success(t('page.result.text.copySuccess'))
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@@ -96,7 +97,7 @@ const handlePreview = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<AsyncButton @click="handlePreview" class="w-full">浏览</AsyncButton>
|
<AsyncButton @click="handlePreview" class="w-full">{{ t('page.shareView.textShare.viewBtn') }}</AsyncButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|||||||
@@ -57,4 +57,5 @@ onUnmounted(() => {
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
</editor-content>
|
</editor-content>
|
||||||
|
<!-- <BubbleMenuView :editor="editor as any" /> -->
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,179 +0,0 @@
|
|||||||
<script setup lang="ts" generic="T extends Record<string, any>">
|
|
||||||
import type { BaseChartProps } from ".";
|
|
||||||
import { type BulletLegendItemInterface, CurveType } from "@unovis/ts";
|
|
||||||
import { Area, Axis, Line } from "@unovis/ts";
|
|
||||||
import { VisArea, VisAxis, VisLine, VisXYContainer } from "@unovis/vue";
|
|
||||||
import { useMounted } from "@vueuse/core";
|
|
||||||
import { useId } from "reka-ui";
|
|
||||||
import { type Component, computed, ref } from "vue";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { ChartCrosshair, ChartLegend, defaultColors } from "../chart";
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<
|
|
||||||
BaseChartProps<T> & {
|
|
||||||
/**
|
|
||||||
* Render custom tooltip component.
|
|
||||||
*/
|
|
||||||
customTooltip?: Component;
|
|
||||||
/**
|
|
||||||
* Type of curve
|
|
||||||
*/
|
|
||||||
curveType?: CurveType;
|
|
||||||
/**
|
|
||||||
* Controls the visibility of gradient.
|
|
||||||
* @default true
|
|
||||||
*/
|
|
||||||
showGradiant?: boolean;
|
|
||||||
}
|
|
||||||
>(),
|
|
||||||
{
|
|
||||||
curveType: CurveType.MonotoneX,
|
|
||||||
filterOpacity: 0.2,
|
|
||||||
margin: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
|
|
||||||
showXAxis: true,
|
|
||||||
showYAxis: true,
|
|
||||||
showTooltip: true,
|
|
||||||
showLegend: true,
|
|
||||||
showGridLine: true,
|
|
||||||
showGradiant: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const emits = defineEmits<{
|
|
||||||
legendItemClick: [d: BulletLegendItemInterface, i: number];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
type KeyOfT = Extract<keyof T, string>;
|
|
||||||
type Data = (typeof props.data)[number];
|
|
||||||
|
|
||||||
const chartRef = useId();
|
|
||||||
|
|
||||||
const index = computed(() => props.index as KeyOfT);
|
|
||||||
const colors = computed(() =>
|
|
||||||
props.colors?.length ? props.colors : defaultColors(props.categories.length),
|
|
||||||
);
|
|
||||||
|
|
||||||
const legendItems = ref<BulletLegendItemInterface[]>(
|
|
||||||
props.categories.map((category, i) => ({
|
|
||||||
name: category,
|
|
||||||
color: colors.value[i],
|
|
||||||
inactive: false,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
const isMounted = useMounted();
|
|
||||||
|
|
||||||
function handleLegendItemClick(d: BulletLegendItemInterface, i: number) {
|
|
||||||
emits("legendItemClick", d, i);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
:class="cn('w-full h-[400px] flex flex-col items-end', $attrs.class ?? '')"
|
|
||||||
>
|
|
||||||
<ChartLegend
|
|
||||||
v-if="showLegend"
|
|
||||||
v-model:items="legendItems"
|
|
||||||
@legend-item-click="handleLegendItemClick"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<VisXYContainer
|
|
||||||
:style="{ height: isMounted ? '100%' : 'auto' }"
|
|
||||||
:margin="{ left: 20, right: 20 }"
|
|
||||||
:data="data"
|
|
||||||
>
|
|
||||||
<svg width="0" height="0">
|
|
||||||
<defs>
|
|
||||||
<linearGradient
|
|
||||||
v-for="(color, i) in colors"
|
|
||||||
:id="`${chartRef}-color-${i}`"
|
|
||||||
:key="i"
|
|
||||||
x1="0"
|
|
||||||
y1="0"
|
|
||||||
x2="0"
|
|
||||||
y2="1"
|
|
||||||
>
|
|
||||||
<template v-if="showGradiant">
|
|
||||||
<stop offset="5%" :stop-color="color" stop-opacity="0.4" />
|
|
||||||
<stop offset="95%" :stop-color="color" stop-opacity="0" />
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<stop offset="0%" :stop-color="color" />
|
|
||||||
</template>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<ChartCrosshair
|
|
||||||
v-if="showTooltip"
|
|
||||||
:colors="colors"
|
|
||||||
:items="legendItems"
|
|
||||||
:index="index"
|
|
||||||
:custom-tooltip="customTooltip"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<template v-for="(category, i) in categories" :key="category">
|
|
||||||
<VisArea
|
|
||||||
:x="(d: Data, i: number) => i"
|
|
||||||
:y="(d: Data) => d[category]"
|
|
||||||
color="auto"
|
|
||||||
:curve-type="curveType"
|
|
||||||
:attributes="{
|
|
||||||
[Area.selectors.area]: {
|
|
||||||
fill: `url(#${chartRef}-color-${i})`,
|
|
||||||
},
|
|
||||||
}"
|
|
||||||
:opacity="
|
|
||||||
legendItems.find((item) => item.name === category)?.inactive
|
|
||||||
? filterOpacity
|
|
||||||
: 1
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-for="(category, i) in categories" :key="category">
|
|
||||||
<VisLine
|
|
||||||
:x="(d: Data, i: number) => i"
|
|
||||||
:y="(d: Data) => d[category]"
|
|
||||||
:color="colors[i]"
|
|
||||||
:curve-type="curveType"
|
|
||||||
:attributes="{
|
|
||||||
[Line.selectors.line]: {
|
|
||||||
opacity: legendItems.find((item) => item.name === category)
|
|
||||||
?.inactive
|
|
||||||
? filterOpacity
|
|
||||||
: 1,
|
|
||||||
},
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<VisAxis
|
|
||||||
v-if="showXAxis"
|
|
||||||
type="x"
|
|
||||||
:tick-format="xFormatter ?? ((v: number) => data[v]?.[index])"
|
|
||||||
:grid-line="false"
|
|
||||||
:tick-line="false"
|
|
||||||
tick-text-color="hsl(var(--vis-text-color))"
|
|
||||||
/>
|
|
||||||
<VisAxis
|
|
||||||
v-if="showYAxis"
|
|
||||||
type="y"
|
|
||||||
:tick-line="false"
|
|
||||||
:tick-format="yFormatter"
|
|
||||||
:domain-line="false"
|
|
||||||
:grid-line="showGridLine"
|
|
||||||
:attributes="{
|
|
||||||
[Axis.selectors.grid]: {
|
|
||||||
class: 'text-muted',
|
|
||||||
},
|
|
||||||
}"
|
|
||||||
tick-text-color="hsl(var(--vis-text-color))"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<slot />
|
|
||||||
</VisXYContainer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
export { default as AreaChart } from "./AreaChart.vue";
|
|
||||||
|
|
||||||
import type { Spacing } from "@unovis/ts";
|
|
||||||
|
|
||||||
type KeyOf<T extends Record<string, any>> = Extract<keyof T, string>;
|
|
||||||
|
|
||||||
export interface BaseChartProps<T extends Record<string, any>> {
|
|
||||||
/**
|
|
||||||
* The source data, in which each entry is a dictionary.
|
|
||||||
*/
|
|
||||||
data: T[];
|
|
||||||
/**
|
|
||||||
* Select the categories from your data. Used to populate the legend and toolip.
|
|
||||||
*/
|
|
||||||
categories: KeyOf<T>[];
|
|
||||||
/**
|
|
||||||
* Sets the key to map the data to the axis.
|
|
||||||
*/
|
|
||||||
index: KeyOf<T>;
|
|
||||||
/**
|
|
||||||
* Change the default colors.
|
|
||||||
*/
|
|
||||||
colors?: string[];
|
|
||||||
/**
|
|
||||||
* Margin of each the container
|
|
||||||
*/
|
|
||||||
margin?: Spacing;
|
|
||||||
/**
|
|
||||||
* Change the opacity of the non-selected field
|
|
||||||
* @default 0.2
|
|
||||||
*/
|
|
||||||
filterOpacity?: number;
|
|
||||||
/**
|
|
||||||
* Function to format X label
|
|
||||||
*/
|
|
||||||
xFormatter?: (
|
|
||||||
tick: number | Date,
|
|
||||||
i: number,
|
|
||||||
ticks: number[] | Date[],
|
|
||||||
) => string;
|
|
||||||
/**
|
|
||||||
* Function to format Y label
|
|
||||||
*/
|
|
||||||
yFormatter?: (
|
|
||||||
tick: number | Date,
|
|
||||||
i: number,
|
|
||||||
ticks: number[] | Date[],
|
|
||||||
) => string;
|
|
||||||
/**
|
|
||||||
* Controls the visibility of the X axis.
|
|
||||||
* @default true
|
|
||||||
*/
|
|
||||||
showXAxis?: boolean;
|
|
||||||
/**
|
|
||||||
* Controls the visibility of the Y axis.
|
|
||||||
* @default true
|
|
||||||
*/
|
|
||||||
showYAxis?: boolean;
|
|
||||||
/**
|
|
||||||
* Controls the visibility of tooltip.
|
|
||||||
* @default true
|
|
||||||
*/
|
|
||||||
showTooltip?: boolean;
|
|
||||||
/**
|
|
||||||
* Controls the visibility of legend.
|
|
||||||
* @default true
|
|
||||||
*/
|
|
||||||
showLegend?: boolean;
|
|
||||||
/**
|
|
||||||
* Controls the visibility of gridline.
|
|
||||||
* @default true
|
|
||||||
*/
|
|
||||||
showGridLine?: boolean;
|
|
||||||
}
|
|
||||||
61
front/components/ui/chart/ChartContainer.vue
Normal file
61
front/components/ui/chart/ChartContainer.vue
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import type { ChartConfig } from '.'
|
||||||
|
import { useId } from 'reka-ui'
|
||||||
|
import { computed, toRefs } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { provideChartContext } from '.'
|
||||||
|
import ChartStyle from './ChartStyle.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
id?: HTMLAttributes['id']
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
config: ChartConfig
|
||||||
|
cursor?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineSlots<{
|
||||||
|
default: {
|
||||||
|
id: string
|
||||||
|
config: ChartConfig
|
||||||
|
}
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { config } = toRefs(props)
|
||||||
|
const uniqueId = useId()
|
||||||
|
const chartId = computed(() => `chart-${props.id || uniqueId.replace(/:/g, '')}`)
|
||||||
|
|
||||||
|
provideChartContext({
|
||||||
|
id: uniqueId,
|
||||||
|
config,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
data-slot="chart"
|
||||||
|
:data-chart="chartId"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
`[&_.tick_text]:!fill-muted-foreground [&_.tick_line]:!stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex flex-col aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden [&_[data-vis-xy-container]]:h-full [&_[data-vis-single-container]]:h-full h-full [&_[data-vis-xy-container]]:w-full [&_[data-vis-single-container]]:w-full w-full `,
|
||||||
|
props.class
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:style="{
|
||||||
|
'--vis-tooltip-padding': '0px',
|
||||||
|
'--vis-tooltip-background-color': 'transparent',
|
||||||
|
'--vis-tooltip-border-color': 'transparent',
|
||||||
|
'--vis-tooltip-text-color': 'none',
|
||||||
|
'--vis-tooltip-shadow-color': 'none',
|
||||||
|
'--vis-tooltip-backdrop-filter': 'none',
|
||||||
|
'--vis-crosshair-circle-stroke-color': '#0000',
|
||||||
|
'--vis-crosshair-line-stroke-width': cursor ? '1px' : '0px',
|
||||||
|
'--vis-font-family': 'var(--font-sans)',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot :id="uniqueId" :config="config" />
|
||||||
|
<ChartStyle :id="chartId" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { BulletLegendItemInterface } from "@unovis/ts";
|
|
||||||
import { omit } from "@unovis/ts";
|
|
||||||
import { VisCrosshair, VisTooltip } from "@unovis/vue";
|
|
||||||
import { type Component, createApp } from "vue";
|
|
||||||
import { ChartTooltip } from ".";
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
colors: string[];
|
|
||||||
index: string;
|
|
||||||
items: BulletLegendItemInterface[];
|
|
||||||
customTooltip?: Component;
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
colors: () => [],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use weakmap to store reference to each datapoint for Tooltip
|
|
||||||
const wm = new WeakMap();
|
|
||||||
function template(d: any) {
|
|
||||||
if (wm.has(d)) {
|
|
||||||
return wm.get(d);
|
|
||||||
} else {
|
|
||||||
const componentDiv = document.createElement("div");
|
|
||||||
const omittedData = Object.entries(omit(d, [props.index])).map(
|
|
||||||
([key, value]) => {
|
|
||||||
const legendReference = props.items.find((i) => i.name === key);
|
|
||||||
return { ...legendReference, value };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const TooltipComponent = props.customTooltip ?? ChartTooltip;
|
|
||||||
createApp(TooltipComponent, {
|
|
||||||
title: d[props.index].toString(),
|
|
||||||
data: omittedData,
|
|
||||||
}).mount(componentDiv);
|
|
||||||
wm.set(d, componentDiv.innerHTML);
|
|
||||||
return componentDiv.innerHTML;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function color(d: unknown, i: number) {
|
|
||||||
return props.colors[i] ?? "transparent";
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<VisTooltip :horizontal-shift="20" :vertical-shift="20" />
|
|
||||||
<VisCrosshair :template="template" :color="color" />
|
|
||||||
</template>
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { BulletLegendItemInterface } from "@unovis/ts";
|
|
||||||
import { BulletLegend } from "@unovis/ts";
|
|
||||||
import { VisBulletLegend } from "@unovis/vue";
|
|
||||||
import { nextTick, onMounted, ref } from "vue";
|
|
||||||
import { buttonVariants } from "../button";
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{ items: BulletLegendItemInterface[] }>(),
|
|
||||||
{
|
|
||||||
items: () => [],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const emits = defineEmits<{
|
|
||||||
legendItemClick: [d: BulletLegendItemInterface, i: number];
|
|
||||||
"update:items": [payload: BulletLegendItemInterface[]];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const elRef = ref<HTMLElement>();
|
|
||||||
|
|
||||||
function keepStyling() {
|
|
||||||
const selector = `.${BulletLegend.selectors.item}`;
|
|
||||||
nextTick(() => {
|
|
||||||
const elements = elRef.value?.querySelectorAll(selector);
|
|
||||||
const classes = buttonVariants({ variant: "ghost", size: "sm" }).split(" ");
|
|
||||||
elements?.forEach((el) =>
|
|
||||||
el.classList.add(...classes, "!inline-flex", "!mr-2"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
keepStyling();
|
|
||||||
});
|
|
||||||
|
|
||||||
function onLegendItemClick(d: BulletLegendItemInterface, i: number) {
|
|
||||||
emits("legendItemClick", d, i);
|
|
||||||
const isBulletActive = !props.items[i].inactive;
|
|
||||||
const isFilterApplied = props.items.some((i) => i.inactive);
|
|
||||||
if (isFilterApplied && isBulletActive) {
|
|
||||||
// reset filter
|
|
||||||
emits(
|
|
||||||
"update:items",
|
|
||||||
props.items.map((item) => ({ ...item, inactive: false })),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// apply selection, set other item as inactive
|
|
||||||
emits(
|
|
||||||
"update:items",
|
|
||||||
props.items.map((item) =>
|
|
||||||
item.name === d.name
|
|
||||||
? { ...d, inactive: false }
|
|
||||||
: { ...item, inactive: true },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
keepStyling();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
ref="elRef"
|
|
||||||
class="w-max"
|
|
||||||
:style="{
|
|
||||||
'--vis-legend-bullet-size': '16px',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<VisBulletLegend :items="items" :on-legend-item-click="onLegendItemClick" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
56
front/components/ui/chart/ChartLegendContent.vue
Normal file
56
front/components/ui/chart/ChartLegendContent.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useChart } from '.'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
hideIcon?: boolean
|
||||||
|
nameKey?: string
|
||||||
|
verticalAlign?: 'bottom' | 'top'
|
||||||
|
// payload?: any[]
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
verticalAlign: 'bottom',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { id, config } = useChart()
|
||||||
|
|
||||||
|
const payload = computed(() =>
|
||||||
|
Object.entries(config.value).map(([key, value]) => {
|
||||||
|
return {
|
||||||
|
key: props.nameKey || key,
|
||||||
|
itemConfig: config.value[key],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const containerSelector = ref('')
|
||||||
|
onMounted(() => {
|
||||||
|
containerSelector.value = `[data-chart="chart-${id}"]>[data-vis-xy-container]`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="containerSelector" :class="cn('flex items-center justify-center gap-4', verticalAlign === 'top' ? 'pb-3' : 'pt-3', props.class)">
|
||||||
|
<div
|
||||||
|
v-for="{ key, itemConfig } in payload"
|
||||||
|
:key="key"
|
||||||
|
:class="cn('[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3')"
|
||||||
|
>
|
||||||
|
<component :is="itemConfig.icon" v-if="itemConfig?.icon" />
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: itemConfig?.color,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{ itemConfig?.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { BulletLegendItemInterface } from "@unovis/ts";
|
|
||||||
import { omit } from "@unovis/ts";
|
|
||||||
import { VisTooltip } from "@unovis/vue";
|
|
||||||
import { type Component, createApp } from "vue";
|
|
||||||
import { ChartTooltip } from ".";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
selector: string;
|
|
||||||
index: string;
|
|
||||||
items?: BulletLegendItemInterface[];
|
|
||||||
valueFormatter?: (tick: number, i?: number, ticks?: number[]) => string;
|
|
||||||
customTooltip?: Component;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// Use weakmap to store reference to each datapoint for Tooltip
|
|
||||||
const wm = new WeakMap();
|
|
||||||
function template(d: any, i: number, elements: (HTMLElement | SVGElement)[]) {
|
|
||||||
const valueFormatter = props.valueFormatter ?? ((tick: number) => `${tick}`);
|
|
||||||
if (props.index in d) {
|
|
||||||
if (wm.has(d)) {
|
|
||||||
return wm.get(d);
|
|
||||||
} else {
|
|
||||||
const componentDiv = document.createElement("div");
|
|
||||||
const omittedData = Object.entries(omit(d, [props.index])).map(
|
|
||||||
([key, value]) => {
|
|
||||||
const legendReference = props.items?.find((i) => i.name === key);
|
|
||||||
return { ...legendReference, value: valueFormatter(value) };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const TooltipComponent = props.customTooltip ?? ChartTooltip;
|
|
||||||
createApp(TooltipComponent, {
|
|
||||||
title: d[props.index],
|
|
||||||
data: omittedData,
|
|
||||||
}).mount(componentDiv);
|
|
||||||
wm.set(d, componentDiv.innerHTML);
|
|
||||||
return componentDiv.innerHTML;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const data = d.data;
|
|
||||||
|
|
||||||
if (wm.has(data)) {
|
|
||||||
return wm.get(data);
|
|
||||||
} else {
|
|
||||||
const style = getComputedStyle(elements[i]);
|
|
||||||
const omittedData = [
|
|
||||||
{
|
|
||||||
name: data.name,
|
|
||||||
value: valueFormatter(data[props.index]),
|
|
||||||
color: style.fill,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const componentDiv = document.createElement("div");
|
|
||||||
const TooltipComponent = props.customTooltip ?? ChartTooltip;
|
|
||||||
createApp(TooltipComponent, {
|
|
||||||
title: d[props.index],
|
|
||||||
data: omittedData,
|
|
||||||
}).mount(componentDiv);
|
|
||||||
wm.set(d, componentDiv.innerHTML);
|
|
||||||
return componentDiv.innerHTML;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<VisTooltip
|
|
||||||
:horizontal-shift="20"
|
|
||||||
:vertical-shift="20"
|
|
||||||
:triggers="{
|
|
||||||
[selector]: template,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
37
front/components/ui/chart/ChartStyle.vue
Normal file
37
front/components/ui/chart/ChartStyle.vue
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import { Primitive } from 'reka-ui'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { THEMES, useChart } from '.'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
id?: HTMLAttributes['id']
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
const colorConfig = computed(() => {
|
||||||
|
return Object.entries(config.value).filter(([, config]) => config.theme || config.color)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive v-if="colorConfig.length" as="style">
|
||||||
|
{{
|
||||||
|
Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color
|
||||||
|
return color ? ` --color-${key}: ${color};` : null
|
||||||
|
})
|
||||||
|
.join('\n')}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join('\n')
|
||||||
|
}}
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../card";
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
title?: string;
|
|
||||||
data: {
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
value: any;
|
|
||||||
}[];
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Card class="text-sm">
|
|
||||||
<CardHeader v-if="title" class="p-3 border-b">
|
|
||||||
<CardTitle>
|
|
||||||
{{ title }}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="p-3 min-w-[180px] flex flex-col gap-1">
|
|
||||||
<div v-for="(item, key) in data" :key="key" class="flex justify-between">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<span class="w-2.5 h-2.5 mr-2">
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 30 30">
|
|
||||||
<path
|
|
||||||
d=" M 15 15 m -14, 0 a 14,14 0 1,1 28,0 a 14,14 0 1,1 -28,0"
|
|
||||||
:stroke="item.color"
|
|
||||||
:fill="item.color"
|
|
||||||
stroke-width="1"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<span>{{ item.name }}</span>
|
|
||||||
</div>
|
|
||||||
<span class="font-semibold ml-4">{{ item.value }}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</template>
|
|
||||||
109
front/components/ui/chart/ChartTooltipContent.vue
Normal file
109
front/components/ui/chart/ChartTooltipContent.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import type { ChartConfig } from '.'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
hideLabel?: boolean
|
||||||
|
hideIndicator?: boolean
|
||||||
|
indicator?: 'line' | 'dot' | 'dashed'
|
||||||
|
nameKey?: string
|
||||||
|
labelKey?: string
|
||||||
|
labelFormatter?: (d: number | Date) => string
|
||||||
|
payload?: Record<string, any>
|
||||||
|
config?: ChartConfig
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
color?: string
|
||||||
|
x?: number | Date
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
payload: () => ({}),
|
||||||
|
config: () => ({}),
|
||||||
|
indicator: 'dot',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: currently we use `createElement` and `render` to render the
|
||||||
|
// const chartContext = useChart(null)
|
||||||
|
|
||||||
|
const payload = computed(() => {
|
||||||
|
return Object.entries(props.payload)
|
||||||
|
.map(([key, value]) => {
|
||||||
|
// const key = `${props.nameKey || item.name || item.dataKey || "value"}`
|
||||||
|
const itemConfig = props.config[key]
|
||||||
|
const indicatorColor = props.config[key]?.color ?? props.payload.fill
|
||||||
|
|
||||||
|
return { key, value, itemConfig, indicatorColor }
|
||||||
|
})
|
||||||
|
.filter((i) => i.itemConfig)
|
||||||
|
})
|
||||||
|
|
||||||
|
const nestLabel = computed(() => Object.keys(props.payload).length === 1 && props.indicator !== 'dot')
|
||||||
|
const tooltipLabel = computed(() => {
|
||||||
|
if (props.hideLabel) return null
|
||||||
|
if (props.labelFormatter && props.x !== undefined) {
|
||||||
|
return props.labelFormatter(props.x)
|
||||||
|
}
|
||||||
|
return props.labelKey ? props.config[props.labelKey]?.label || props.payload[props.labelKey] : props.x
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn('border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl', props.class)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
<div v-if="!nestLabel && tooltipLabel" class="font-medium">
|
||||||
|
{{ tooltipLabel }}
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-1.5">
|
||||||
|
<div
|
||||||
|
v-for="{ value, itemConfig, indicatorColor, key } in payload"
|
||||||
|
:key="key"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
|
||||||
|
indicator === 'dot' && 'items-center'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<component :is="itemConfig.icon" v-if="itemConfig?.icon" />
|
||||||
|
<template v-else-if="!hideIndicator">
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn('shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)', {
|
||||||
|
'h-2.5 w-2.5': indicator === 'dot',
|
||||||
|
'w-1': indicator === 'line',
|
||||||
|
'w-0 border-[1.5px] border-dashed bg-transparent': indicator === 'dashed',
|
||||||
|
'my-0.5': nestLabel && indicator === 'dashed',
|
||||||
|
})
|
||||||
|
"
|
||||||
|
:style="{
|
||||||
|
'--color-bg': indicatorColor,
|
||||||
|
'--color-border': indicatorColor,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div :class="cn('flex flex-1 justify-between leading-none', nestLabel ? 'items-end' : 'items-center')">
|
||||||
|
<div class="grid gap-1.5">
|
||||||
|
<div v-if="nestLabel" class="font-medium">
|
||||||
|
{{ tooltipLabel }}
|
||||||
|
</div>
|
||||||
|
<span class="text-muted-foreground">
|
||||||
|
{{ itemConfig?.label || value }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="value" class="text-foreground font-mono font-medium tabular-nums">
|
||||||
|
{{ value.toLocaleString() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,21 +1,26 @@
|
|||||||
export { default as ChartCrosshair } from "./ChartCrosshair.vue";
|
import type { Component, Ref } from 'vue'
|
||||||
export { default as ChartLegend } from "./ChartLegend.vue";
|
import { createContext } from 'reka-ui'
|
||||||
export { default as ChartSingleTooltip } from "./ChartSingleTooltip.vue";
|
|
||||||
export { default as ChartTooltip } from "./ChartTooltip.vue";
|
|
||||||
|
|
||||||
export function defaultColors(count: number = 3) {
|
export { default as ChartContainer } from './ChartContainer.vue'
|
||||||
const quotient = Math.floor(count / 2);
|
export { default as ChartLegendContent } from './ChartLegendContent.vue'
|
||||||
const remainder = count % 2;
|
export { default as ChartTooltipContent } from './ChartTooltipContent.vue'
|
||||||
|
export { componentToString } from './utils'
|
||||||
|
|
||||||
const primaryCount = quotient + remainder;
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
const secondaryCount = quotient;
|
export const THEMES = { light: '', dark: '.dark' } as const
|
||||||
return [
|
|
||||||
...Array.from(new Array(primaryCount).keys()).map(
|
export type ChartConfig = {
|
||||||
(i) => `hsl(var(--vis-primary-color) / ${1 - (1 / primaryCount) * i})`,
|
[k in string]: {
|
||||||
),
|
label?: string | Component
|
||||||
...Array.from(new Array(secondaryCount).keys()).map(
|
icon?: string | Component
|
||||||
(i) =>
|
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> })
|
||||||
`hsl(var(--vis-secondary-color) / ${1 - (1 / secondaryCount) * i})`,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ChartContextProps {
|
||||||
|
id: string
|
||||||
|
config: Ref<ChartConfig>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const [useChart, provideChartContext] = createContext<ChartContextProps>('Chart')
|
||||||
|
|
||||||
|
export { VisCrosshair as ChartCrosshair, VisTooltip as ChartTooltip } from '@unovis/vue'
|
||||||
|
|||||||
42
front/components/ui/chart/utils.ts
Normal file
42
front/components/ui/chart/utils.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { ChartConfig } from '.'
|
||||||
|
import { isClient } from '@vueuse/core'
|
||||||
|
import { useId } from 'reka-ui'
|
||||||
|
import { h, render } from 'vue'
|
||||||
|
|
||||||
|
// Simple cache using a Map to store serialized object keys
|
||||||
|
const cache = new Map<string, string>()
|
||||||
|
|
||||||
|
// Convert object to a consistent string key
|
||||||
|
function serializeKey(key: Record<string, any>): string {
|
||||||
|
return JSON.stringify(key, Object.keys(key).sort())
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Constructor<P = any> {
|
||||||
|
__isFragment?: never
|
||||||
|
__isTeleport?: never
|
||||||
|
__isSuspense?: never
|
||||||
|
new (...args: any[]): {
|
||||||
|
$props: P
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function componentToString<P>(config: ChartConfig, component: Constructor<P>, props?: P) {
|
||||||
|
if (!isClient) return
|
||||||
|
|
||||||
|
// This function will be called once during mount lifecycle
|
||||||
|
const id = useId()
|
||||||
|
|
||||||
|
// https://unovis.dev/docs/auxiliary/Crosshair#component-props
|
||||||
|
return (_data: any, x: number | Date) => {
|
||||||
|
const data = 'data' in _data ? _data.data : _data
|
||||||
|
const serializedKey = `${id}-${serializeKey(data)}`
|
||||||
|
const cachedContent = cache.get(serializedKey)
|
||||||
|
if (cachedContent) return cachedContent
|
||||||
|
|
||||||
|
const vnode = h<unknown>(component, { ...props, payload: data, config, x })
|
||||||
|
const div = document.createElement('div')
|
||||||
|
render(vnode, div)
|
||||||
|
cache.set(serializedKey, div.innerHTML)
|
||||||
|
return div.innerHTML
|
||||||
|
}
|
||||||
|
}
|
||||||
47
front/composables/useFeatureMeta.ts
Normal file
47
front/composables/useFeatureMeta.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { LucideShare, LucideImageMinus, LucideArrowRightLeft } from 'lucide-vue-next'
|
||||||
|
import useMyAppConfig from '@/composables/useMyAppConfig'
|
||||||
|
import type { FileHandleKey, TextHandleKey } from '../components/Preprocessing/types'
|
||||||
|
import generateRandomColors from '@/lib/generateRandomColors'
|
||||||
|
|
||||||
|
export type FeatureKey = FileHandleKey | TextHandleKey
|
||||||
|
|
||||||
|
export type FeatureMeta = {
|
||||||
|
key: FeatureKey
|
||||||
|
label: string
|
||||||
|
icon: any
|
||||||
|
}
|
||||||
|
|
||||||
|
const allFeatureMeta = (t: (key: string) => string): FeatureMeta[] => [
|
||||||
|
{
|
||||||
|
key: 'file-share',
|
||||||
|
label: t('page.upload.file.handleType.file-share'),
|
||||||
|
icon: LucideShare,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'file-image-compress',
|
||||||
|
label: t('page.upload.file.handleType.file-image-compress'),
|
||||||
|
icon: LucideImageMinus,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'file-image-convert',
|
||||||
|
label: t('page.upload.file.handleType.file-image-convert'),
|
||||||
|
icon: LucideArrowRightLeft,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'text-share',
|
||||||
|
label: t('page.upload.text.handleType.text-share'),
|
||||||
|
icon: LucideShare,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function useFeatureMeta() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const appConfig = useMyAppConfig()
|
||||||
|
|
||||||
|
return computed(() => {
|
||||||
|
const enabledKeys = appConfig.value?.features ?? []
|
||||||
|
const result = allFeatureMeta(t).filter((meta) => enabledKeys.includes(meta.key))
|
||||||
|
const colors = generateRandomColors(result.length)
|
||||||
|
return result.map((meta, index) => ({ ...meta, style: { backgroundColor: colors[index] } }))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ const useMyAppConfig = () => {
|
|||||||
site_bg_url: string
|
site_bg_url: string
|
||||||
version: string
|
version: string
|
||||||
build_time: number
|
build_time: number
|
||||||
|
features: string[]
|
||||||
}
|
}
|
||||||
}>('/api/config')
|
}>('/api/config')
|
||||||
return computed(() => data?.value?.data)
|
return computed(() => data?.value?.data)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user