Merge pull request '0.0.9' (#1) from dev into main

Reviewed-on: https://gitea.fudaoyuan.icu/keven/015/pulls/1
This commit is contained in:
keven
2026-03-07 09:24:01 +08:00
121 changed files with 5902 additions and 5226 deletions

View 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

View 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

View File

@@ -0,0 +1,52 @@
name: Publish
on:
push:
tags:
- '*'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: fudaoyuanicu
password: ${{ secrets.DOCKER_PASSWORD }}
- uses: docker/metadata-action@v5
with:
images: fudaoyuanicu/015-app
tags: |
type=ref,event=tag
type=raw,value=latest
- 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
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 }}
- name: build-worker
uses: docker/build-push-action@v5
with:
context: .
file: ./worker/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- 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"

1
.gitignore vendored
View File

@@ -22,6 +22,7 @@ logs
.env
.env.*
!.env.example
config.yaml
# Serwist
/front/public/sw*

View File

@@ -5,7 +5,7 @@ FROM front-base AS front-deps
RUN apk add --no-cache gcompat
WORKDIR /app
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
@@ -13,16 +13,17 @@ WORKDIR /app
COPY --from=front-deps /app/dist/ .
RUN corepack enable pnpm && pnpm build
FROM golang:1.24.3 AS backend-builder
FROM golang:1.25.5 AS backend-builder
WORKDIR /app
# Download Go modules
COPY backend/go.mod backend/go.sum ./
RUN go env -w GO111MODULE=on && go env -w GOPROXY=https://goproxy.cn,direct && go mod download
# Copy the source code. Note the slash at the end, as explained in
# https://docs.docker.com/engine/reference/builder/#copy
COPY backend/ .
# Build
RUN CGO_ENABLED=0 GOOS=linux go build -o backend
# Workspace and module manifests for cache
COPY go.work go.work.sum ./
COPY backend/ ./backend/
COPY worker/ ./worker/
COPY pkg/ ./pkg/
RUN go env -w GO111MODULE=on && go env -w GOPROXY=https://goproxy.cn,direct && \
go mod download
# Build from workspace root so pkg/utils, pkg/models, pkg/services resolve
RUN CGO_ENABLED=0 GOOS=linux go build -o backend ./backend
FROM front-base AS runner

View File

@@ -70,6 +70,23 @@ docker compose up -d
```
## 🚀 快速开始
### Docker
1. 下载文件
- config.example.yaml
- docker-compose.yml
2. 把config.example.yaml配置完成后改为config.yaml
3. 启动
```bash
docker compose up -d
```
## 🏗️ 技术架构
### 前端技术栈

View File

@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1
FROM golang:1.24.3 AS builder
FROM golang:1.25.5 AS builder
# Set destination for COPY
WORKDIR /app

View File

@@ -1,56 +1,36 @@
module backend
go 1.24.0
toolchain go1.24.3
go 1.25.5
require (
dario.cat/mergo v1.0.2
github.com/dustin/go-humanize v1.0.1
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/gorilla/sessions v1.4.0
github.com/hibiken/asynq v0.25.1
github.com/labstack/echo-contrib v0.17.4
github.com/labstack/echo/v4 v4.13.4
github.com/labstack/echo-contrib v0.50.0
github.com/labstack/echo/v5 v5.0.1
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/redis/go-redis/v9 v9.14.0
github.com/samber/lo v1.51.0
github.com/samber/lo v1.52.0
github.com/spf13/cast v1.10.0
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.42.0
golang.org/x/time v0.13.0
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.47.0
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // 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/gorilla/context 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/redis/go-redis/v9 v9.17.3 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sagikazarmark/locafero v0.12.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.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -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/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -10,16 +8,10 @@ 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/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/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/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
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/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
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/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
@@ -38,69 +30,44 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
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/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk=
github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0=
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/labstack/echo-contrib v0.50.0 h1:MLTQdqME3BEBczV2thYz9yPT5sBhzkoUEpwAOY9llds=
github.com/labstack/echo-contrib v0.50.0/go.mod h1:oftqJL4enNg9ao1VLpVZmisVE5/8uwHtIYE4zTpqyWU=
github.com/labstack/echo/v5 v5.0.1 h1:60L7x1KMWRIJuaFqvnEHH322g+YnsMWq5Rzaeo6lcP4=
github.com/labstack/echo/v5 v5.0.1/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
github.com/mattn/go-colorable v0.1.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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
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/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/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
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/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
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/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/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
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/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
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/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -1,16 +1,17 @@
package controllers
import (
"backend/internal/models"
"backend/internal/utils"
"encoding/json"
"pkg/models"
u "pkg/utils"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v5"
"github.com/samber/lo"
)
func GetAbout(c echo.Context) error {
maxStorageSize, err := utils.GetFileSize(utils.GetEnv("upload.maximum"))
func GetAbout(c *echo.Context) error {
maxStorageSize, err := u.GetFileSize(u.GetEnv("upload.maximum"))
if err != nil {
return utils.HTTPErrorHandler(c, err)
}
@@ -30,12 +31,12 @@ func GetAbout(c echo.Context) error {
}, 0)
return utils.HTTPSuccessHandler(c, map[string]any{
"bg_url": utils.GetEnv("about.bg_url"),
"content": utils.GetEnvMapString("about.content"),
"email": utils.GetEnv("about.email"),
"name": utils.GetEnv("about.name"),
"url": utils.GetEnv("about.url"),
"avatar": utils.GetEnv("about.avatar"),
"bg_url": u.GetEnv("about.bg_url"),
"content": u.GetEnvMapString("about.content"),
"email": u.GetEnv("about.email"),
"name": u.GetEnv("about.name"),
"url": u.GetEnv("about.url"),
"avatar": u.GetEnv("about.avatar"),
"file": map[string]any{
"maximun": maxStorageSize,
"current": currentFileSize,

View File

@@ -2,20 +2,21 @@ package controllers
import (
"backend/internal/utils"
u "pkg/utils"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v5"
"github.com/spf13/cast"
)
func GetConfig(c echo.Context) error {
func GetConfig(c *echo.Context) error {
return utils.HTTPSuccessHandler(c, map[string]any{
"site_title": utils.GetEnvMapString("site.title"),
"site_desc": utils.GetEnvMapString("site.desc"),
"site_url": utils.GetEnv("site.url"),
"site_icon": utils.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"),
"version": utils.GetEnvWithDefault("VERSION", "dev"),
"build_time": cast.ToInt(utils.GetEnvWithDefault("BUILD_TIME", cast.ToString(time.Now().Unix()))),
"site_title": u.GetEnvMapString("site.title"),
"site_desc": u.GetEnvMapString("site.desc"),
"site_url": u.GetEnv("site.url"),
"site_icon": u.GetEnvWithDefault("site.icon", "/logo.png"),
"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": u.GetEnvWithDefault("VERSION", "dev"),
"build_time": cast.ToInt(u.GetEnvWithDefault("BUILD_TIME", cast.ToString(time.Now().Unix()))),
})
}

View File

@@ -1,15 +1,14 @@
package controllers
import (
"backend/internal/models"
"backend/internal/utils"
"backend/middleware"
"errors"
"fmt"
"pkg/models"
u "pkg/utils"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v5"
"github.com/spf13/cast"
)
@@ -18,31 +17,30 @@ type DownloadShareClaims struct {
jwt.RegisteredClaims
}
func DownloadShare(c echo.Context) error {
cc := c.(*middleware.CustomContext)
token := cc.FormValue("token")
func DownloadShare(c *echo.Context) error {
token := c.FormValue("token")
if token == "" {
return utils.HTTPErrorHandler(c, errors.New("缺少token"))
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
}
claims := DownloadShareClaims{}
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 {
return utils.HTTPErrorHandler(c, err)
}
if !t.Valid {
return utils.HTTPErrorHandler(c, errors.New("token格式错误"))
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
}
shareInfo, _ := models.GetRedisShareInfo(claims.ShareId)
if shareInfo.Type == models.ShareTypeFile {
fileInfo, _ := models.GetRedisFileInfo(shareInfo.Data)
uploadPath, err := utils.GetUploadDirPath()
uploadPath, err := u.GetUploadDirPath()
if err != nil {
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{
"data": shareInfo.Data,
@@ -54,16 +52,14 @@ type VaildateShareProps struct {
Password string `json:"password"`
}
func VaildateShare(c echo.Context) error {
cc := c.(*middleware.CustomContext)
func VaildateShare(c *echo.Context) error {
r := new(VaildateShareProps)
if err := cc.Bind(r); err != nil {
if err := c.Bind(r); err != nil {
return utils.HTTPErrorHandler(c, err)
}
if r.ShareId == "" {
return utils.HTTPErrorHandler(c, errors.New("缺少分享ID"))
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
}
shareInfo, err := models.GetRedisShareInfo(r.ShareId)
@@ -71,25 +67,25 @@ func VaildateShare(c echo.Context) error {
return utils.HTTPErrorHandler(c, err)
}
if shareInfo == nil {
return utils.HTTPErrorHandler(c, errors.New("分享不存在"))
return utils.HTTPErrorHandler(c, ErrShareNotFound)
}
if shareInfo.Password != "" {
if r.Password == "" {
return utils.HTTPErrorHandler(c, errors.New("缺少分享密码"))
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
}
hash, err := utils.GeneratePasswordHash(r.Password)
if err != nil {
return utils.HTTPErrorHandler(c, err)
}
if hash != shareInfo.Password {
return utils.HTTPErrorHandler(c, errors.New("分享密码错误"))
return utils.HTTPErrorHandler(c, ErrInvalidSharePassword)
}
}
// 如果下载次数为0则设置为-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{
ShareId: r.ShareId,
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
downloadToken, err := token.SignedString([]byte(utils.GetEnv("share.download_secret")))
downloadToken, err := token.SignedString([]byte(u.GetEnv("share.download_secret")))
if err != nil {
return utils.HTTPErrorHandler(c, err)
}
@@ -108,10 +104,10 @@ func VaildateShare(c echo.Context) error {
return utils.HTTPErrorHandler(c, err)
}
if fileInfo == nil {
return utils.HTTPErrorHandler(c, errors.New("分享文件不存在"))
return utils.HTTPErrorHandler(c, ErrShareFileNotFound)
}
if fileInfo.FileType != models.FileTypeUpload {
return utils.HTTPErrorHandler(c, errors.New("分享文件状态错误"))
return utils.HTTPErrorHandler(c, ErrInvalidShareFileState)
}
}
// download_nums 必须放在创建token的时候减掉不然多线程下载会导致多次减掉
@@ -120,9 +116,12 @@ func VaildateShare(c echo.Context) error {
if latestViewNum < 1 {
latestViewNum = -1
}
models.SetRedisShareInfo(r.ShareId, models.RedisShareInfo{
err = models.SetRedisShareInfo(r.ShareId, models.RedisShareInfo{
ViewNum: latestViewNum,
})
if err != nil {
return utils.HTTPErrorHandler(c, err)
}
// 统计分享数
currentDate := time.Now().Format("2006-01-02")
@@ -136,7 +135,10 @@ func VaildateShare(c echo.Context) error {
}
}
statData.DownloadNum += 1
models.SetRedisStat(currentDate, *statData)
err = models.SetRedisStat(currentDate, *statData)
if err != nil {
return utils.HTTPErrorHandler(c, err)
}
if shareInfo.Type == models.ShareTypeFile {
return utils.HTTPSuccessHandler(c, map[string]any{

View 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") // 下载次数不足
)

View File

@@ -1,22 +1,22 @@
package controllers
import (
"backend/internal/models"
"backend/internal/services"
"backend/internal/utils"
"encoding/json"
"errors"
"fmt"
"math"
"mime/multipart"
"os"
"path/filepath"
"pkg/models"
s "pkg/services"
u "pkg/utils"
"time"
"github.com/hibiken/asynq"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v5"
"github.com/spf13/cast"
)
func CreateUploadTask(c echo.Context) error {
func CreateUploadTask(c *echo.Context) error {
// cc := c.(*middleware.CustomContext)
r := new(models.FileInfo)
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 == "" {
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 {
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{
"size": fileInfo.FileSize,
"mime_type": fileInfo.MimeType,
@@ -38,9 +49,10 @@ func CreateUploadTask(c echo.Context) error {
"expire": fileInfo.Expire,
"id": fileId,
"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 {
return utils.HTTPErrorHandler(c, err)
}
@@ -58,7 +70,7 @@ func CreateUploadTask(c echo.Context) error {
totalSize += fileInfo.FileSize
}
if totalSize+r.FileSize > int64(maxStorageSize) {
return utils.HTTPErrorHandler(c, errors.New("存储空间不足"))
return utils.HTTPErrorHandler(c, ErrInsufficientStorage)
}
ChunkSize := int64(0.25 * 1024 * 1024)
@@ -66,7 +78,7 @@ func CreateUploadTask(c echo.Context) error {
for r.FileSize/ChunkSize > 1000 {
ChunkSize *= 2
}
uploadTaskExpire := int64(3600)
uploadTaskExpire := cast.ToInt64(u.GetEnvWithDefault("upload.remove_expire", "2")) * 3600
newFileInfo := models.RedisFileInfo{
FileType: models.FileTypeInit,
FileInfo: models.FileInfo{
@@ -83,14 +95,7 @@ func CreateUploadTask(c echo.Context) error {
return utils.HTTPErrorHandler(c, err)
}
client := utils.GetQueueClient()
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))
err = s.SetFileRemoveTask(fileId, time.Duration(uploadTaskExpire)*time.Second)
if err != nil {
return utils.HTTPErrorHandler(c, err)
}
@@ -112,14 +117,14 @@ type UploadFileSliceProps struct {
FileSlice *multipart.FileHeader `form:"file"`
}
func UploadFileSlice(c echo.Context) error {
func UploadFileSlice(c *echo.Context) error {
r := new(UploadFileSliceProps)
if err := c.Bind(r); err != nil {
return utils.HTTPErrorHandler(c, err)
}
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)
if err != nil {
@@ -128,18 +133,18 @@ func UploadFileSlice(c echo.Context) error {
now := time.Now().Unix()
if fileInfo.CreatedAt+fileInfo.Expire < now {
return utils.HTTPErrorHandler(c, errors.New("上传任务已过期"))
return utils.HTTPErrorHandler(c, ErrUploadTaskExpired)
}
if fileInfo.FileType != models.FileTypeInit {
return utils.HTTPErrorHandler(c, errors.New("上传任务状态错误"))
return utils.HTTPErrorHandler(c, ErrInvalidUploadTaskState)
}
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 {
return utils.HTTPErrorHandler(c, errors.New("文件切片大小错误"))
return utils.HTTPErrorHandler(c, ErrInvalidFileSliceSize)
}
// 打开文件
@@ -147,9 +152,14 @@ func UploadFileSlice(c echo.Context) error {
if err != nil {
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)
}
@@ -162,14 +172,14 @@ type FinishUploadTaskProps struct {
FileId string `json:"id"`
}
func FinishUploadTask(c echo.Context) error {
func FinishUploadTask(c *echo.Context) error {
r := new(FinishUploadTaskProps)
if err := c.Bind(r); err != nil {
return utils.HTTPErrorHandler(c, err)
}
if r.FileId == "" {
return utils.HTTPErrorHandler(c, errors.New("文件ID不能为空"))
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
}
fileInfo, err := models.GetRedisFileInfo(r.FileId)
@@ -178,50 +188,57 @@ func FinishUploadTask(c echo.Context) error {
}
if fileInfo.FileType != models.FileTypeInit {
return utils.HTTPErrorHandler(c, errors.New("上传任务状态错误"))
return utils.HTTPErrorHandler(c, ErrInvalidUploadTaskState)
}
now := time.Now().Unix()
if fileInfo.CreatedAt+fileInfo.Expire < now {
return utils.HTTPErrorHandler(c, errors.New("上传任务已过期"))
return utils.HTTPErrorHandler(c, ErrUploadTaskExpired)
}
// 合并文件切片
uploadPath, _ := utils.GetUploadDirPath()
slicesPath := filepath.Join(uploadPath, fmt.Sprintf("%s_%s", r.FileId, "tmp"))
uploadPath, err := u.GetUploadDirPath()
if err != nil {
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)
if err := services.MergeFileSlices(slicesPath, mergeFilePath); err != nil {
mergeFilePath, err := services.MergeFileSlices(r.FileId, uploadPath)
if err != nil {
return utils.HTTPErrorHandler(c, err)
}
// 计算文件MD5
file, err := os.Open(mergeFilePath)
if err != nil {
file.Close()
os.Remove(mergeFilePath)
return utils.HTTPErrorHandler(c, err)
}
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)
}
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,
})
if err != nil {
return utils.HTTPErrorHandler(c, err)
}
// 统计
currentDate := time.Now().Format("2006-01-02")
statData, _ := models.GetRedisStat(currentDate)
@@ -235,7 +252,10 @@ func FinishUploadTask(c echo.Context) error {
}
statData.FileSize += fileInfo.FileSize
statData.FileNum += 1
models.SetRedisStat(currentDate, *statData)
err = models.SetRedisStat(currentDate, *statData)
if err != nil {
return utils.HTTPErrorHandler(c, err)
}
return utils.HTTPSuccessHandler(c, map[string]any{
"size": fileInfo.FileSize,

View File

@@ -1,16 +1,15 @@
package controllers
import (
"backend/internal/models"
"backend/internal/utils"
"backend/middleware"
"encoding/json"
"errors"
"pkg/models"
u "pkg/utils"
"strings"
"time"
"github.com/hibiken/asynq"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v5"
gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/spf13/cast"
)
@@ -33,19 +32,19 @@ type ShareConfig struct {
HasPickupCode bool `json:"has_pickup_code"`
}
func CreateShareInfo(c echo.Context) error {
cc := c.(*middleware.CustomContext)
func CreateShareInfo(c *echo.Context) error {
owner, _ := echo.ContextGet[string](c, "auth")
r := new(CreateShareProps)
if err := cc.Bind(r); err != nil {
if err := c.Bind(r); err != nil {
return utils.HTTPErrorHandler(c, err)
}
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)
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()
@@ -59,10 +58,10 @@ func CreateShareInfo(c echo.Context) error {
return utils.HTTPErrorHandler(c, err)
}
if fileInfo == nil {
return utils.HTTPErrorHandler(c, errors.New("分享文件不存在"))
return utils.HTTPErrorHandler(c, ErrShareFileNotFound)
}
if fileInfo.FileType != models.FileTypeUpload {
return utils.HTTPErrorHandler(c, errors.New("分享文件状态错误"))
return utils.HTTPErrorHandler(c, ErrInvalidShareFileState)
}
}
password := ""
@@ -74,17 +73,20 @@ func CreateShareInfo(c echo.Context) error {
password = hash
}
models.SetRedisShareInfo(id, models.RedisShareInfo{
err = models.SetRedisShareInfo(id, models.RedisShareInfo{
Data: r.Data,
Type: r.Type,
CreatedAt: time.Now().Unix(),
Owner: cc.Auth.(string),
Owner: owner,
ViewNum: r.Config.ViewNum,
Password: password,
// NotifyEmail: r.Config.NotifyEmail,
FileName: r.FileName,
ExpireAt: ExpireTime.Unix(),
})
if err != nil {
return utils.HTTPErrorHandler(c, err)
}
var pickupCode string
if r.Config.HasPickupCode {
for {
@@ -106,14 +108,17 @@ func CreateShareInfo(c echo.Context) error {
return utils.HTTPErrorHandler(c, err)
}
shareIDs = append(shareIDs, id)
models.SetRedisFileShareRelational(r.Data, shareIDs)
client := utils.GetQueueClient()
err = models.SetRedisFileShareRelational(r.Data, shareIDs)
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})
if err != nil {
return utils.HTTPErrorHandler(c, err)
}
// 这里延时分享过期时间基础上加下载窗口期后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
_, err = client.Enqueue(asynq.NewTask("share:remove", json), asynq.ProcessIn(deleteTime))
if err != nil {
@@ -133,7 +138,10 @@ func CreateShareInfo(c echo.Context) error {
}
}
statData.ShareNum += 1
models.SetRedisStat(currentDate, *statData)
err = models.SetRedisStat(currentDate, *statData)
if err != nil {
return utils.HTTPErrorHandler(c, err)
}
return utils.HTTPSuccessHandler(c, map[string]any{
"id": id,
@@ -148,11 +156,10 @@ type GetShareProps struct {
ShareId string `param:"id"`
}
func GetShareInfo(c echo.Context) error {
cc := c.(*middleware.CustomContext)
shareId := cc.Param("id")
func GetShareInfo(c *echo.Context) error {
shareId := c.Param("id")
if shareId == "" {
return utils.HTTPErrorHandler(c, errors.New("缺少分享ID"))
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
}
shareInfo, err := models.GetRedisShareInfo(shareId)
@@ -160,7 +167,7 @@ func GetShareInfo(c echo.Context) error {
return utils.HTTPErrorHandler(c, err)
}
if shareInfo == nil || shareInfo.ViewNum < 1 {
return utils.HTTPErrorHandler(c, errors.New("分享不存在"))
return utils.HTTPErrorHandler(c, ErrShareNotFound)
}
if shareInfo.Type == models.ShareTypeFile {
@@ -169,10 +176,10 @@ func GetShareInfo(c echo.Context) error {
return utils.HTTPErrorHandler(c, err)
}
if fileInfo == nil {
return utils.HTTPErrorHandler(c, errors.New("分享文件不存在"))
return utils.HTTPErrorHandler(c, ErrShareFileNotFound)
}
if fileInfo.FileType != models.FileTypeUpload {
return utils.HTTPErrorHandler(c, errors.New("分享文件状态错误"))
return utils.HTTPErrorHandler(c, ErrInvalidShareFileState)
}
return utils.HTTPSuccessHandler(c, map[string]any{
"id": shareId,
@@ -198,18 +205,17 @@ func GetShareInfo(c echo.Context) error {
})
}
func GetShareByPickupCode(c echo.Context) error {
cc := c.(*middleware.CustomContext)
pickupCode := cc.Param("code")
func GetShareByPickupCode(c *echo.Context) error {
pickupCode := c.Param("code")
if pickupCode == "" {
return utils.HTTPErrorHandler(c, errors.New("缺少提取码"))
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
}
shareId, err := models.GetRedisPickupData(strings.ToUpper(pickupCode))
if err != nil {
return utils.HTTPErrorHandler(c, err)
}
if shareId == "" {
return utils.HTTPErrorHandler(c, errors.New("分享不存在"))
return utils.HTTPErrorHandler(c, ErrShareNotFound)
}
return utils.HTTPSuccessHandler(c, map[string]any{
"share_id": shareId,

View File

@@ -1,11 +1,12 @@
package controllers
import (
"backend/internal/models"
"backend/internal/utils"
"encoding/json"
"pkg/models"
u "pkg/utils"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v5"
)
const (
@@ -26,7 +27,7 @@ type QueueChartData struct {
Failed int `json:"failed"`
}
func GetStat(c echo.Context) error {
func GetStat(c *echo.Context) error {
statInfoMap, err := models.GetRedisStatAll()
if err != nil {
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)
if err != nil {
return utils.HTTPErrorHandler(c, err)

View File

@@ -1,38 +1,36 @@
package controllers
import (
"backend/internal/models"
"backend/internal/controllers/task"
"backend/internal/utils"
"backend/middleware"
"encoding/json"
"errors"
"pkg/models"
u "pkg/utils"
"github.com/hibiken/asynq"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v5"
)
type GenCompressImageRequest struct {
FileId string `json:"file_id"`
var handleTaskMap = map[string]func(c *echo.Context) ([]byte, error){
"image:compress": task.HandleImageCompress,
"image:convert": task.HandleImageConvert,
}
func GenCompressImage(c echo.Context) error {
cc := c.(*middleware.CustomContext)
r := new(GenCompressImageRequest)
if err := cc.Bind(r); err != nil {
return utils.HTTPErrorHandler(c, err)
func CreateTask(c *echo.Context) error {
taskType := c.Param("type")
if taskType == "" {
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
}
if r.FileId == "" {
return utils.HTTPErrorHandler(c, errors.New("调用接口参数错误"))
handleTask, ok := handleTaskMap[taskType]
if !ok {
return utils.HTTPErrorHandler(c, ErrTaskNotFound)
}
client := utils.GetQueueClient()
json, err := json.Marshal(map[string]any{
"file_id": r.FileId,
})
json, err := handleTask(c)
if err != nil {
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 {
return utils.HTTPErrorHandler(c, err)
}
@@ -42,11 +40,10 @@ func GenCompressImage(c echo.Context) error {
})
}
func GetCompressImage(c echo.Context) error {
cc := c.(*middleware.CustomContext)
taskId := cc.Param("id")
func GetTask(c *echo.Context) error {
taskId := c.Param("id")
if taskId == "" {
return utils.HTTPErrorHandler(c, errors.New("调用接口参数错误"))
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
}
taskInfo, err := models.GetRedisTaskInfo(taskId)
@@ -54,11 +51,11 @@ func GetCompressImage(c echo.Context) error {
return utils.HTTPErrorHandler(c, err)
}
if taskInfo == nil {
client := utils.GetQueueInspector()
client := u.GetQueueInspector()
queneTaskInfo, err := client.GetTaskInfo("default", taskId)
if err != nil {
return utils.HTTPErrorHandler(c, errors.New("任务已过期"))
return utils.HTTPErrorHandler(c, ErrTaskExpired)
}
stateMap := map[asynq.TaskState]string{
asynq.TaskStateActive: "processing",

View File

@@ -0,0 +1,7 @@
package task
import "errors"
var (
ErrInvalidRequest = errors.New("InvalidRequest")
)

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

View File

@@ -1,31 +0,0 @@
package models
import (
"backend/internal/utils"
"encoding/json"
"github.com/redis/go-redis/v9"
)
func GetRedisFileShareRelational(fileId string) ([]string, error) {
rdb, ctx := utils.GetRedisClient()
fileShareRelationalUnmarshalData, err := rdb.HGet(ctx, "015:fileShareRelational", fileId).Result()
if err == redis.Nil {
return nil, nil
}
if err != nil {
return nil, err
}
var shareIDs []string
if err := json.Unmarshal([]byte(fileShareRelationalUnmarshalData), &shareIDs); err != nil {
return nil, err
}
return shareIDs, nil
}
func SetRedisFileShareRelational(fileId string, shareIDs []string) error {
rdb, ctx := utils.GetRedisClient()
jsonData, _ := json.Marshal(shareIDs)
_, err := rdb.HSet(ctx, "015:fileShareRelational", fileId, string(jsonData)).Result()
return err
}

View File

@@ -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
}

View File

@@ -1,89 +1,89 @@
package services
import (
"backend/internal/utils"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strconv"
)
func CreateFileSlice(fileSlice io.Reader, fileId string, fileIndex int64) error {
uploadPath, err := utils.GetUploadDirPath()
if err != nil {
return err
}
func CreateFileSlice(fileId string, uploadPath string, fileSlice io.Reader, fileIndex int64) (string, error) {
filePath := filepath.Join(uploadPath, fmt.Sprintf("%s_%s", fileId, "tmp"))
if err := os.MkdirAll(filePath, 0755); err != nil {
return err
return "", err
}
dst, err := os.Create(filepath.Join(filePath, fmt.Sprintf("%d", fileIndex)))
if err != nil {
return err
return "", err
}
defer dst.Close()
defer dst.Close() //nolint:errcheck
if _, err = io.Copy(dst, fileSlice); err != nil {
return err
return "", err
}
return nil
return filePath, nil
}
// MergeFileSlices 合并文件切片
func MergeFileSlices(slicesPath string, mergeFilePath string) error {
// 创建最终文件
destFile, err := os.Create(mergeFilePath)
if err != nil {
return fmt.Errorf("创建合并文件失败: %v", err)
}
defer destFile.Close()
// 读取目录下的所有文件
func GetFileSliceList(fileId string, uploadPath string) ([]int, error) {
slicesPath := filepath.Join(uploadPath, fmt.Sprintf("%s_%s", fileId, "tmp"))
files, err := os.ReadDir(slicesPath)
if err != nil {
return fmt.Errorf("读取切片目录失败: %v", err)
if err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("读取切片目录失败: %v", err)
}
// 按照索引排序文件切片
sliceFiles := make([]string, len(files))
fileSliceList := []int{}
for _, file := range files {
index, err := strconv.Atoi(file.Name())
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
for _, sliceFile := range sliceFiles {
sf, err := os.Open(sliceFile)
for _, index := range fileSliceList {
sliceFilePath := filepath.Join(slicesPath, fmt.Sprintf("%d", index))
sf, err := os.Open(sliceFilePath)
if err != nil {
return fmt.Errorf("打开切片文件失败: %v", err)
return "", fmt.Errorf("打开切片文件失败: %v", err)
}
defer sf.Close() //nolint:errcheck
for {
n, err := sf.Read(buffer)
if err == io.EOF {
break
}
if err != nil {
sf.Close()
return fmt.Errorf("读取切片文件失败: %v", err)
return "", fmt.Errorf("读取切片文件失败: %v", err)
}
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 nil
return mergeFilePath, nil
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -3,7 +3,7 @@ package utils
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v5"
)
type Option interface {
@@ -36,7 +36,7 @@ func (o WithData) applyTo(props *HTTPBaseResponse) {
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{}}
for _, option := range options {
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))
}
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))
}

View File

@@ -3,14 +3,19 @@ package utils
import (
"errors"
"fmt"
"pkg/utils"
"golang.org/x/crypto/argon2"
)
var (
ErrPasswordSaltNotSet = errors.New("PasswordSaltNotSet")
)
func GeneratePasswordHash(password string) (string, error) {
salt := GetEnv("share.password_salt")
salt := utils.GetEnv("share.password_salt")
if salt == "" {
return "", errors.New("请配置PASSWORD_SALT")
return "", ErrPasswordSaltNotSet
}
hash := argon2.IDKey([]byte(password), []byte(salt), 1, 64*1024, 4, 32)
return fmt.Sprintf("%x", hash), nil

View File

@@ -1,12 +1,10 @@
package main
import (
"backend/internal/controllers"
"backend/internal/utils"
"backend/middleware"
"fmt"
"pkg/utils"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v5"
"go.uber.org/zap"
)
@@ -18,30 +16,18 @@ func main() {
} else {
logger, _ = zap.NewDevelopment()
}
defer logger.Sync()
defer logger.Sync() //nolint:errcheck
zap.ReplaceGlobals(logger)
e := echo.New()
e.Use(middleware.ContextMiddleware())
e.Use(middleware.SessionMiddleware())
e.Use(middleware.AuthMiddleware())
e.Use(middleware.RateLimiterMiddleware())
e.Use(middleware.LoggerMiddleware())
for _, middleware := range middlewares {
e.Use(middleware())
}
e.POST("/file/create", controllers.CreateUploadTask)
e.POST("/file/slice", controllers.UploadFileSlice)
e.POST("/file/finish", controllers.FinishUploadTask)
e.GET("/share/:id", controllers.GetShareInfo)
e.POST("/share", controllers.CreateShareInfo)
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"))))
for _, route := range routes {
e.Match(route.Method, route.Path, route.Handler)
}
if err := e.Start(fmt.Sprintf(":%s", utils.GetEnvWithDefault("api.port", "5001"))); err != nil {
logger.Fatal("server failed", zap.Error(err))
}
}

14
backend/middleware.go Normal file
View 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,
}

View File

@@ -3,14 +3,14 @@ package middleware
import (
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v5"
gonanoid "github.com/matoous/go-nanoid/v2"
)
// CustomMiddleware 创建自定义中间件
func AuthMiddleware() echo.MiddlewareFunc {
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)
if err != nil {
return err
@@ -30,11 +30,8 @@ func AuthMiddleware() echo.MiddlewareFunc {
return err
}
}
cc := c.(*CustomContext)
cc.Auth = sess.Values["auth"]
// 将自定义上下文传递给下一个处理器
return next(cc)
c.Set("auth", sess.Values["auth"])
return next(c)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -1,8 +1,8 @@
package middleware
import (
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware"
"go.uber.org/zap"
)
@@ -10,7 +10,7 @@ func LoggerMiddleware() echo.MiddlewareFunc {
return middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
LogURI: 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.String("url", v.URI),
zap.Int("status", v.Status),

View File

@@ -5,9 +5,8 @@ import (
"slices"
"time"
"github.com/labstack/echo/v4"
echo_middleware "github.com/labstack/echo/v4/middleware"
"golang.org/x/time/rate"
"github.com/labstack/echo/v5"
echo_middleware "github.com/labstack/echo/v5/middleware"
)
type RateSkiper struct {
@@ -23,22 +22,22 @@ var RateSkipList = []RateSkiper{
func RateLimiterMiddleware() echo.MiddlewareFunc {
config := echo_middleware.RateLimiterConfig{
Skipper: func(e echo.Context) bool {
Skipper: func(e *echo.Context) bool {
path := e.Path()
r := e.Request()
return slices.Contains(RateSkipList, RateSkiper{Path: path, Method: r.Method})
},
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()
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)
},
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)
},
}

View File

@@ -3,7 +3,7 @@ package middleware
import (
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v5"
)
func SessionMiddleware() echo.MiddlewareFunc {

33
backend/route.go Normal file
View 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},
}

View File

@@ -6,7 +6,9 @@ import (
"net/http/httptest"
"testing"
"github.com/labstack/echo/v4"
"backend/internal/utils"
"github.com/labstack/echo/v5"
"github.com/stretchr/testify/assert"
)
@@ -17,7 +19,7 @@ func TestHTTPSuccessHandler(t *testing.T) {
c := e.NewContext(req, rec)
data := map[string]interface{}{"result": "success"}
err := HTTPSuccessHandler(c, data)
err := utils.HTTPSuccessHandler(c, data)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
@@ -40,7 +42,7 @@ func TestHTTPErrorHandler(t *testing.T) {
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
err := HTTPErrorHandler(c, assert.AnError)
err := utils.HTTPErrorHandler(c, assert.AnError)
assert.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)

View File

@@ -1,30 +1,31 @@
package utils
import (
"os"
"bytes"
"fmt"
"testing"
"backend/internal/utils"
u "pkg/utils"
"github.com/stretchr/testify/assert"
)
func TestGeneratePasswordHash(t *testing.T) {
// 保存原始环境变量
originalSalt := os.Getenv("share.password_salt")
defer os.Setenv("share.password_salt", originalSalt)
tests := []struct {
name string
password string
salt string
expectError bool
errorMsg string
err error
}{
{
name: "share.password_salt未配置",
password: "testpassword",
salt: "",
expectError: true,
errorMsg: "请配置share.password_salt",
err: utils.ErrPasswordSaltNotSet,
},
{
name: "正常生成哈希",
@@ -37,21 +38,23 @@ func TestGeneratePasswordHash(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 设置环境变量
if tt.salt != "" {
os.Setenv("share.password_salt", tt.salt)
} else {
os.Unsetenv("share.password_salt")
}
u.InitEnv(u.EnvOption{
ConfigData: bytes.NewBuffer([]byte(fmt.Sprintf(`
share:
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 err == nil {
t.Errorf("期望错误,但得到了 nil")
return
}
if err.Error() != tt.errorMsg {
t.Errorf("期望错误信息 '%s',但得到了 '%s'", tt.errorMsg, err.Error())
if err != tt.err {
t.Errorf("期望错误信息 '%s',但得到了 '%s'", tt.err.Error(), err.Error())
}
return
}

View File

@@ -52,14 +52,14 @@ const genUserAvatar = (email: string) => {
<div class="flex flex-col gap-2 items-center">
<div class="text-xl">{{ renderI18n(appConfig?.site_title ?? {}, 'en', locale) }}</div>
<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>
</I18nT>
</div>
</div>
</template>
<div class="font-semibold">{{ t('about.systemInfo') }}</div>
<div class="font-semibold">{{ t('page.about.systemInfo') }}</div>
<template v-if="isLoading">
<div class="flex flex-row gap-2">
<Skeleton class="w-full h-20 rounded-xl" v-for="i in 2" :key="i" />
@@ -68,7 +68,7 @@ const genUserAvatar = (email: string) => {
<template v-else>
<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="opacity-75 text-xs">{{ t('about.admin') }}</div>
<div class="opacity-75 text-xs">{{ t('page.about.admin') }}</div>
<div
class="flex flex-row gap-2 items-center cursor-pointer"
@click="
@@ -98,7 +98,7 @@ const genUserAvatar = (email: string) => {
</div>
</div>
<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">
<span class="text-lg font-semibold">{{ getFileSize(data?.file?.current ?? 0) }}</span>
<span class="text-md opacity-75">/ {{ getFileSize(data?.file?.maximun ?? 0) }}</span>
@@ -115,7 +115,7 @@ const genUserAvatar = (email: string) => {
<Accordion type="single" collapsible>
<AccordionItem value="about">
<AccordionTrigger>
<span class="font-semibold">{{ t('about.about') }}</span>
<span class="font-semibold">{{ t('page.about.about') }}</span>
</AccordionTrigger>
<AccordionContent>
<MarkdownRender class="max-w-full" :markdown="renderI18n(data?.content ?? {}, 'en', locale) ?? ''" />

View File

@@ -51,28 +51,28 @@ const { t } = useI18n()
const chartTabs = computed(() => {
return [
{
label: t('about.file'),
label: t('page.about.file'),
value: 'storage',
total: data.value?.chart?.storage
? Object.values(data.value.chart.storage).reduce((acc: number, curr: StatChartData) => acc + curr.file_num, 0)
: 0,
},
{
label: t('about.share'),
label: t('page.about.share'),
value: 'share',
total: data.value?.chart?.storage
? Object.values(data.value.chart.storage).reduce((acc: number, curr: StatChartData) => acc + curr.share_num, 0)
: 0,
},
{
label: t('about.download'),
label: t('page.about.download'),
value: 'download',
total: data.value?.chart?.storage
? Object.values(data.value.chart.storage).reduce((acc: number, curr: StatChartData) => acc + curr.download_num, 0)
: 0,
},
{
label: t('about.task'),
label: t('page.about.task'),
value: 'queue',
total: data.value?.chart?.queue
? Object.values(data.value.chart.queue).reduce((acc: number, curr: QueueChartData) => acc + curr.processed + curr.failed, 0)
@@ -144,7 +144,7 @@ const currentChartData = computed((): ChartConfig => {
</script>
<template>
<div class="font-semibold">{{ t('about.analysis') }}</div>
<div class="font-semibold">{{ t('page.about.analysis') }}</div>
<template v-if="isLoading">
<div class="flex flex-row gap-2">
<Skeleton class="w-full h-96 rounded-xl" />

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

View File

@@ -1,19 +1,21 @@
<script lang="ts" setup>
import { VisSingleContainer, VisDonut } from '@unovis/vue'
import { withDefaults, defineProps } from 'vue'
const props = withDefaults(defineProps<{
value?: number
size?: number
color?: string
}>(), {
value: 0,
size: 40,
color: '#06b6d4'
})
const props = withDefaults(
defineProps<{
value?: number
size?: number
color?: string
}>(),
{
value: 0,
size: 40,
color: '#06b6d4',
}
)
const data = computed(() => {
const progress = Math.min(Math.max(props.value, 0), 100)
return [progress, 100 - progress ]
return [progress, 100 - progress]
})
const getValue = (d: number) => d
const getColor = (d: number, i: number) => [props?.color, 'transparent'][i]

View File

@@ -15,6 +15,7 @@ import { cx } from 'class-variance-authority'
import { isObject } from 'lodash-es'
import showDrawer from '@/lib/showDrawer'
import FileShareHandle from '@/components/Preprocessing/FileShareHandle.vue'
import ImageConvertHandle from '@/components/Preprocessing/ImageConvertHandle.vue'
import type { FileShareHandleProps } from '../Preprocessing/types'
const { t } = useI18n()
const props = defineProps<{
@@ -35,7 +36,7 @@ const isPPT = computed(() => props.file.every((r) => r?.type?.startsWith('applic
const isDocument = computed(() => isPDF.value || isDOC.value || isXLS.value || isPPT.value)
const actions = [
{
label: t('file.handleType.file-share'),
label: t('page.upload.file.handleType.file-share'),
icon: LucideShare,
className: 'bg-green-300',
onClick: () => {
@@ -45,13 +46,23 @@ const actions = [
},
},
isImage.value && {
label: t('file.handleType.file-image-compress'),
label: t('page.upload.file.handleType.file-image-compress'),
icon: LucideImageMinus,
className: 'bg-red-300',
onClick: () => {
props.onFileHandle({ type: 'file-image-compress', config: {} })
},
},
isImage.value && {
label: t('page.upload.file.handleType.file-image-convert'),
icon: LucideArrowRightLeft,
className: 'bg-purple-300',
onClick: () => {
showDrawer({
render: ({ hide }) => h(ImageConvertHandle, { ...props, hide }),
})
},
},
// isImage.value && {
// label: '图片翻译', icon: LucideLanguages, className: 'bg-orange-300', onClick: () => {
// console.log('复制链接')

View File

@@ -18,7 +18,7 @@ const handleSubmit = async (form: FormContext<GenericObject, GenericObject>) =>
}
}>(`/api/share/pickup/${code}`)
if (!data.data.share_id) {
toast.error(t('pickup.codeError'))
toast.error(t('page.upload.pickup.codeError'))
form.resetForm()
return
}
@@ -29,7 +29,7 @@ const handleSubmit = async (form: FormContext<GenericObject, GenericObject>) =>
})
return
} catch (error) {
toast.error(t('pickup.codeError'))
toast.error(t('page.upload.pickup.codeError'))
form.resetForm()
}
}
@@ -38,7 +38,7 @@ const handleSubmit = async (form: FormContext<GenericObject, GenericObject>) =>
<template>
<VeeForm>
<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
name="code"
:rules="

View File

@@ -1,74 +1,62 @@
<script setup lang="ts">
import {
LucideShare,
LucideImage,
LucideBot,
LucideLanguages,
} from "lucide-vue-next";
import { cx } from "class-variance-authority";
import showDrawer from "@/lib/showDrawer";
import TextShareHandle from "@/components/Preprocessing/TextShareHandle.vue";
import { LucideShare, LucideImage, 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<{
hide: () => void;
text: string;
onTextHandle: ({ type, config }: { type: string; config: any }) => void;
}>();
const { t } = useI18n();
hide: () => void
text: string
onTextHandle: ({ type, config }: { type: string; config: any }) => void
}>()
const { t } = useI18n()
const actions = [
{
label: t("text.handleType.text-share"),
icon: LucideShare,
className: "bg-green-300",
onClick: () => {
showDrawer({
render: ({ hide }) => h(TextShareHandle, { ...props, hide }),
});
{
label: t('page.upload.text.handleType.text-share'),
icon: LucideShare,
className: 'bg-green-300',
onClick: () => {
showDrawer({
render: ({ hide }) => h(TextShareHandle, { ...props, hide }),
})
},
},
},
// {
// label: '生成配图', icon: LucideImage, className: 'bg-red-300', onClick: () => {
// console.log('复制链接')
// }
// },
// {
// label: '问大模型', icon: LucideBot, className: 'bg-blue-300', onClick: () => {
// console.log('复制链接')
// }
// },
// {
// label: '文本翻译', icon: LucideLanguages, className: 'bg-orange-300', onClick: () => {
// console.log('复制链接')
// }
// },
];
// {
// label: '生成配图', icon: LucideImage, className: 'bg-red-300', onClick: () => {
// console.log('复制链接')
// }
// },
// {
// label: '问大模型', icon: LucideBot, className: 'bg-blue-300', onClick: () => {
// console.log('复制链接')
// }
// },
// {
// label: '文本翻译', icon: LucideLanguages, className: 'bg-orange-300', onClick: () => {
// console.log('复制链接')
// }
// },
]
</script>
<template>
<div class="flex flex-col gap-5 p-5">
<div class="flex flex-row gap-2">
<div
v-for="item in actions"
:key="item.label"
class="flex flex-col items-center gap-2 max-w-20"
@click="
() => {
props?.hide();
item?.onClick();
}
"
>
<div
:class="
cx(
'size-14 flex justify-center items-center rounded-full mx-3',
item?.className,
)
"
>
<component :is="item?.icon" />
<div class="flex flex-col gap-5 p-5">
<div class="flex flex-row gap-2">
<div
v-for="item in actions"
:key="item.label"
class="flex flex-col items-center gap-2 max-w-20"
@click="
() => {
props?.hide()
item?.onClick()
}
"
>
<div :class="cx('size-14 flex justify-center items-center rounded-full mx-3', item?.className)">
<component :is="item?.icon" />
</div>
<div class="text-xs truncate w-full text-center">{{ item?.label }}</div>
</div>
</div>
<div class="text-xs truncate w-full text-center">{{ item?.label }}</div>
</div>
</div>
</div>
</template>

View File

@@ -56,14 +56,14 @@ const { t } = useI18n()
<div class="size-16 flex justify-center items-center rounded-xl bg-white/80">
<PlusIcon class="size-7" />
</div>
<div class="mb-3">{{ t('file.addMore') }}</div>
<div class="mb-3">{{ t('page.upload.file.addMore') }}</div>
</div>
</div>
</template>
<template v-else>
<LucideUpload class="size-10" />
<div class="text-sm select-none">
{{ t('file.uploadFilePlaceholder') }}
{{ t('page.upload.file.uploadFilePlaceholder') }}
</div>
</template>
</div>

View File

@@ -32,7 +32,7 @@ const fileIcon = computed(() => {
if (baseType === 'audio') {
return LucideFileAudio
}
if (baseType === 'text' || ['json', 'ld+json', 'html']?.includes(type)) {
if (baseType === 'text' || ['json', 'ld+json', 'html']?.includes(type ?? '')) {
return LucideFileCode
}
if (
@@ -44,11 +44,11 @@ const fileIcon = computed(() => {
'vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'vnd.ms-powerpoint',
'vnd.openxmlformats-officedocument.presentationml.presentation',
].includes(type)
].includes(type ?? '')
) {
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 LucideFile

View File

@@ -12,3 +12,5 @@ watchEffect(() => {
dayjs.locale(locale.value.toLowerCase())
})
</script>
<template></template>

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import VeeForm from '@/components/VeeForm.vue'
import FileUploadInputFileView from './FileUploadInputFileView.vue'
import FileUploadProgressView from './FileUploadProgressView.vue'
import FileUploadProgressView from './FileUploadProgressView/index.vue'
import ResultIndexView from '@/components/Result/ResultIndexView.vue'
const fileStepList = [
@@ -16,19 +16,26 @@ const renderComponent = computed(() => {
return fileStepList.find((item) => item.key === step.value)?.component
})
const formRef = ref<InstanceType<typeof VeeForm>>()
watch(() => step.value, (newVal) => {
if (newVal === 'input') {
formRef.value?.form?.resetForm()
formRef.value?.form?.setValues({ file: null })
watch(
() => step.value,
(newVal) => {
if (newVal === 'input') {
formRef.value?.form?.resetForm()
formRef.value?.form?.setValues({ file: null })
}
}
})
)
</script>
<template>
<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 :is="renderComponent" :data="values" @change="(key: string) => {
step = key
}" />
</div>
<component
:is="renderComponent"
:data="values"
@change="
(key: string) => {
step = key
}
"
/>
</VeeForm>
</template>
</template>

View File

@@ -1,42 +1,39 @@
<script lang="ts" setup>
import showDrawer from "@/lib/showDrawer";
import FileShareDrawer from "@/components/Drawer/FileShareDrawer.vue";
import FileUploadField from "@/components/Field/FileUploadField.vue";
import FormButton from "@/components/Field/FormButton.vue";
import PickupShareBtn from "@/components/PickupShareBtn.vue";
import showDrawer from '@/lib/showDrawer'
import FileShareDrawer from '@/components/Drawer/FileShareDrawer.vue'
import FileUploadField from '@/components/Field/FileUploadField.vue'
import FormButton from '@/components/Field/FormButton.vue'
import PickupShareBtn from '@/components/PickupShareBtn.vue'
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 { file } = form?.values || {};
showDrawer({
render: ({ hide }) =>
h(FileShareDrawer, {
hide,
file,
onFileHandle: ({ type, config }) => {
form.setFieldValue("handle_type", type);
form.setFieldValue("config", config);
emit("change", "progress");
},
}),
});
};
const { file } = form?.values || {}
showDrawer({
render: ({ hide }) =>
h(FileShareDrawer, {
hide,
file,
onFileHandle: ({ type, config }) => {
form.setFieldValue('handle_type', type)
form.setFieldValue('config', config)
emit('change', 'progress')
},
}),
})
}
</script>
<template>
<div class="gap-5 flex flex-col">
<div class="text-xl font-normal">{{ t("file.uploadFile") }}</div>
<FileUploadField name="file" rules="required" />
<div class="flex flex-row gap-3">
<FormButton @click="handleFormSubmit">
<LucideShare class="size-4" />{{ t("btn.submit") }}
</FormButton>
<PickupShareBtn />
</div>
</div>
<BaseCard class="gap-5 flex flex-col" :title="t('page.upload.file.uploadFile')">
<FileUploadField name="file" rules="required" />
<div class="flex flex-row gap-3">
<FormButton @click="handleFormSubmit"> <LucideShare class="size-4" />{{ t('btn.submit') }} </FormButton>
<PickupShareBtn />
</div>
</BaseCard>
</template>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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="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>

View File

@@ -1,21 +1,25 @@
<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 FileUploadBlockProgressView from '@/components/FileUploadBlockProgressView.vue'
import { motion } from 'motion-v'
import getFileSize from '~/lib/getFileSize'
import { cx } from 'class-variance-authority'
import asyncWorker from '@/lib/asyncWorker'
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 { toast } from 'vue-sonner'
import dayjs from 'dayjs'
import showDrawer from '~/lib/showDrawer'
import FileUploadTotalSpeedView from './FileUploadTotalSpeedView.vue'
import FileUploadTotalProgressControlView from './FileUploadTotalProgressControlView.vue'
import FileUploadSpeedInfoView from './FileUploadSpeedInfoView.vue'
import getFileChunk from '~/lib/getFileChunk'
import type { FileHandleKey } from '~/components/Preprocessing/types'
import asyncWait from '@/lib/asyncWait'
import axios from 'axios'
import type { SelectedFile, Uploadfile } from './types'
import FileUploadDetailView from './FileUploadDetailView.vue'
const props = defineProps<{
data: { file: File[]; config: Record<string, any>; handle_type: FileHandleKey }
@@ -24,81 +28,17 @@ const emit = defineEmits<{
(e: 'change', key: string): void
}>()
const form = useFormContext()
const { t } = useI18n()
const selectedFile = ref()
const uploadfiles = ref<
{
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 selectedFile = ref<SelectedFile>(null)
const uploadfiles = ref<Uploadfile[]>([])
const procressTaskList = ref<Map<string, any>>(new Map())
const activeTaskList = computed(() => uploadfiles.value.filter((r) => r.queue.length > 0 && r.status === 'start'))
const activeTaskAllQueue = computed(() => activeTaskList.value.flatMap((r) => r.queue))
const batchNum = ref(3)
const totalTaskStatus = computed(() => {
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')
})
const speedChartData = ref<Record<number, { fileId: string; index: number; value: number }[]>>({})
onMounted(() => {
props.data.file.forEach((file) => {
@@ -136,10 +76,10 @@ const { error, execute, isLoading } = useAsyncState(
if (queueType === 'async') {
if (!!retry && retry >= 3) {
toast.error('上传错误', {
description: `文件 ${file?.file?.name}${index}分块经过多次尝试依然上传失败, 我们已经终止该文件上传`,
toast.error(t('page.progress.file.uploadError'), {
description: t('page.progress.file.chunkUploadFailed', [file?.file?.name, index]),
})
uploadfiles.value[uploadFileIndex].status = 'error'
uploadfiles.value[uploadFileIndex]!.status = 'error'
return
}
uploadfiles.value[uploadFileIndex]?.queue.shift()
@@ -170,14 +110,14 @@ const { error, execute, isLoading } = useAsyncState(
// todo
if (queueType === 'async') {
uploadfiles.value[uploadFileIndex]?.queue.push({ ...task, retry: (task?.retry || 0) + 1 })
toast.warning('上传错误', {
description: `文件 ${file?.file?.name}${index}分块上传失败, 我们将在稍后再次尝试上传`,
toast.warning(t('page.progress.file.uploadError'), {
description: t('page.progress.file.chunkUploadRetry', [file?.file?.name, index]),
})
}
if (queueType === 'sync') {
uploadfiles.value[uploadFileIndex].status = 'error'
toast.error('上传错误', {
description: `文件${file?.file?.name}上传失败, 请重试`,
uploadfiles.value[uploadFileIndex]!.status = 'error'
toast.error(t('page.progress.file.uploadError'), {
description: t('page.progress.file.fileUploadFailed', [file?.file?.name]),
})
}
} finally {
@@ -218,6 +158,7 @@ const handleCreate = async (fileId: string) => {
id: string
type: 'init' | 'already'
chunk_size: number
chunks: number[]
}
}>('/api/file/create', {
method: 'POST',
@@ -227,10 +168,10 @@ const handleCreate = async (fileId: string) => {
hash,
},
})
const { id, chunk_size, type: createType } = createData?.data || {}
const { id, chunk_size, type: createType, chunks = [] } = createData?.data || {}
uploadfile.id = id
uploadfile.uploadInfo = {
chunks: {},
chunks: Object.fromEntries(chunks.map((index: number) => [index - 1, { status: 'success', createdAt: dayjs().unix() }])),
chunkLength: Math.ceil(size / chunk_size),
ChunkSize: chunk_size,
}
@@ -277,17 +218,18 @@ const handleUpload = async (fileId: string, index: number) => {
formData.append('file', new Blob([chunk]))
formData.append('index', (index + 1).toString())
formData.append('id', id)
const res = await $fetch<{
code: number
}>('/api/file/slice', {
method: 'POST',
body: formData,
const { data: res } = await axios.post('/api/file/slice', formData, {
onUploadProgress: throttle((progressEvent) => {
const { rate } = progressEvent || {}
const timestamp = dayjs().unix()
speedChartData.value[timestamp] = [...(speedChartData.value[timestamp] || []), { fileId, index, value: rate || 0 }]
}, 1000),
})
const { code } = res || {}
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) {
uploadfile.queue.push({ taskType: 'finish', queueType: 'sync', taskId: nanoid() })
}
@@ -307,7 +249,7 @@ const handleFinish = async (fileId: string) => {
},
})
if (res?.code !== 200) {
throw new Error('上传失败')
throw new Error(t('page.progress.file.uploadFailed'))
}
uploadfile.status = 'finish'
}
@@ -341,102 +283,29 @@ const handleShowSpeedInfo = () => {
</script>
<template>
<div 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">
<div class="flex flex-col gap-1">
<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>
<BaseCard class="grid grid-cols-4 gap-5">
<FileUploadTotalSpeedView :speedChartData="speedChartData" />
<FileUploadTotalProgressControlView :uploadfiles="uploadfiles" />
<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="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>文件大小</div>
<div>{{ t('page.progress.file.fileName') }}</div>
<div>{{ t('page.progress.file.fileSize') }}</div>
<div @click="handleShowSpeedInfo" class="flex flex-row gap-1 items-center">
上传速度
{{ t('page.progress.file.uploadSpeed') }}
<LucideInfo class="size-3" />
</div>
<div class="hidden md:block">进度</div>
<div class="hidden md:block">{{ t('page.progress.file.progress') }}</div>
</div>
<div
:class="
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',
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"
@@ -459,9 +328,9 @@ const handleShowSpeedInfo = () => {
(e: Event) => {
e.stopPropagation()
if (item?.status === 'start') {
uploadfiles[index].status = 'pause'
uploadfiles[index]!.status = 'pause'
} else {
uploadfiles[index].status = 'start'
uploadfiles[index]!.status = 'start'
}
}
"
@@ -475,26 +344,26 @@ const handleShowSpeedInfo = () => {
</div>
<div>{{ getFileSize(item?.file?.size) }}</div>
<div>
{{
`${getFileSize(
(Object.entries(item?.uploadInfo?.chunks || {})?.filter(
([, chunk]) => chunk.status === 'success' && dayjs().unix() - 60 < chunk.createdAt
)?.length /
60) *
(item?.uploadInfo?.ChunkSize || 0)
)} /s`
}}
<template v-if="item?.status === 'start' && item?.procressType === 'upload'">
{{
`${getFileSize(
Object.entries(speedChartData)
?.filter(([key, value]) => value.some((r) => r.fileId === item?.fileId) && dayjs().unix() - 1 === Number(key))
?.reduce((acc, curr) => acc + curr[1]?.reduce((acc, curr) => acc + curr.value, 0), 0) ?? 0
)} /s`
}}
</template>
</div>
<div
class="flex flex-row gap-2 items-center col-span-3 md:col-span-1"
v-if="['hash', 'create', 'chunk']?.includes(item?.procressType)"
>
<LucideLoaderCircle class="size-4 animate-spin" />
<div>正在{{ item?.procressType }}...</div>
<div>{{ t(`page.progress.file.processing.${item?.procressType}`) }}</div>
</div>
<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 === 'error' ? '上传失败,请稍后重试' : null }}
{{ item?.status === 'finish' ? t('page.progress.file.instantUploadSuccess') : null }}
{{ item?.status === 'error' ? t('page.progress.file.uploadFailedRetry') : null }}
</div>
<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">
@@ -526,44 +395,6 @@ const handleShowSpeedInfo = () => {
</div>
</div>
</div>
<div class="col-span-4 flex flex-col bg-white/80 rounded-xl p-3 gap-5" v-if="selectedFile">
<div>上传详情</div>
<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>
<FileUploadDetailView :uploadfiles="uploadfiles" :selectedFile="selectedFile" />
</BaseCard>
</template>

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

View File

@@ -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>

View File

@@ -14,19 +14,26 @@ const renderComponent = computed(() => {
return textStepList.find((item) => item.key === step.value)?.component
})
const formRef = ref<InstanceType<typeof VeeForm>>()
watch(() => step.value, (newVal) => {
if (newVal === 'input') {
formRef.value?.form?.resetForm()
// formRef.value?.form?.setValues({ file: null })
watch(
() => step.value,
(newVal) => {
if (newVal === 'input') {
formRef.value?.form?.resetForm()
// formRef.value?.form?.setValues({ file: null })
}
}
})
)
</script>
<template>
<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 :is="renderComponent" :data="values" @change="(key: string) => {
step = key
}" />
</div>
<component
:is="renderComponent"
:data="values"
@change="
(key: string) => {
step = key
}
"
/>
</VeeForm>
</template>
</template>

View File

@@ -21,13 +21,12 @@ const handleTextShare = ({ type, config }: { type: string; config: any }) => {
}
</script>
<template>
<div class="gap-5 flex flex-col">
<div class="text-xl font-normal">{{ t('text.uploadText') }}</div>
<BaseCard class="gap-5 flex flex-col" :title="t('page.upload.text.uploadText')">
<div class="relative">
<MarkdownInputField
name="text"
:placeholder="t('text.uploadTextPlaceholder')"
class="max-h-[50vh] min-h-40 overflow-y-auto max-w-full [&>*]:pr-10 flex flex-col"
:placeholder="t('page.upload.text.uploadTextPlaceholder')"
class="max-h-[50vh] min-h-40 overflow-y-auto max-w-full *:pr-10 flex flex-col"
rules="required"
/>
<Button
@@ -68,5 +67,5 @@ const handleTextShare = ({ type, config }: { type: string; config: any }) => {
</FormButton>
<PickupShareBtn />
</div>
</div>
</BaseCard>
</template>

View File

@@ -16,6 +16,6 @@ const { t } = useI18n()
"
>
<LucideArchive class="size-4" />
{{ t('pickup.btn') }}
{{ t('page.upload.pickup.btn') }}
</AsyncButton>
</template>

View File

@@ -15,37 +15,37 @@ const props = defineProps<{
<template>
<VeeForm v-slot="{ values, setFieldValue }" :initialValues="{ download_nums: 1, expire_time: 1440 }">
<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">
<SelectField
name="download_nums"
:label="t('fileshare.downloadNums')"
:label="t('page.shareOptions.file.downloadNums')"
:options="[
{ label: t('fileshare.downloadOptions.1time'), value: 1 },
{ label: t('fileshare.downloadOptions.2times'), value: 2 },
{ label: t('fileshare.downloadOptions.3times'), value: 3 },
{ label: t('fileshare.downloadOptions.5times'), value: 5 },
{ label: t('fileshare.downloadOptions.10times'), value: 10 },
{ label: t('page.shareOptions.file.downloadOptions.xdownload', [1]), value: 1 },
{ label: t('page.shareOptions.file.downloadOptions.xdownload', [2]), value: 2 },
{ label: t('page.shareOptions.file.downloadOptions.xdownload', [3]), value: 3 },
{ label: t('page.shareOptions.file.downloadOptions.xdownload', [5]), value: 5 },
{ label: t('page.shareOptions.file.downloadOptions.xdownload', [10]), value: 10 },
]"
/>
{{ t('fileshare.or') }}
{{ t('page.shareOptions.file.or') }}
<SelectField
name="expire_time"
:label="t('fileshare.expireTime')"
:label="t('page.shareOptions.file.expireTime')"
:options="[
{ label: t('fileshare.expireOptions.5min'), value: 5 },
{ label: t('fileshare.expireOptions.1hour'), value: 60 },
{ label: t('fileshare.expireOptions.1day'), value: 1440 },
{ label: t('fileshare.expireOptions.3days'), value: 4320 },
{ label: t('page.shareOptions.file.expireOptions.5min'), value: 5 },
{ label: t('page.shareOptions.file.expireOptions.1hour'), value: 60 },
{ label: t('page.shareOptions.file.expireOptions.1day'), value: 1440 },
{ label: t('page.shareOptions.file.expireOptions.3days'), value: 4320 },
]"
/>
{{ t('fileshare.expireAfter') }}
{{ t('page.shareOptions.file.expireAfter') }}
</div>
<div class="flex flex-col gap-1">
<div class="flex flex-row gap-3 min-h-9">
<SwitchField
name="has_pickup_code"
:label="t('fileshare.pickupCode')"
:label="t('page.shareOptions.file.pickupCode')"
:rules="
(value: boolean) => {
if (!!value) {
@@ -59,7 +59,7 @@ const props = defineProps<{
<div class="flex flex-row gap-3 min-h-9">
<SwitchField
name="has_password"
:label="t('fileshare.passwordProtection')"
:label="t('page.shareOptions.file.passwordProtection')"
:rules="
(value: boolean) => {
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 class="flex flex-row gap-3 min-h-9">
<SwitchField name="has_notify" :label="t('fileshare.downloadNotify')" />
<InputField v-if="!!values.has_notify" name="notify_email" :placeholder="t('fileshare.emailPlaceholder')" rules="required" />
<SwitchField name="has_notify" :label="t('page.shareOptions.file.downloadNotify')" />
<InputField
v-if="!!values.has_notify"
name="notify_email"
:placeholder="t('page.shareOptions.file.emailPlaceholder')"
rules="required"
/>
</div>
</div>
<FormButton

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

View File

@@ -15,37 +15,37 @@ const props = defineProps<{
<template>
<VeeForm v-slot="{ values, setFieldValue }" :initialValues="{ download_nums: 1, expire_time: 1440 }">
<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">
<SelectField
name="download_nums"
:label="t('textshare.viewNums')"
:label="t('page.shareOptions.text.viewNums')"
:options="[
{ label: t('textshare.viewOptions.1time'), value: 1 },
{ label: t('textshare.viewOptions.2times'), value: 2 },
{ label: t('textshare.viewOptions.3times'), value: 3 },
{ label: t('textshare.viewOptions.5times'), value: 5 },
{ label: t('textshare.viewOptions.10times'), value: 10 },
{ label: t('page.shareOptions.text.viewOptions.xview', [1]), value: 1 },
{ label: t('page.shareOptions.text.viewOptions.xview', [2]), value: 2 },
{ label: t('page.shareOptions.text.viewOptions.xview', [3]), value: 3 },
{ label: t('page.shareOptions.text.viewOptions.xview', [5]), value: 5 },
{ label: t('page.shareOptions.text.viewOptions.xview', [10]), value: 10 },
]"
/>
{{ t('textshare.or') }}
{{ t('page.shareOptions.text.or') }}
<SelectField
name="expire_time"
:label="t('textshare.expireTime')"
:label="t('page.shareOptions.text.expireTime')"
:options="[
{ label: t('textshare.expireOptions.5min'), value: 5 },
{ label: t('textshare.expireOptions.1hour'), value: 60 },
{ label: t('textshare.expireOptions.1day'), value: 1440 },
{ label: t('textshare.expireOptions.3days'), value: 4320 },
{ label: t('page.shareOptions.text.expireOptions.5min'), value: 5 },
{ label: t('page.shareOptions.text.expireOptions.1hour'), value: 60 },
{ label: t('page.shareOptions.text.expireOptions.1day'), value: 1440 },
{ label: t('page.shareOptions.text.expireOptions.3days'), value: 4320 },
]"
/>
{{ t('textshare.expireAfter') }}
{{ t('page.shareOptions.text.expireAfter') }}
</div>
<div class="flex flex-col gap-1">
<div class="flex flex-row gap-3 min-h-9">
<SwitchField
name="has_pickup_code"
:label="t('textshare.pickupCode')"
:label="t('page.shareOptions.text.pickupCode')"
:rules="
(value: boolean) => {
if (!!value) {
@@ -59,7 +59,7 @@ const props = defineProps<{
<div class="flex flex-row gap-3 min-h-9">
<SwitchField
name="has_password"
:label="t('textshare.passwordProtection')"
:label="t('page.shareOptions.text.passwordProtection')"
:rules="
(value: boolean) => {
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 class="flex flex-row gap-3 min-h-9">
<SwitchField name="has_notify" :label="t('textshare.readNotify')" />
<InputField v-if="!!values.has_notify" name="notify_email" :placeholder="t('textshare.emailPlaceholder')" rules="required" />
<SwitchField name="has_notify" :label="t('page.shareOptions.text.readNotify')" />
<InputField
v-if="!!values.has_notify"
name="notify_email"
:placeholder="t('page.shareOptions.text.emailPlaceholder')"
rules="required"
/>
</div>
</div>
<FormButton

View File

@@ -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 TextHandleKey = 'text-share'

View File

@@ -12,11 +12,9 @@ import showDrawer from '@/lib/showDrawer'
import QrCoreDrawer from '@/components/Drawer/QrCoreDrawer.vue'
import { h } from 'vue'
import { cx } from 'class-variance-authority'
import type { FileHandleKey } from '../Preprocessing/types'
import type { handleFileComponentProps } from './types'
const props = defineProps<{
data: { files: { id: string; file: File }[]; config: Record<string, any>; handle_type: FileHandleKey }
}>()
const props = defineProps<handleFileComponentProps>()
const emit = defineEmits<{
(e: 'change', key: string): void
}>()
@@ -59,14 +57,13 @@ const { copy } = useClipboard()
</script>
<template>
<div class="flex flex-col gap-3">
<h2 class="text-lg">{{ t('fileshareresult.title') }}</h2>
<BaseCard class="flex flex-col gap-3" :title="t('page.result.file.title')" :showBackButton="true">
<div class="flex flex-col gap-3 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" />
</div>
<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
v-for="file in data"
:class="
@@ -92,7 +89,7 @@ const { copy } = useClipboard()
@click="
() => {
copy(getShareUrl(file?.id as string))
toast.success(t('fileshareresult.copySuccess'))
toast.success(t('page.result.file.copySuccess'))
}
"
>
@@ -121,21 +118,21 @@ const { copy } = useClipboard()
</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 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="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>
<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">
{{ dayjs((selectedFileShare?.expire_at || 0) * 1000).format('YYYY-MM-DD HH:mm:ss') }}
</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="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
variant="outline"
class="bg-white/70 p-0 size-6"
@@ -143,7 +140,7 @@ const { copy } = useClipboard()
@click="
() => {
copy(selectedFileShare?.pickup_code as string)
toast.success(t('fileshareresult.copySuccess'))
toast.success(t('page.result.file.copySuccess'))
}
"
>
@@ -159,7 +156,7 @@ const { copy } = useClipboard()
</div>
</div>
<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">
<Input :model-value="getShareUrl(selectedFileShare?.id as string)" class="bg-white/70" readonly />
<Button
@@ -169,7 +166,7 @@ const { copy } = useClipboard()
@click="
() => {
copy(getShareUrl(selectedFileShare?.id as string))
toast.success(t('fileshareresult.copySuccess'))
toast.success(t('page.result.file.copySuccess'))
}
"
>
@@ -197,16 +194,6 @@ const { copy } = useClipboard()
</div>
</div>
</div>
<Button
class="w-40 hover:bg-primary/90"
@click="
() => {
emit('change', 'input')
}
"
>
{{ t('btn.backToHome') }}
</Button>
</div>
</div>
</BaseCard>
</template>

View File

@@ -1,66 +1,92 @@
<script setup lang="ts">
import { useQuery } from '@tanstack/vue-query'
import { AsyncButton, Button } from '@/components/ui/button'
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'
import { get } from 'lodash-es'
const { t } = useI18n()
const emit = defineEmits<{
(e: 'change', key: string): void
}>()
const props = defineProps<{
data: { file: File; config: any; handle_type: string; file_id: string }
}>()
const { data } = useQuery({
queryKey: ['create-image-compress', props?.data?.file_id],
const props = defineProps<handleFileComponentProps>()
const fileIds = computed(() => props?.data?.files?.map((item) => item.id))
const { data: taskIds } = useQuery({
queryKey: ['create-image-compress', fileIds.value],
queryFn: async () => {
const { file_id } = props?.data || {}
const data = await $fetch<{
code: number
data: {
id?: string
}
}>(`/api/image/compress`, {
method: 'POST',
body: {
file_id,
},
})
return data?.data
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:compress`, {
method: 'POST',
body: {
file_id: id,
},
})
return data?.data?.id
})
)
},
staleTime: Infinity,
enabled: !!fileIds.value && fileIds.value?.length > 0,
})
const taskId = computed(() => data?.value?.id)
const { data: taskData, refetch } = useQuery({
queryKey: ['image-compress-task', 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
const taskResults = useQueries({
queries: computed(
() =>
taskIds?.value?.filter(Boolean).map((taskId) => {
return {
queryKey: ['task-image-compress', 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 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
},
enabled: !!taskId.value,
},
{ oldSize: 0, newSize: 0 }
)
})
const { downloadFileByShareId, createFileShare } = useMyAppShare()
@@ -70,66 +96,98 @@ const { counter, pause } = useInterval(2000, { controls: true })
watch(
() => counter.value,
() => {
if (['success', 'archived'].includes(taskData.value?.status ?? '')) {
pause()
return
}
refetch()
}
)
taskResults.value.forEach((item) => {
if (['success', 'archived'].includes(item.data?.status ?? '')) return
item.refetch()
})
watch(
() => taskData.value?.err?.retry,
(newVal, oldVal) => {
if (!oldVal || !newVal || !taskData.value?.err?.max_retry) {
return
}
if (newVal <= taskData.value?.err?.max_retry) {
toast.error(`处理错误: ${taskData.value?.err?.message}, 将再次重试`)
if (taskResults.value.every((item) => ['success', 'archived'].includes(item.data?.status ?? ''))) {
pause()
}
}
)
</script>
<template>
<div class="flex flex-col gap-3">
<h2 class="text-lg">上传成功</h2>
<div class="flex flex-col gap-1 items-center">
<div class="flex flex-col h-30 items-center justify-center">
<FilePreviewView :value="props?.data?.file" />
</div>
</div>
<div v-if="taskData?.status === 'success'" class="flex flex-col gap-2" v-for="item in taskData?.result">
<div class="bg-white/80 p-2 rounded-md w-full flex flex-row items-center justify-between gap-2">
<div class="flex flex-row gap-2 items-center max-w-2/3">
<div class="flex flex-row items-center justify-center rounded-md bg-black/5 p-2">
<LucideImage />
</div>
<div class="truncate w-auto">{{ props?.data?.file?.name }}</div>
<div class="flex flex-row gap-2 items-center text-sm shrink-0">
<span class="opacity-75">{{ filesize(item.new_file.size ?? 0) }}</span>
<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>
<BaseCard class="flex flex-col gap-3" :title="t('page.result.imageCompress.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.imageCompress.totalSize') }}</div>
<div class="text-2xl font-light flex flex-row items-center gap-1">
<span v-if="totalSize.oldSize > 0" class="opacity-75">{{ filesize(totalSize.oldSize) }}</span>
<span v-else><Skeleton class="w-12 h-10" /></span>
<LucideChevronsRight class="size-6" />
<span v-if="totalSize.newSize > 0">{{ filesize(totalSize.newSize) }}</span>
<span v-else><Skeleton class="w-12 h-10" /></span>
<div
v-if="totalSize.oldSize > 0 && totalSize.newSize > 0"
class="rounded flex flex-row items-center bg-green-100 text-green-600 p-1 py-0.5 text-sm"
>
<LucideArrowDown class="size-4" />
{{ ((1 - totalSize.newSize / totalSize.oldSize) * 100).toFixed(2) }}%
</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
variant="outline"
class="bg-black/5"
size="icon"
@click="
async () => {
const { new_file } = taskResults?.[index]?.data?.result?.[0] || {}
if (!new_file?.id) return
const data = await createFileShare({
file_id: item.new_file.id,
files: [{ id: new_file?.id as string, name: item?.file?.name }],
config: {
download_nums: 1,
expire_time: 60,
has_pickup_code: false,
has_password: false,
},
file_name: props?.data?.file?.name,
})
const { id } = data?.data || {}
const { id } = data?.[0]?.data || {}
if (!id) {
return
}
@@ -140,27 +198,9 @@ watch(
}
}
"
>
<LucideDownload />
</AsyncButton>
><LucideArrowDown
/></AsyncButton>
</div>
</div>
<div v-else-if="taskData?.status !== 'retry' && !!taskData?.err?.message" class="flex flex-col gap-2">
<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>
</BaseCard>
</template>

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

View File

@@ -2,13 +2,8 @@
import FileShareResult from '@/components/Result/FileShareResult.vue'
import TextShareResult from '@/components/Result/TextShareResult.vue'
import ImageCompressResult from '@/components/Result/ImageCompressResult.vue'
import type { FileHandleKey, TextHandleKey } from '../Preprocessing/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'
import ImageConvertResult from '@/components/Result/ImageConvertResult.vue'
import type { filehandleData, handleComponent, handleKey, texthandleData } from './types'
const props = defineProps<{
data: filehandleData | texthandleData
@@ -18,10 +13,11 @@ const emit = defineEmits<{
(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: TextShareResult, key: 'text-share' },
{ component: ImageCompressResult, key: 'file-image-compress' },
{ component: ImageConvertResult, key: 'file-image-convert' },
]
const activeHandle = computed(() => {
@@ -30,8 +26,6 @@ const activeHandle = computed(() => {
// vue这个ts蠢的没边了本来想写component: FileShareResult | TextShareResult结果不行
</script>
<template>
<div>
<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)" />
</div>
<component v-if="'files' 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)" />
</template>

View File

@@ -10,11 +10,9 @@ import showDrawer from '@/lib/showDrawer'
import QrCoreDrawer from '@/components/Drawer/QrCoreDrawer.vue'
import dayjs from 'dayjs'
import { h } from 'vue'
import type { TextHandleKey } from '../Preprocessing/types'
import type { handleTextComponentProps } from './types'
const props = defineProps<{
data: { text: string; config: Record<string, any>; handle_type: TextHandleKey }
}>()
const props = defineProps<handleTextComponentProps>()
const emit = defineEmits<{
(e: 'change', key: string): void
@@ -44,41 +42,24 @@ const { t } = useI18n()
</script>
<template>
<div class="flex flex-col gap-3">
<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>
<BaseCard class="flex flex-col gap-3" :title="t('page.result.text.title')" :showBackButton="true">
<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="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="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>
<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">
{{ dayjs((data?.expire_at ?? 0) * 1000).format('YYYY-MM-DD HH:mm:ss') }}
</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="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
variant="outline"
class="bg-white/70 p-0 size-6"
@@ -86,7 +67,7 @@ const { t } = useI18n()
@click="
() => {
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 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">
<Input v-model="url" class="bg-white/70" readonly />
<Button
@@ -112,7 +93,7 @@ const { t } = useI18n()
@click="
() => {
copy(url)
toast.success(t('textshareresult.copySuccess'))
toast.success(t('page.result.text.copySuccess'))
}
"
>
@@ -140,7 +121,7 @@ const { t } = useI18n()
</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" />
</div>
</BaseCard>
</template>

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

View File

@@ -57,4 +57,5 @@ onUnmounted(() => {
"
>
</editor-content>
<!-- <BubbleMenuView :editor="editor as any" /> -->
</template>

View File

@@ -14,9 +14,9 @@ const handleError = () => clearError({ redirect: '/' })
<template>
<NuxtLayout>
<div class="rounded-xl p-5 bg-white/50 backdrop-blur-xl w-full lg:w-200 flex flex-col items-center justify-center min-h-[50vh] mt-5 gap-10">
<BaseCard class="flex flex-col items-center justify-center min-h-[50vh] mt-5 gap-10">
<div class="font-bold text-5xl">{{ error?.statusCode }}</div>
<Button @click="handleError">{{ t('btn.backToHome') }}</Button>
</div>
</BaseCard>
</NuxtLayout>
</template>

View File

@@ -13,114 +13,173 @@
"submit": "Submit",
"backToHome": "Back to Home"
},
"file": {
"uploadFile": "Upload File",
"uploadFilePlaceholder": "Drag and drop files or click to upload",
"addMore": "Add More",
"handleType": {
"file-share": "File Share",
"file-image-compress": "Image Compress"
}
},
"text": {
"uploadText": "Upload Text",
"uploadTextPlaceholder": "Share, translate, summarize, generate images, and ask large models with our text processor",
"handleType": {
"text-share": "Text Share"
}
},
"pickup": {
"title": "Enter Pickup Code",
"codeError": "Invalid pickup code",
"btn": "Pickup"
},
"fileshare": {
"title": "Share Options",
"downloadNums": "Download Count",
"expireTime": "Expire Time",
"or": "or",
"expireAfter": "expire",
"pickupCode": "Pickup Code",
"passwordProtection": "Password Protection",
"downloadNotify": "Download Notification",
"passwordPlaceholder": "Enter password",
"emailPlaceholder": "Enter email",
"downloadOptions": {
"1time": "1 download",
"2times": "2 downloads",
"3times": "3 downloads",
"5times": "5 downloads",
"10times": "10 downloads"
"page": {
"upload": {
"file": {
"uploadFile": "Upload File",
"uploadFilePlaceholder": "Drag and drop files or click to upload",
"addMore": "Add More",
"handleType": {
"file-share": "File Share",
"file-image-compress": "Image Compress",
"file-image-convert": "Format Convert"
}
},
"text": {
"uploadText": "Upload Text",
"uploadTextPlaceholder": "Share, translate, summarize, generate images, and ask large models with our text processor",
"handleType": {
"text-share": "Text Share"
}
},
"pickup": {
"title": "Enter Pickup Code",
"codeError": "Invalid pickup code",
"btn": "Pickup"
}
},
"expireOptions": {
"5min": "5 minutes",
"1hour": "1 hour",
"1day": "1 day",
"3days": "3 days"
}
},
"textshare": {
"title": "Share Options",
"viewNums": "View Count",
"expireTime": "Expire Time",
"or": "or",
"expireAfter": "expire",
"pickupCode": "Pickup Code",
"passwordProtection": "Password Protection",
"readNotify": "Read Notification",
"passwordPlaceholder": "Enter password",
"emailPlaceholder": "Enter email",
"viewOptions": {
"1time": "1 view",
"2times": "2 views",
"3times": "3 views",
"5times": "5 views",
"10times": "10 views"
"shareOptions": {
"file": {
"title": "Share Options",
"downloadNums": "Download Count",
"expireTime": "Expire Time",
"or": "or",
"expireAfter": "expire",
"pickupCode": "Pickup Code",
"passwordProtection": "Password Protection",
"downloadNotify": "Download Notification",
"passwordPlaceholder": "Enter password",
"emailPlaceholder": "Enter email",
"downloadOptions": {
"xdownload": "{0} downloads"
},
"expireOptions": {
"5min": "5 minutes",
"1hour": "1 hour",
"1day": "1 day",
"3days": "3 days"
}
},
"imageConvert": {
"title": "Image Convert",
"targetFormat": "Target Format"
},
"text": {
"title": "Share Options",
"viewNums": "View Count",
"expireTime": "Expire Time",
"or": "or",
"expireAfter": "expire",
"pickupCode": "Pickup Code",
"passwordProtection": "Password Protection",
"readNotify": "Read Notification",
"passwordPlaceholder": "Enter password",
"emailPlaceholder": "Enter email",
"viewOptions": {
"xview": "{0} views"
},
"expireOptions": {
"5min": "5 minutes",
"1hour": "1 hour",
"1day": "1 day",
"3days": "3 days"
}
}
},
"expireOptions": {
"5min": "5 minutes",
"1hour": "1 hour",
"1day": "1 day",
"3days": "3 days"
"progress": {
"file": {
"totalUploadProgress": "Total Upload Progress",
"fileList": "File List",
"fileName": "File Name",
"fileSize": "File Size",
"uploadSpeed": "Speed",
"progress": "Progress",
"uploadDetails": "Upload Details",
"chunk": "Chunk",
"completed": "Completed",
"discarded": "Discarded",
"pending": "Pending",
"chunkProgress": "Chunk Progress",
"chunkHeatmap": "Chunk Heatmap",
"heatmap": "Heatmap",
"progressBar": "Progress Bar",
"uploadError": "Upload Error",
"chunkUploadFailed": "Chunk {1} of file {0} failed after multiple attempts, we have terminated the file upload",
"chunkUploadRetry": "Chunk {1} of file {0} failed to upload, we will try again later",
"fileUploadFailed": "File {0} upload failed, please try again",
"uploadFailed": "Upload Failed",
"processing": {
"hash": "Calculating Hash...",
"create": "Initializing Upload...",
"upload": "Uploading...",
"finish": "Upload Finished"
},
"instantUploadSuccess": "File with same hash exists in cloud, instant upload successful",
"uploadFailedRetry": "Upload failed, please try again later",
"uploadSpeedInfo": {
"title": "How is the upload speed calculated?",
"desc": {
"base": "The upload speed is estimated based on {chunkNum} * {chunkSize} uploaded in the current second, which may have some deviation from the actual upload speed, for reference only",
"chunkNum": "Number of file chunks",
"chunkSize": "Size of each file chunk"
}
}
}
},
"result": {
"file": {
"title": "Upload Successful",
"fileList": "File List",
"info": "Info",
"downloadNums": "Download Count",
"expireTime": "Expire Time",
"pickupCode": "Pickup Code",
"link": "Link",
"copySuccess": "Copy Success"
},
"imageCompress": {
"title": "Image Compress",
"totalSize": "Total Size",
"task": "Task",
"retry": "Retry {0}/{1}",
"failed": "Failed"
},
"imageConvert": {
"title": "Image Convert",
"convert": "Convert",
"task": "Task",
"retry": "Retry {0}/{1}",
"failed": "Failed"
},
"text": {
"title": "Share Successful",
"info": "Info",
"viewNums": "View Count",
"expireTime": "Expire Time",
"pickupCode": "Pickup Code",
"link": "Link",
"content": "Content",
"copySuccess": "Copy Success"
}
},
"about": {
"powerBy": "Power by {0} as a open source temporary file sharing platform",
"file": "File",
"share": "Share",
"download": "Download",
"task": "Task",
"admin": "Site Admin",
"author": "Author",
"title": "About",
"about": "About",
"systemInfo": "System Info",
"systemVersion": "System Version",
"storage": "Storage",
"analysis": "Analysis",
"fileSize": "File Size",
"fileNum": "File Num",
"processed": "Processed",
"failed": "Failed"
}
},
"fileshareresult": {
"title": "Upload Successful",
"fileList": "File List",
"info": "Info",
"downloadNums": "Download Count",
"expireTime": "Expire Time",
"pickupCode": "Pickup Code",
"link": "Link",
"copySuccess": "Copy Success"
},
"textshareresult": {
"title": "Share Successful",
"info": "Info",
"viewNums": "View Count",
"expireTime": "Expire Time",
"pickupCode": "Pickup Code",
"link": "Link",
"content": "Content",
"copySuccess": "Copy Success"
},
"about": {
"powerBy": "Power by {0} as a open source temporary file sharing platform",
"file": "File",
"share": "Share",
"download": "Download",
"task": "Task",
"admin": "Site Admin",
"author": "Author",
"title": "About",
"about": "About",
"systemInfo": "System Info",
"systemVersion": "System Version",
"storage": "Storage",
"analysis": "Analysis",
"fileSize": "File Size",
"fileNum": "File Num",
"processed": "Processed",
"failed": "Failed"
}
}

View File

@@ -13,114 +13,173 @@
"submit": "提交",
"backToHome": "返回首页"
},
"file": {
"uploadFile": "上传文件",
"uploadFilePlaceholder": "拖拽文件 或 点击上传",
"addMore": "添加更多",
"handleType": {
"file-share": "文件分享",
"file-image-compress": "图片压缩"
}
},
"text": {
"uploadText": "上传文本",
"uploadTextPlaceholder": "使用我们的文本处理器轻松分享,翻译,总结,生成图片,询问大模型",
"handleType": {
"text-share": "文本分享"
}
},
"pickup": {
"title": "输入取件码",
"codeError": "取件码错误",
"btn": "取件"
},
"fileshare": {
"title": "分享选项",
"downloadNums": "下载次数",
"expireTime": "过期时间",
"or": "或",
"expireAfter": "后过期",
"pickupCode": "取件码",
"passwordProtection": "密码保护",
"downloadNotify": "下载通知",
"passwordPlaceholder": "请输入密码",
"emailPlaceholder": "请输入邮箱",
"downloadOptions": {
"1time": "1次下载",
"2times": "2次下载",
"3times": "3次下载",
"5times": "5次下载",
"10times": "10次下载"
"page": {
"upload": {
"file": {
"uploadFile": "上传文件",
"uploadFilePlaceholder": "拖拽文件 或 点击上传",
"addMore": "添加更多",
"handleType": {
"file-share": "文件分享",
"file-image-compress": "图片压缩",
"file-image-convert": "格式转换"
}
},
"text": {
"uploadText": "上传文本",
"uploadTextPlaceholder": "使用我们的文本处理器轻松分享,翻译,总结,生成图片,询问大模型",
"handleType": {
"text-share": "文本分享"
}
},
"pickup": {
"title": "输入取件码",
"codeError": "取件码错误",
"btn": "取件"
}
},
"expireOptions": {
"5min": "5分钟",
"1hour": "1小时",
"1day": "1天",
"3days": "3天"
}
},
"textshare": {
"title": "分享选项",
"viewNums": "浏览次数",
"expireTime": "过期时间",
"or": "",
"expireAfter": "后过期",
"pickupCode": "取件码",
"passwordProtection": "密码保护",
"readNotify": "已读通知",
"passwordPlaceholder": "请输入密码",
"emailPlaceholder": "请输入邮箱",
"viewOptions": {
"1time": "1次浏览",
"2times": "2次浏览",
"3times": "3次浏览",
"5times": "5次浏览",
"10times": "10次浏览"
"shareOptions": {
"file": {
"title": "分享选项",
"downloadNums": "下载次数",
"expireTime": "过期时间",
"or": "或",
"expireAfter": "后过期",
"pickupCode": "取件码",
"passwordProtection": "密码保护",
"downloadNotify": "下载通知",
"passwordPlaceholder": "请输入密码",
"emailPlaceholder": "请输入邮箱",
"downloadOptions": {
"xdownload": "{0}次下载"
},
"expireOptions": {
"5min": "5分钟",
"1hour": "1小时",
"1day": "1天",
"3days": "3天"
}
},
"imageConvert": {
"title": "图片转换",
"targetFormat": "目标格式"
},
"text": {
"title": "分享选项",
"viewNums": "浏览次数",
"expireTime": "过期时间",
"or": "或",
"expireAfter": "后过期",
"pickupCode": "取件码",
"passwordProtection": "密码保护",
"readNotify": "已读通知",
"passwordPlaceholder": "请输入密码",
"emailPlaceholder": "请输入邮箱",
"viewOptions": {
"xview": "{0}次浏览"
},
"expireOptions": {
"5min": "5分钟",
"1hour": "1小时",
"1day": "1天",
"3days": "3天"
}
}
},
"expireOptions": {
"5min": "5分钟",
"1hour": "1小时",
"1day": "1天",
"3days": "3天"
"progress": {
"file": {
"totalUploadProgress": "总上传进度",
"fileList": "文件列表",
"fileName": "文件名",
"fileSize": "文件大小",
"uploadSpeed": "上传速度",
"progress": "进度",
"uploadDetails": "上传详情",
"chunk": "区块",
"completed": "已完成",
"discarded": "已丢弃",
"pending": "待完成",
"chunkProgress": "区块进度条",
"chunkHeatmap": "区块热力图",
"heatmap": "热力图",
"progressBar": "进度条",
"uploadError": "上传错误",
"chunkUploadFailed": "文件 {0} 的{1}分块经过多次尝试依然上传失败, 我们已经终止该文件上传",
"chunkUploadRetry": "文件 {0} 的{1}分块上传失败, 我们将在稍后再次尝试上传",
"fileUploadFailed": "文件{0}上传失败, 请重试",
"uploadFailed": "上传失败",
"processing": {
"hash": "计算Hash...",
"create": "初始化上传...",
"upload": "上传中...",
"finish": "上传完成"
},
"instantUploadSuccess": "云端已有相同Hash文件, 秒传成功",
"uploadFailedRetry": "上传失败,请稍后重试",
"uploadSpeedInfo": {
"title": "上传速度如何计算",
"desc": {
"base": "上传速度根据当前秒上传了 {chunkNum} * {chunkSize} 估算而来,可能与真实上传速度有一定的误差,仅供参考",
"chunkNum": "文件区块的数量",
"chunkSize": "每个文件区块的大小"
}
}
}
},
"result": {
"file": {
"title": "上传成功",
"fileList": "文件列表",
"info": "信息",
"downloadNums": "下载次数",
"expireTime": "过期时间",
"pickupCode": "提取码",
"link": "链接",
"copySuccess": "复制成功"
},
"imageCompress": {
"title": "图片压缩",
"totalSize": "总大小",
"task": "任务",
"retry": "重试 {0}/{1}",
"failed": "失败"
},
"imageConvert": {
"title": "图片转换",
"convert": "转换",
"task": "任务",
"retry": "重试 {0}/{1}",
"failed": "失败"
},
"text": {
"title": "分享成功",
"info": "信息",
"viewNums": "浏览次数",
"expireTime": "过期时间",
"pickupCode": "提取码",
"link": "链接",
"content": "内容",
"copySuccess": "复制成功"
}
},
"about": {
"powerBy": "由 {0} 驱动的开源自托管临时文件分享平台",
"file": "文件",
"share": "分享",
"download": "下载",
"task": "任务",
"admin": "本站管理员",
"author": "作者",
"title": "关于",
"about": "关于",
"systemInfo": "系统信息",
"systemVersion": "系统版本",
"storage": "已托管的文件",
"analysis": "分析",
"fileSize": "文件大小",
"fileNum": "文件数量",
"processed": "处理数量",
"failed": "失败数量"
}
},
"fileshareresult": {
"title": "上传成功",
"fileList": "文件列表",
"info": "信息",
"downloadNums": "下载次数",
"expireTime": "过期时间",
"pickupCode": "提取码",
"link": "链接",
"copySuccess": "复制成功"
},
"textshareresult": {
"title": "分享成功",
"info": "信息",
"viewNums": "浏览次数",
"expireTime": "过期时间",
"pickupCode": "提取码",
"link": "链接",
"content": "内容",
"copySuccess": "复制成功"
},
"about": {
"powerBy": "由 {0} 驱动的开源自托管临时文件分享平台",
"file": "文件",
"share": "分享",
"download": "下载",
"task": "任务",
"admin": "本站管理员",
"author": "作者",
"title": "关于",
"about": "关于",
"systemInfo": "系统信息",
"systemVersion": "系统版本",
"storage": "已托管的文件",
"analysis": "分析",
"fileSize": "文件大小",
"fileNum": "文件数量",
"processed": "处理数量",
"failed": "失败数量"
}
}

View File

@@ -27,7 +27,6 @@ export default defineNuxtConfig({
],
},
vite: {
transformMixedEsModules: true,
plugins: [tailwindcss()],
optimizeDeps: {
include: ['eventemitter3'],

View File

@@ -12,55 +12,55 @@
"dependencies": {
"@nuxt/image": "1.10.0",
"@nuxtjs/i18n": "9.5.5",
"@pinia/nuxt": "^0.11.2",
"@tailwindcss/postcss": "^4.1.14",
"@tailwindcss/vite": "^4.1.14",
"@tanstack/vue-query": "^5.90.5",
"@tiptap/extension-blockquote": "^3.7.2",
"@tiptap/extension-bold": "^3.7.2",
"@tiptap/extension-bubble-menu": "^3.7.2",
"@tiptap/extension-heading": "^3.7.2",
"@tiptap/extension-italic": "^3.7.2",
"@tiptap/extension-paragraph": "^3.7.2",
"@tiptap/extension-placeholder": "^3.7.2",
"@tiptap/extension-strike": "^3.7.2",
"@tiptap/extension-text": "^3.7.2",
"@tiptap/pm": "^3.7.2",
"@tiptap/starter-kit": "^3.7.2",
"@tiptap/vue-3": "^3.7.2",
"@unovis/ts": "^1.6.1",
"@unovis/vue": "^1.6.1",
"@pinia/nuxt": "^0.11.3",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/vue-query": "^5.92.9",
"@tiptap/extension-blockquote": "^3.18.0",
"@tiptap/extension-bold": "^3.18.0",
"@tiptap/extension-bubble-menu": "^3.18.0",
"@tiptap/extension-heading": "^3.18.0",
"@tiptap/extension-italic": "^3.18.0",
"@tiptap/extension-paragraph": "^3.18.0",
"@tiptap/extension-placeholder": "^3.18.0",
"@tiptap/extension-strike": "^3.18.0",
"@tiptap/extension-text": "^3.18.0",
"@tiptap/pm": "^3.18.0",
"@tiptap/starter-kit": "^3.18.0",
"@tiptap/vue-3": "^3.18.0",
"@unovis/ts": "^1.6.2",
"@unovis/vue": "^1.6.2",
"@vee-validate/nuxt": "^4.15.1",
"@vee-validate/rules": "^4.15.1",
"axios": "^1.13.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.18",
"dayjs": "^1.11.19",
"filesize": "^10.1.6",
"js-md5": "^0.8.3",
"lodash-es": "^4.17.21",
"lodash-es": "^4.17.23",
"lucide-vue-next": "^0.542.0",
"markdown-it": "^14.1.0",
"motion-v": "^1.7.2",
"motion-v": "^1.10.2",
"nanoid": "^5.1.6",
"nuxt": "4.1.2",
"nuxt": "4.2.2",
"nuxt-lucide-icons": "1.0.5",
"pinia": "^3.0.3",
"pixi.js": "^8.14.0",
"pinia": "^3.0.4",
"pixi.js": "^8.15.0",
"qrcode": "^1.5.4",
"reka-ui": "^2.5.1",
"reka-ui": "^2.8.0",
"shadcn-nuxt": "2.0.1",
"spark-md5": "^3.0.2",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.14",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"tiptap-markdown": "^0.9.0",
"tw-animate-css": "^1.4.0",
"vaul-vue": "^0.4.1",
"vue": "^3.5.22",
"vue-router": "^4.6.3",
"vue": "^3.5.27",
"vue-router": "^4.6.4",
"vue-sonner": "^1.3.2",
"vue3-pixi": "1.0.0-beta.2"
},
"packageManager": "pnpm@9.11.0+sha512.0a203ffaed5a3f63242cd064c8fb5892366c103e328079318f78062f24ea8c9d50bc6a47aa3567cabefd824d170e78fa2745ed1f16b132e16436146b7688f19b",
"resolutions": {
"esbuild": "0.25.6"
},
@@ -68,7 +68,7 @@
"@tailwindcss/typography": "^0.5.19",
"@types/lodash-es": "^4.17.12",
"@types/markdown-it": "^14.1.2",
"@types/qrcode": "^1.5.5",
"@types/qrcode": "^1.5.6",
"@types/spark-md5": "^3.0.5",
"@vueuse/core": "^13.9.0",
"@vueuse/nuxt": "^13.9.0",

View File

@@ -6,10 +6,9 @@ const { t } = useI18n()
</script>
<template>
<div class="rounded-xl p-5 bg-white/50 backdrop-blur-xl w-full lg:w-200 my-5 flex flex-col gap-5">
<div class="text-xl font-normal">{{ t('about.title') }}</div>
<BaseCard class="my-5 flex flex-col gap-5" :title="t('page.about.title')">
<AboutBaseInfo />
<AboutChartView />
<AboutVersionView />
</div>
</BaseCard>
</template>

View File

@@ -19,8 +19,7 @@ if (!isDev) {
</script>
<template>
<div class="rounded-xl p-5 bg-white/50 backdrop-blur-xl w-full lg:w-200 my-5 flex flex-col gap-5">
<h1>Dev</h1>
<BaseCard class="my-5 flex flex-col gap-5" title="dev">
<div class="flex flex-row gap-5 items-center">
<Button
@click="
@@ -74,6 +73,6 @@ if (!isDev) {
</div>
</VeeForm>
<div>测试dayjs语言包渲染:{{ dayjs().add(1, 'day').fromNow() }}</div>
</div>
</BaseCard>
</template>
<style scoped></style>

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col gap-5 py-5 items-center w-full h-full">
<component :is="renderComponent" />
</div>
<div class="flex flex-col gap-5 py-5 items-center w-full h-full">
<component :is="renderComponent" />
</div>
</template>
<script setup lang="ts">
@@ -11,15 +11,15 @@ import { isString } from 'lodash-es'
const route = useRoute()
const router = useRouter()
const type = computed(() => route?.query?.type)
onMounted(() => {
if (!isString(type.value) || type.value?.length === 0) {
router.push({ query: { type: 'file' }, replace: true })
}
watchEffect(() => {
if (!isString(type.value) || type.value?.length === 0) {
router.push({ query: { type: 'file' }, replace: true })
}
})
const renderList = [
{ key: 'file', component: FileUploadView },
{ key: 'text', component: TextUploadView },
{ key: 'file', component: FileUploadView },
{ key: 'text', component: TextUploadView },
]
const renderComponent = computed(() => renderList.find(item => item.key === type.value)?.component)
</script>
const renderComponent = computed(() => renderList.find((item) => item.key === type.value)?.component)
</script>

View File

@@ -23,7 +23,7 @@ const { data, isLoading, error } = useQuery({
}>(`/api/share/${id.value}`)
return data?.data
},
retry: false
retry: false,
})
const isExpired = computed(() => {
@@ -33,13 +33,12 @@ const isExpired = computed(() => {
const componentMap = {
file: FileShareView,
text: TextShareView
text: TextShareView,
}
</script>
<template>
<div class="rounded-xl p-5 bg-white/50 backdrop-blur-xl w-full lg:w-200 my-5 overflow-hidden">
<BaseCard class="my-5 overflow-hidden">
<div v-if="isLoading" class="flex flex-col gap-5 items-center">
<Skeleton class="w-20 h-5 rounded-full" />
<Skeleton class="w-16 h-16 rounded-xl" />
@@ -52,15 +51,19 @@ const componentMap = {
<template v-else>
<div v-if="isExpired || !data" class="flex flex-col gap-5 items-center">
<LucideAlertCircle :size="48" class="text-orange-500 rounded-full bg-orange-500/30 p-2" />
<div class="text-xl ">此链接已过期</div>
<Button @click="() => {
router.push('/')
}">返回首页</Button>
<div class="text-xl">此链接已过期</div>
<Button
@click="
() => {
router.push('/')
}
"
>返回首页</Button
>
</div>
<template v-else>
<component :is="componentMap[data?.type as keyof typeof componentMap] || 'div'" :data="data" />
</template>
</template>
</div>
</BaseCard>
</template>

9
go.work Normal file
View File

@@ -0,0 +1,9 @@
go 1.25.5
use (
./backend
./pkg/models
./pkg/services
./pkg/utils
./worker
)

104
go.work.sum Normal file
View File

@@ -0,0 +1,104 @@
github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob0t8PQPMybUNFM=
github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.2 h1:b0mc6WyRSYLjzofB2v/0cuDUZ+MqoGyH3r0dVij35GI=
github.com/bmatcuk/doublestar/v4 v4.9.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.105.0 h1:dLj5P6pLApBRat9SADGiLxLZjiDPvA1bsPkyV4PGx6I=
github.com/casbin/casbin/v2 v2.105.0/go.mod h1:Ee33aqGrmES+GNL17L0h9X28wXuo829wnNUnS0edAco=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg=
github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o=
github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk=
github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg=
github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20250807160809-1a19826ec488 h1:3doPGa+Gg4snce233aCWnbZVFsyFMo/dR40KK/6skyE=
golang.org/x/telemetry v0.0.0-20250807160809-1a19826ec488/go.mod h1:fGb/2+tgXXjhjHsTNdVEEMZNWA0quBnfrO+AfoDSAKw=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM=
google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -4,12 +4,23 @@
"private": true,
"description": "",
"scripts": {
"dev": "npm-run-all -p dev:front dev:backend dev:worker",
"dev": "concurrently -n front,backend,worker -c blue,green,yellow 'pnpm:dev:front' 'pnpm:dev:backend' 'pnpm:dev:worker'",
"dev:front": "cd front && pnpm run dev",
"dev:backend": "cd backend && air",
"dev:worker": "cd worker && air",
"test": "echo \"Error: no test specified\" && exit 1",
"prepare": "husky"
"lint": "concurrently -n front,backend,worker -c blue,green,yellow 'pnpm:lint:front' 'pnpm:lint:backend' 'pnpm:lint:worker'",
"lint:front": "cd front && pnpm nuxt typecheck",
"lint:backend": "cd backend && golangci-lint run",
"lint:worker": "cd worker && golangci-lint run",
"test:backend": "cd backend && go test -v ./test/...",
"test:worker": "cd worker && go test -v ./test/...",
"test": "concurrently -n backend,worker -c green,yellow 'pnpm:test:backend' 'pnpm:test:worker'",
"prepare": "husky",
"up-deps": "concurrently -n backend,worker,models,utils -c green,yellow,blue,magenta 'pnpm:up-deps:backend' 'pnpm:up-deps:worker' 'pnpm:up-deps:models' 'pnpm:up-deps:utils'",
"up-deps:backend": "cd backend && go get -u",
"up-deps:worker": "cd worker && go get -u",
"up-deps:models": "cd pkg/models && go get -u",
"up-deps:utils": "cd pkg/utils && go get -u"
},
"workspaces": [
"front"
@@ -27,10 +38,10 @@
"author": "",
"license": "ISC",
"devDependencies": {
"concurrently": "^9.2.1",
"husky": "^9.1.7",
"lint-staged": "^15.5.2",
"npm-run-all": "^4.1.5",
"prettier": "^3.6.2"
"prettier": "^3.8.1"
},
"packageManager": "pnpm@9.11.0+sha512.0a203ffaed5a3f63242cd064c8fb5892366c103e328079318f78062f24ea8c9d50bc6a47aa3567cabefd824d170e78fa2745ed1f16b132e16436146b7688f19b"
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017"
}

View File

@@ -1,8 +1,8 @@
package models
import (
"backend/internal/utils"
"encoding/json"
"pkg/utils"
"dario.cat/mergo"
"github.com/redis/go-redis/v9"

View File

@@ -2,7 +2,7 @@ package models
import (
"encoding/json"
"worker/internal/utils"
"pkg/utils"
"github.com/redis/go-redis/v9"
)

13
pkg/models/go.mod Normal file
View File

@@ -0,0 +1,13 @@
module pkg/models
go 1.25.5
require (
dario.cat/mergo v1.0.2
github.com/redis/go-redis/v9 v9.17.3
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
)

10
pkg/models/go.sum Normal file
View File

@@ -0,0 +1,10 @@
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/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=

View File

@@ -1,10 +1,11 @@
package models
import (
"backend/internal/utils"
"fmt"
"time"
"pkg/utils"
"github.com/redis/go-redis/v9"
)

View File

@@ -1,11 +1,12 @@
package models
import (
"backend/internal/utils"
"encoding/json"
"fmt"
"time"
"pkg/utils"
"dario.cat/mergo"
"github.com/redis/go-redis/v9"
)

View File

@@ -1,9 +1,10 @@
package models
import (
"backend/internal/utils"
"encoding/json"
"pkg/utils"
"dario.cat/mergo"
"github.com/redis/go-redis/v9"
)

View File

@@ -4,7 +4,8 @@ import (
"encoding/json"
"fmt"
"time"
"worker/internal/utils"
"pkg/utils"
"github.com/redis/go-redis/v9"
)

22
pkg/services/file.go Normal file
View File

@@ -0,0 +1,22 @@
package services
import (
"encoding/json"
"time"
"pkg/utils"
"github.com/hibiken/asynq"
)
func SetFileRemoveTask(fileId string, expire time.Duration) error {
client := utils.GetQueueClient()
json, err := json.Marshal(map[string]any{
"file_id": fileId,
})
if err != nil {
return err
}
_, err = client.Enqueue(asynq.NewTask("file:remove", json), asynq.ProcessIn(time.Duration(expire)*time.Second))
return err
}

17
pkg/services/go.mod Normal file
View File

@@ -0,0 +1,17 @@
module pkg/services
go 1.25.5
require github.com/hibiken/asynq v0.25.1
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/redis/go-redis/v9 v9.17.3 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/spf13/cast v1.10.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

18
pkg/services/go.sum Normal file
View File

@@ -0,0 +1,18 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

99
pkg/utils/env.go Normal file
View File

@@ -0,0 +1,99 @@
package utils
import (
"io"
"strings"
"sync"
"github.com/spf13/viper"
)
var (
v *viper.Viper
envOnce sync.Once
)
func InitEnv(props EnvOption) {
if v != nil {
return
}
envOnce.Do(func() {
v = viper.New()
for _, viperConfigType := range props.ConfigType {
v.SetConfigType(viperConfigType)
}
if props.ConfigData != nil {
v.ReadConfig(props.ConfigData)
return
}
for _, name := range props.ConfigName {
v.SetConfigName(name)
}
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
for _, path := range props.ConfigPath {
v.AddConfigPath(path)
}
v.AutomaticEnv()
v.WatchConfig()
err := v.ReadInConfig()
if err != nil {
panic(err)
}
})
}
type Option interface {
applyTo(*EnvOption)
}
type EnvOption struct {
DefaultValue string
ConfigPath []string
ConfigName []string
ConfigType []string
ConfigData io.Reader // 测试环境使用
}
type WithDefaultValue string
func (o WithDefaultValue) applyTo(props *EnvOption) {
props.DefaultValue = string(o)
}
func getEnvOptions(options ...Option) EnvOption {
props := EnvOption{
DefaultValue: "",
ConfigPath: []string{".", "../"},
ConfigName: []string{"config"},
ConfigType: []string{"yaml"},
}
for _, option := range options {
option.applyTo(&props)
}
return props
}
func GetEnv(key string, options ...Option) string {
props := getEnvOptions(options...)
InitEnv(props)
value := v.GetString(key)
if value == "" && props.DefaultValue != "" {
return props.DefaultValue
}
return value
}
func GetEnvWithDefault(key string, defaultValue string) string {
return GetEnv(key, WithDefaultValue(defaultValue))
}
func GetEnvMapString(key string) map[string]string {
props := getEnvOptions()
InitEnv(props)
return v.GetStringMapString(key)
}
func SetEnv(key string, value string) {
v.Set(key, value)
}

View File

@@ -7,6 +7,8 @@ import (
"io"
"os"
"path/filepath"
"github.com/dustin/go-humanize"
)
func GetFileId(fileHash string, fileSize int64) string {
@@ -14,11 +16,11 @@ func GetFileId(fileHash string, fileSize int64) string {
}
func GetFileMd5(file io.Reader) (string, error) {
const bufferSize = 1024 * 1000 // 1MB
hash := md5.New()
for buf, reader := make([]byte, bufferSize), bufio.NewReader(file); ; {
buf := make([]byte, bufferSize)
reader := bufio.NewReader(file)
for {
n, err := reader.Read(buf)
if err != nil {
if err == io.EOF {
@@ -26,26 +28,30 @@ func GetFileMd5(file io.Reader) (string, error) {
}
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
uploadPath := GetEnv("upload.path")
if uploadPath == "" {
basepath, err := os.Getwd()
if err != nil {
return "", err
}
uploadPath = filepath.Join(basepath, "uploads")
}
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)
}
func CopyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {

35
pkg/utils/go.mod Normal file
View File

@@ -0,0 +1,35 @@
module pkg/utils
go 1.25.5
require (
github.com/dustin/go-humanize v1.0.1
github.com/hibiken/asynq v0.25.1
github.com/redis/go-redis/v9 v9.17.3
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // 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.5.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

60
pkg/utils/go.sum Normal file
View File

@@ -0,0 +1,60 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
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/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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw=
github.com/hibiken/asynq v0.25.1/go.mod h1:pazWNOLBu0FEynQRBvHA26qdIKRSmfdIfUm4HdsLmXg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4=
github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
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/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/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -2,12 +2,16 @@ package utils
import (
"context"
"sync"
"github.com/redis/go-redis/v9"
)
var rdb *redis.Client = InitRedis()
var ctx = context.Background()
var (
rdb *redis.Client
ctx = context.Background()
onceRedis sync.Once
)
func InitRedis() *redis.Client {
opt, err := redis.ParseURL(GetEnv("redis.url"))
@@ -18,5 +22,10 @@ func InitRedis() *redis.Client {
}
func GetRedisClient() (*redis.Client, context.Context) {
onceRedis.Do(func() {
if rdb == nil {
rdb = InitRedis()
}
})
return rdb, ctx
}

View File

@@ -0,0 +1,39 @@
package test
import (
"bytes"
"testing"
"pkg/utils"
"github.com/stretchr/testify/assert"
)
func TestInitEnvAndGetEnv(t *testing.T) {
jsonData := `
{
"test": {
"value": "foobar",
"empty": null
}
}
`
props := utils.EnvOption{
ConfigData: bytes.NewBufferString(jsonData),
ConfigType: []string{"json"},
}
utils.InitEnv(props)
// GetEnv应能拿到值
val := utils.GetEnv("test.value")
assert.Equal(t, "foobar", val)
// GetEnv拿不到值时应该返回空字符串
emptyVal := utils.GetEnv("test.empty")
assert.Equal(t, "", emptyVal)
// GetEnv拿不到值且有默认值时应该返回默认值
notExistKey := "test.not_exist"
valWithDefault := utils.GetEnvWithDefault(notExistKey, "defaultbar")
assert.Equal(t, "defaultbar", valWithDefault)
}

6515
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More