Initialize AutoLibrary website with Gitea deployment
This commit is contained in:
commit
18216953e7
101 files changed
+12581
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
* text=auto
|
||||||
|
/frontend/** text=auto eol=lf
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.service text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: Deploy AutoLibrary Website
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: autolibrary-production
|
||||||
|
steps:
|
||||||
|
- name: Check out the pushed commit
|
||||||
|
uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
github-server-url: ${{ gitea.server_url }}
|
||||||
|
ref: ${{ gitea.sha }}
|
||||||
|
|
||||||
|
- name: Build and test
|
||||||
|
shell: bash
|
||||||
|
run: bash deploy/build.sh
|
||||||
|
|
||||||
|
- name: Deploy and verify
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
RELEASE_SHA: ${{ gitea.sha }}
|
||||||
|
AUTOLIBRARY_PORT: ${{ vars.AUTOLIBRARY_PORT || '3003' }}
|
||||||
|
run: bash deploy/publish.sh
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
node_modules/
|
||||||
|
/.deploy/
|
||||||
|
/frontend/dist/
|
||||||
|
/webserver
|
||||||
|
.preview/
|
||||||
|
.legacy/
|
||||||
|
/.env
|
||||||
|
/.env.*
|
||||||
|
/frontend/.env
|
||||||
|
/frontend/.env.*
|
||||||
|
.vscode/
|
||||||
|
.claude/
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
*.exe
|
||||||
|
/config.json
|
||||||
|
/backend/data/
|
||||||
|
/casdoor/
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# AutoLibrary Website
|
||||||
|
|
||||||
|
北京建筑大学图书馆自动化工具的官网、软件下载中心与社区主题市场。
|
||||||
|
|
||||||
|
- 产品仓库:<https://gitea.kenanzhu.com/KenanZhu/AutoLibrary>
|
||||||
|
- 用户手册:<https://manuals.autolibrary.kenanzhu.com>
|
||||||
|
- 发行版本:<https://gitea.kenanzhu.com/KenanZhu/AutoLibrary/releases>
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
前端采用 Vue 3、TypeScript、Vite、Vue Router、Pinia 和 Lucide 图标。所有字体、图标和图片随构建产物提供,无需第三方 CDN。页面使用统一的布局、表单和状态组件,深浅主题由 CSS 语义变量管理。
|
||||||
|
|
||||||
|
Go/Gin 前端服务提供构建产物并将 `/api/*` 代理到后端;后端保留 GORM/SQLite、Casdoor 和本地主题文件存储。既可一起启动,也可分别部署。当前面向单实例运行。
|
||||||
|
|
||||||
|
```text
|
||||||
|
frontend/
|
||||||
|
src/
|
||||||
|
api/ # 请求、响应、下载和错误处理
|
||||||
|
components/ # 全站与业务共享组件
|
||||||
|
composables/ # 画廊等可复用交互逻辑
|
||||||
|
pages/ # 按路由拆分的页面
|
||||||
|
stores/ # 账户与外观状态
|
||||||
|
styles/ # 页面专用样式
|
||||||
|
site.ts # 项目链接、URL 和日期工具
|
||||||
|
types.ts # API 数据类型
|
||||||
|
styles.css # 双主题变量与响应式样式
|
||||||
|
public/assets/ # 原有品牌标识与实机截图
|
||||||
|
dist/ # 生产构建产物(生成)
|
||||||
|
backend/
|
||||||
|
handlers/themes_handler/ # 认证、查询、上传、更新、审核、下载
|
||||||
|
services/ # 主题打包、文件存储、事务协调、用户缓存
|
||||||
|
database/ # SQLite 初始化
|
||||||
|
models/ # 数据模型
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
需要 Go 1.25.6+ 和 Node.js 22.13+(推荐当前 Node LTS,Vite 要求 Node 22.12+,ESLint 要求 22.13+)。从项目根目录运行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 首次配置;已有 config.json 时保留原文件
|
||||||
|
cp config.example.json config.json
|
||||||
|
npm --prefix frontend ci
|
||||||
|
go run . backend
|
||||||
|
```
|
||||||
|
|
||||||
|
在另一个终端启动前端:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm --prefix frontend run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
开发地址为 `http://127.0.0.1:5173`。默认代理到 `http://127.0.0.1:8080`;后端端口不同时,在 `frontend/.env.local` 设置 `API_PROXY_TARGET=http://127.0.0.1:实际端口`。无需在前端添加服务器密钥。
|
||||||
|
|
||||||
|
登录与注册需要配置并启动 Casdoor。未配置时,浏览下载和主题市场仍可用;账户操作会显示服务未启用。管理员审核统一使用 Casdoor 管理员账户。
|
||||||
|
|
||||||
|
## 构建与检查
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm --prefix frontend run typecheck
|
||||||
|
npm --prefix frontend run check
|
||||||
|
npm --prefix frontend run build
|
||||||
|
go test ./...
|
||||||
|
go build -o webserver .
|
||||||
|
./webserver start
|
||||||
|
```
|
||||||
|
|
||||||
|
`build` 已包含类型、ESLint 和 Prettier 检查,产物位于 `frontend/dist`。使用 `npm --prefix frontend run format` 统一前端源码格式。没有增加测试用例或测试工具链。Go 检查用于验证已有包可以编译。
|
||||||
|
|
||||||
|
生产环境默认访问 `http://127.0.0.1:3000`,实际地址以 `config.json` 为准。`frontend` 和 `backend` 子命令支持独立启动,均可使用 `--config /absolute/path/config.json`。请将工作目录设置为项目部署目录。
|
||||||
|
|
||||||
|
## 发行与下载数据
|
||||||
|
|
||||||
|
版本数据库是官网版本信息的唯一来源。首页通过 `GET /api/download/latest` 获取最新已发布的稳定版(按 `releaseDate`、ID 降序;无稳定版时 `data: null`),下载列表保持原有分页接口。`/api` 前缀由 Go 前端或 Vite 代理移除,直接访问后端时使用 `/download/latest` 等路径。
|
||||||
|
|
||||||
|
版本模型新增以下字段,启动时由 GORM 增量添加列,不删除原字段或数据:
|
||||||
|
|
||||||
|
| JSON 字段 | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `giteaZipUrl` | Gitea Windows 便携包 |
|
||||||
|
| `giteaInstallerUrl` | Gitea Windows 安装程序 |
|
||||||
|
| `giteaDmgUrl` | Gitea macOS 安装包 |
|
||||||
|
| `releaseNotesUrl` | 对应版本的发行说明 |
|
||||||
|
|
||||||
|
使用已有受 API Key 保护的 `POST/PUT /download/versions` 接口维护这些字段。提供真实的 Gitea 附件地址;不要仅将历史 GitHub URL 替换域名。新页面没有可用附件地址时,提供 Gitea 发行列表入口,云盘下载照常保留。原 `github*Url` 字段继续供已有 API 调用使用,新页面不再展示 GitHub 下载入口。
|
||||||
|
|
||||||
|
独立更新日志已移除,`/changelog` 永久跳转 `/downloads`。发行说明在 Gitea 查看;公告接口及其数据库保留。个人下载历史尚未实现,本次没有扩展该功能。
|
||||||
|
|
||||||
|
## 发布与维护
|
||||||
|
|
||||||
|
网站源码推送到 Gitea 的 `main` 后,由生产 Runner 构建、部署并检查服务。首次接入见 [Gitea 自动部署](docs/gitea-deployment.md)。
|
||||||
|
|
||||||
|
完整部署、备份和回滚步骤见 [Ubuntu 部署说明](docs/migration-to-ubuntu.md)。配置和运行时数据与前端构建分开保存;本地现有数据库、主题文件和 Casdoor 配置不需要重建。
|
||||||
|
|
||||||
|
迁移前的静态页面、脚本和混淆工具已保存在项目内 `.legacy/`,不参与构建或运行,可用于核对历史内容。
|
||||||
|
|
||||||
|
主题上传支持最大 8 MB 的 `.qss` 和最大 4 MB 的 PNG/JPG 预览图,后端总请求体上限 16 MB。主题和打包文件一起保存;文件、元数据或预览更新后重新进入审核队列。退出时先停止接收请求,再关闭数据库。
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"autolibrary.top/webserver/backend/database/bulletins_database"
|
||||||
|
"autolibrary.top/webserver/backend/database/themes_database"
|
||||||
|
"autolibrary.top/webserver/backend/database/versions_database"
|
||||||
|
"autolibrary.top/webserver/backend/handlers/bulletins_handler"
|
||||||
|
"autolibrary.top/webserver/backend/handlers/errors_handler"
|
||||||
|
"autolibrary.top/webserver/backend/handlers/health_handler"
|
||||||
|
"autolibrary.top/webserver/backend/handlers/themes_handler"
|
||||||
|
"autolibrary.top/webserver/backend/handlers/versions_handler"
|
||||||
|
"autolibrary.top/webserver/backend/middlewares/auth_middleware"
|
||||||
|
"autolibrary.top/webserver/backend/middlewares/casdoor_middleware"
|
||||||
|
"autolibrary.top/webserver/backend/services/theme_files"
|
||||||
|
"autolibrary.top/webserver/backend/services/users_service"
|
||||||
|
"autolibrary.top/webserver/config"
|
||||||
|
"autolibrary.top/webserver/utils"
|
||||||
|
|
||||||
|
"github.com/casdoor/casdoor-go-sdk/casdoorsdk"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartBackend : initializes the backend server.
|
||||||
|
func StartBackend() error { return StartBackendContext(context.Background()) }
|
||||||
|
|
||||||
|
func StartBackendContext(ctx context.Context) error {
|
||||||
|
defer EndBackend()
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
err := versions_database.InitDB(cfg.Backend.Database.Versions.Path)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] versions database initialization failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = bulletins_database.InitDB(cfg.Backend.Database.Bulletins.Path)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] bulletins database initialization failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = themes_database.InitDB(cfg.Backend.Database.Themes.Path)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] themes database initialization failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
files, err := theme_files.NewStore(cfg.Backend.Database.ThemesFiles.Path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
users := users_service.New(cfg.Backend.Casdoor.Endpoint != "" && cfg.Backend.Casdoor.ClientID != "")
|
||||||
|
themes := themes_handler.New(themes_database.DB, cfg, files, users)
|
||||||
|
|
||||||
|
// Pre-initialize Casdoor SDK and user display-name cache
|
||||||
|
if cfg.Backend.Casdoor.Endpoint != "" && cfg.Backend.Casdoor.ClientID != "" {
|
||||||
|
casdoorsdk.InitConfig(
|
||||||
|
cfg.Backend.Casdoor.Endpoint,
|
||||||
|
cfg.Backend.Casdoor.ClientID,
|
||||||
|
cfg.Backend.Casdoor.ClientSecret,
|
||||||
|
cfg.Backend.Casdoor.Certificate,
|
||||||
|
cfg.Backend.Casdoor.Organization,
|
||||||
|
cfg.Backend.Casdoor.Application,
|
||||||
|
)
|
||||||
|
casdoorsdk.SetHttpClient(&http.Client{Timeout: 15 * time.Second})
|
||||||
|
}
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.MaxMultipartMemory = 8 << 20
|
||||||
|
r.Use(func(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<20); c.Next() })
|
||||||
|
r.Use(gin.Recovery())
|
||||||
|
r.Use(func(c *gin.Context) {
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, X-Requested-With")
|
||||||
|
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
templateDir := filepath.Join(utils.GetRootPath(), "backend", "templates")
|
||||||
|
templateFiles := []string{
|
||||||
|
filepath.Join(templateDir, "404.html"),
|
||||||
|
}
|
||||||
|
r.LoadHTMLFiles(templateFiles...)
|
||||||
|
|
||||||
|
public := r.Group("/")
|
||||||
|
{
|
||||||
|
public.GET("/health", health_handler.GetHealth)
|
||||||
|
public.GET("/download/latest", versions_handler.GetLatestDownload)
|
||||||
|
public.GET("/download/versions", versions_handler.GetDownloadVersions)
|
||||||
|
public.GET("/download/versions/:id", versions_handler.GetDownloadVersionByID)
|
||||||
|
public.GET("/bulletins", bulletins_handler.GetBulletins)
|
||||||
|
public.GET("/bulletins/:id", bulletins_handler.GetBulletinByID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marketplace public routes
|
||||||
|
mp := r.Group("/marketplace")
|
||||||
|
{
|
||||||
|
mp.GET("/auth/config", themes.GetAuthConfig)
|
||||||
|
mp.GET("/auth/captcha", themes.GetCaptcha)
|
||||||
|
mp.POST("/auth/login", themes.LoginProxy)
|
||||||
|
mp.POST("/auth/signup", themes.SignupProxy)
|
||||||
|
mp.GET("/themes/featured", themes.GetFeaturedThemes)
|
||||||
|
mp.GET("/themes/preview/:filename", themes.ServePreview)
|
||||||
|
mp.GET("/themes", themes.GetThemes)
|
||||||
|
mp.GET("/themes/:id", themes.GetThemeByID)
|
||||||
|
mp.GET("/themes/:id/download", themes.DownloadTheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marketplace admin routes (API key)
|
||||||
|
mpAdmin := r.Group("/marketplace")
|
||||||
|
mpAdmin.Use(auth_middleware.APIKeyAuth())
|
||||||
|
{
|
||||||
|
mpAdmin.PUT("/admin/themes/:id/review", themes.ReviewTheme)
|
||||||
|
mpAdmin.GET("/admin/themes/pending", themes.GetPendingThemes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marketplace user routes (Casdoor JWT)
|
||||||
|
mpUser := r.Group("/marketplace")
|
||||||
|
mpUser.Use(casdoor_middleware.CasdoorAuth())
|
||||||
|
{
|
||||||
|
mpUser.POST("/themes", themes.PostTheme)
|
||||||
|
mpUser.PUT("/themes/:id", themes.PutThemeByID)
|
||||||
|
mpUser.DELETE("/themes/:id", themes.DeleteThemeByID)
|
||||||
|
mpUser.GET("/user/check-theme-name", themes.CheckThemeName)
|
||||||
|
mpUser.POST("/themes/:id/preview", themes.UploadPreview)
|
||||||
|
mpUser.POST("/themes/:id/file", themes.ReplaceThemeFile)
|
||||||
|
mpUser.GET("/user/themes", themes.GetUserThemes)
|
||||||
|
mpUser.GET("/user/downloads", themes.GetUserDownloads)
|
||||||
|
mpUser.GET("/user/role", themes.GetUserRole)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marketplace admin routes (Casdoor JWT + admin check)
|
||||||
|
mpAdminUser := r.Group("/marketplace")
|
||||||
|
mpAdminUser.Use(casdoor_middleware.CasdoorAdminAuth())
|
||||||
|
{
|
||||||
|
mpAdminUser.GET("/admin/pending", themes.GetPendingThemes)
|
||||||
|
mpAdminUser.PUT("/admin/review/:id", themes.ReviewTheme)
|
||||||
|
mpAdminUser.GET("/admin/download/:id", themes.AdminDownloadTheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected := r.Group("/")
|
||||||
|
protected.Use(auth_middleware.APIKeyAuth())
|
||||||
|
{
|
||||||
|
protected.POST("/download/versions", versions_handler.PostDownloadVersion)
|
||||||
|
protected.PUT("/download/versions/:id", versions_handler.PutDownloadVersionByID)
|
||||||
|
protected.DELETE("/download/versions/:id", versions_handler.DeleteDownloadVersionByID)
|
||||||
|
|
||||||
|
protected.POST("/bulletins", bulletins_handler.PostBulletin)
|
||||||
|
protected.PUT("/bulletins/:id", bulletins_handler.PutBulletinByID)
|
||||||
|
protected.DELETE("/bulletins/:id", bulletins_handler.DeleteBulletinByID)
|
||||||
|
}
|
||||||
|
r.NoRoute(errors_handler.GetNotFoundError)
|
||||||
|
|
||||||
|
backend_address := net.JoinHostPort(cfg.Backend.Host, cfg.Backend.Port)
|
||||||
|
log.Printf("[I] backend server is running on: http://%s", backend_address)
|
||||||
|
|
||||||
|
return utils.ServeHTTP(ctx, backend_address, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EndBackend : closes the database connection.
|
||||||
|
func EndBackend() error {
|
||||||
|
return errors.Join(versions_database.CloseDB(), bulletins_database.CloseDB(), themes_database.CloseDB())
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "autolibrary.top/webserver/backend/database/versions_database"
|
||||||
|
_ "autolibrary.top/webserver/backend/database/bulletins_database"
|
||||||
|
_ "autolibrary.top/webserver/backend/database/themes_database"
|
||||||
|
)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package bulletins_database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"autolibrary.top/webserver/backend/models/bulletins_model"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var DB *gorm.DB // global bulletins database instance
|
||||||
|
|
||||||
|
func InitDB(dsn string) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] unable to connect to bulletins database: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = DB.AutoMigrate(
|
||||||
|
&bulletins_model.Bulletin{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] bulletins database migration failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[I] bulletins database initialization completed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CloseDB() error {
|
||||||
|
sqlDB, err := DB.DB()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] unable to get bulletins database connection: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = sqlDB.Close()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] unable to close bulletins database connection: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[I] bulletins database connection closed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package themes_database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"autolibrary.top/webserver/backend/models/themes_model"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var DB *gorm.DB
|
||||||
|
|
||||||
|
func InitDB(dsn string) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] unable to connect to themes database: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = DB.AutoMigrate(
|
||||||
|
&themes_model.Theme{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] themes database migration failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[I] themes database initialization completed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CloseDB() error {
|
||||||
|
if DB == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := DB.DB()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] unable to get themes database connection: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Close()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] unable to close themes database connection: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[I] themes database connection closed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package versions_database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"autolibrary.top/webserver/backend/models/versions_model"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var DB *gorm.DB // global versions database instance
|
||||||
|
|
||||||
|
func InitDB(dsn string) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] unable to connect to versions database: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = DB.AutoMigrate(
|
||||||
|
&versions_model.DownloadVersion{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] versions database migration failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[I] versions database initialization completed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CloseDB() error {
|
||||||
|
if DB == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := DB.DB()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] unable to get versions database connection: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Close()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] unable to close versions database connection: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[I] versions database connection closed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "autolibrary.top/webserver/backend/handlers/errors_handler"
|
||||||
|
_ "autolibrary.top/webserver/backend/handlers/health_handler"
|
||||||
|
_ "autolibrary.top/webserver/backend/handlers/versions_handler"
|
||||||
|
_ "autolibrary.top/webserver/backend/handlers/bulletins_handler"
|
||||||
|
_ "autolibrary.top/webserver/backend/handlers/themes_handler"
|
||||||
|
)
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
package bulletins_handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"autolibrary.top/webserver/utils"
|
||||||
|
"autolibrary.top/webserver/backend/database/bulletins_database"
|
||||||
|
"autolibrary.top/webserver/backend/models/bulletins_model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
These handlers are used to manage bulletins.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// GET /bulletins?date={yyyy-MM-dd}&time={HH:mm:ss}&range_hour={0<number}
|
||||||
|
//
|
||||||
|
// This handler regard the given 'date' and 'time' in Shanghai(+8:00) time zone.
|
||||||
|
func GetBulletins(c *gin.Context) {
|
||||||
|
dateStr := c.Query("date")
|
||||||
|
timeStr := c.Query("time")
|
||||||
|
rangeHourStr := c.Query("range_hour")
|
||||||
|
|
||||||
|
loc := utils.ShanghaiLocation
|
||||||
|
|
||||||
|
// Default to current date_time and range
|
||||||
|
if dateStr == "" {
|
||||||
|
dateStr = utils.NowInShanghai().Format("2006-01-02")
|
||||||
|
}
|
||||||
|
if timeStr == "" {
|
||||||
|
timeStr = "00:00:00"
|
||||||
|
}
|
||||||
|
if rangeHourStr == "" {
|
||||||
|
rangeHourStr = "24"
|
||||||
|
}
|
||||||
|
|
||||||
|
dateTimeStr := dateStr + " " + timeStr
|
||||||
|
dateTime, err := utils.ParseInShanghai("2006-01-02 15:04:05", dateTimeStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] GET > try to get bulletins, but got invalid (date=%s, time=%s, tz=%s) parameters: %v", dateStr, timeStr, loc.String(), err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"msg": "GET > try to get bulletins, but got invalid parameters: 'date' or 'time'",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rangeHours, err := strconv.Atoi(rangeHourStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] GET > try to get bulletins, but got invalid (range_hour=%s) parameter: %v", rangeHourStr, err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"msg": "GET > try to get bulletins, but got invalid parameter: 'range_hour'",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startOfDateTime := dateTime
|
||||||
|
endOfDateTime := startOfDateTime.Add(time.Duration(rangeHours)*time.Hour)
|
||||||
|
startOfDateTimeStr := startOfDateTime.Format(time.RFC3339)
|
||||||
|
endOfDateTimeStr := endOfDateTime.Format(time.RFC3339)
|
||||||
|
|
||||||
|
var bulletins []bulletins_model.Bulletin
|
||||||
|
result := bulletins_database.DB.Where(
|
||||||
|
"date_time >= ? AND date_time < ? AND is_deleted = ?", startOfDateTime, endOfDateTime, false,
|
||||||
|
).Find(&bulletins)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[E] GET > occur an error when get bulletins from %s to %s: %v", startOfDateTimeStr, endOfDateTimeStr, result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"msg": "GET > occur an error when get bulletins",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var deleteIds []int64
|
||||||
|
result = bulletins_database.DB.Model(bulletins_model.Bulletin{}).Where(
|
||||||
|
"date_time >= ? AND date_time < ? AND is_deleted = ?", startOfDateTime, endOfDateTime, true,
|
||||||
|
).Pluck("id", &deleteIds)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[E] GET > occur an error when get bulletins from %s to %s: %v", startOfDateTimeStr, endOfDateTimeStr, result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"msg": "GET > occur an error when get bulletins",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := gin.H{
|
||||||
|
"bulletins": bulletins,
|
||||||
|
"delete_ids": deleteIds,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[I] GET > get bulletins from %s to %s successfully", startOfDateTimeStr, endOfDateTimeStr)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"msg": "GET > get bulletins successful",
|
||||||
|
"data": data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /bulletins/:id
|
||||||
|
func GetBulletinByID(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] GET > try to get bulletin, but got invalid id parameter: %v", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"msg": "GET > try to get bulletin, but got invalid id",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var bulletin bulletins_model.Bulletin
|
||||||
|
result := bulletins_database.DB.Where("is_deleted = ?", false).First(&bulletin, id)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[E] GET > bulletin (id=%d) can not found: %v", id, result.Error)
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"code": http.StatusNotFound,
|
||||||
|
"msg": "GET > bulletin can not found",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[I] GET > get bulletin (id=%d) successfully", id)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"msg": "GET > get bulletin successful",
|
||||||
|
"data": bulletin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /bulletins
|
||||||
|
func PostBulletin(c *gin.Context) {
|
||||||
|
var bulletin bulletins_model.Bulletin
|
||||||
|
if err := c.ShouldBindJSON(&bulletin); err != nil {
|
||||||
|
log.Printf("[E] POST > try to create bulletin, but got invalid request data: %v", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"msg": "POST > try to create bulletin, but got invalid request data",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bulletin.CreateAt = utils.NowInShanghai()
|
||||||
|
// set default value to make sure the bulletin is not edited and not deleted before
|
||||||
|
// get into the database
|
||||||
|
bulletin.IsEdited = false
|
||||||
|
bulletin.IsDeleted = false
|
||||||
|
result := bulletins_database.DB.Create(&bulletin)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[E] POST > occur an error when create bulletin: %v", result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"msg": "POST > occur an error when create bulletin",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dataTimeStr := bulletin.DateTime.Format(time.RFC3339)
|
||||||
|
|
||||||
|
id := strconv.FormatUint(uint64(bulletin.ID), 10)
|
||||||
|
log.Printf("[I] POST > create bulletin (id=%s, date_time=%s) successful", id, dataTimeStr)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"msg": "POST > create bulletin successful",
|
||||||
|
"data": bulletin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /bulletins/:id
|
||||||
|
func PutBulletinByID(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] PUT > try to update bulletin, but got invalid id parameter: %v", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"msg": "PUT > try to update bulletin, but got invalid id",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateBulletin bulletins_model.Bulletin
|
||||||
|
if err := c.ShouldBindJSON(&updateBulletin); err != nil {
|
||||||
|
log.Printf("[E] PUT > try to update bulletin, but got invalid request data: %v", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"msg": "PUT > try to update bulletin, but got invalid request data",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingBulletin bulletins_model.Bulletin
|
||||||
|
result := bulletins_database.DB.Where("is_deleted = ?", false).First(&existingBulletin, id)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[E] PUT > bulletin (id=%d) can not found: %v", id, result.Error)
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"code": http.StatusNotFound,
|
||||||
|
"msg": "PUT > bulletin can not found",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep the original date time
|
||||||
|
updateBulletin.DateTime = existingBulletin.DateTime
|
||||||
|
// set last edit time to current time
|
||||||
|
updateBulletin.LastEditAt = utils.NowInShanghai()
|
||||||
|
// set is edited to true
|
||||||
|
updateBulletin.IsEdited = true
|
||||||
|
|
||||||
|
result = bulletins_database.DB.Model(&existingBulletin).Where("id = ?", id).Updates(updateBulletin)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[E] PUT > occur an error when update bulletin (id=%d): %v", id, result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"msg": "PUT > occur an error when update bulletin",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// post check after update
|
||||||
|
var updatedBulletin bulletins_model.Bulletin
|
||||||
|
if result = bulletins_database.DB.Where("is_deleted = ?", false).First(&updatedBulletin, id); result.Error == nil {
|
||||||
|
log.Printf("[I] PUT > update bulletin (id=%d) successful", id)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"msg": "PUT > update bulletin successful",
|
||||||
|
"data": updatedBulletin,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
log.Printf("[E] PUT > update successful but failed to retrieve updated bulletin (id=%d): %v", id, result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"msg": "PUT > update successful but failed to retrieve updated bulletin",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /bulletins/:id
|
||||||
|
func DeleteBulletinByID(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] DELETE > try to delete bulletin, but got invalid id: %v", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"msg": "DELETE > try to delete bulletin, but got invalid id",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// soft delete, actually is a update operation
|
||||||
|
var existingBulletin bulletins_model.Bulletin
|
||||||
|
result := bulletins_database.DB.Where("is_deleted = ?", false).First(&existingBulletin, id)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[E] DELETE > bulletin (id=%d) can not found: %v", id, result.Error)
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"code": http.StatusNotFound,
|
||||||
|
"msg": "DELETE > bulletin can not found",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// set is_deleted to true
|
||||||
|
existingBulletin.IsDeleted = true
|
||||||
|
|
||||||
|
result = bulletins_database.DB.Model(&existingBulletin).Where("id = ?", id).Updates(existingBulletin)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[E] DELETE > occur an error when soft delete bulletin (id=%d): %v", id, result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"msg": "DELETE > occur an error when soft delete bulletin",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// post check after soft delete
|
||||||
|
var deletedBulletin bulletins_model.Bulletin
|
||||||
|
if result = bulletins_database.DB.Where("is_deleted = ?", true).First(&deletedBulletin, id); result.Error == nil {
|
||||||
|
log.Printf("[I] DELETE > soft delete bulletin (id=%d) successful", id)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"msg": "DELETE > soft delete bulletin successful",
|
||||||
|
"data": deletedBulletin,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
log.Printf("[E] DELETE > soft delete successful but failed to retrieve deleted bulletin (id=%d): %v", id, result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"msg": "DELETE > soft delete successful but failed to retrieve deleted bulletin",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package errors_handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 404 - Not Found
|
||||||
|
func GetNotFoundError(c *gin.Context) {
|
||||||
|
errorData := gin.H{
|
||||||
|
"code": http.StatusNotFound,
|
||||||
|
"msg": c.Request.Method + " > the requested API endpoint was not found",
|
||||||
|
"data": gin.H{
|
||||||
|
"path": c.Request.URL.Path,
|
||||||
|
"method": c.Request.Method,
|
||||||
|
"endpoints": gin.H{
|
||||||
|
"health": "/health",
|
||||||
|
"downloads": "/download/versions",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
acceptHeader := c.GetHeader("Accept")
|
||||||
|
if strings.Contains(acceptHeader, "text/html") {
|
||||||
|
// for browser requests, return HTML
|
||||||
|
jsonBytes, err := json.Marshal(errorData)
|
||||||
|
if err != nil {
|
||||||
|
jsonBytes = []byte(`{"error": "json_serialization_failed"}`)
|
||||||
|
}
|
||||||
|
jsonStr := string(jsonBytes)
|
||||||
|
c.HTML(http.StatusNotFound, "404.html", gin.H{
|
||||||
|
"title": "404 - Not Found",
|
||||||
|
"path": c.Request.URL.Path,
|
||||||
|
"method": c.Request.Method,
|
||||||
|
"baseURL": c.Request.Host,
|
||||||
|
"jsonData": jsonStr,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// for General requests (no text/html in Accept header), return JSON
|
||||||
|
c.JSON(http.StatusNotFound, errorData)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package health_handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /health
|
||||||
|
func GetHealth(c *gin.Context) {
|
||||||
|
log.Printf("[I] HEALTH > get backend server health")
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"msg": "GET > backend server is running ok",
|
||||||
|
"data": gin.H{
|
||||||
|
"timestamp": time.Now().Unix(),
|
||||||
|
"status": "healthy",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package themes_handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dchest/captcha"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) GetUserRole(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
userName, _ := c.Get("user_name")
|
||||||
|
displayName, _ := c.Get("display_name")
|
||||||
|
isAdmin, _ := c.Get("is_admin")
|
||||||
|
userTag, _ := c.Get("user_tag")
|
||||||
|
|
||||||
|
isAdminBool := false
|
||||||
|
if isAdmin != nil {
|
||||||
|
isAdminBool = isAdmin.(bool)
|
||||||
|
}
|
||||||
|
displayNameStr := ""
|
||||||
|
if displayName != nil {
|
||||||
|
displayNameStr = displayName.(string)
|
||||||
|
}
|
||||||
|
userTagStr := ""
|
||||||
|
if userTag != nil {
|
||||||
|
userTagStr = userTag.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"msg": "GET > user role",
|
||||||
|
"data": gin.H{
|
||||||
|
"user_id": userID,
|
||||||
|
"user_name": userName,
|
||||||
|
"display_name": displayNameStr,
|
||||||
|
"is_admin": isAdminBool,
|
||||||
|
"is_verified": isAdminBool || userTagStr == "verified",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/user/downloads (stretch goal)
|
||||||
|
|
||||||
|
func (h *Handler) GetUserDownloads(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"msg": "GET > download history not yet implemented",
|
||||||
|
"data": []interface{}{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/marketplace/auth/captcha
|
||||||
|
|
||||||
|
func (h *Handler) GetCaptcha(c *gin.Context) {
|
||||||
|
id := captcha.NewLen(4)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := captcha.WriteImage(&buf, id, 150, 50); err != nil {
|
||||||
|
log.Printf("[E] CAPTCHA > failed to generate: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": http.StatusInternalServerError, "msg": "captcha generation failed", "data": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"msg": "GET > captcha",
|
||||||
|
"data": gin.H{
|
||||||
|
"captchaId": id,
|
||||||
|
"captchaImage": "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/marketplace/auth/config
|
||||||
|
|
||||||
|
func (h *Handler) GetAuthConfig(c *gin.Context) {
|
||||||
|
cfg := h.Config
|
||||||
|
casdoorConfigured := cfg.Backend.Casdoor.Endpoint != "" && cfg.Backend.Casdoor.ClientID != ""
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"msg": "GET > auth config",
|
||||||
|
"data": gin.H{
|
||||||
|
"casdoor_configured": casdoorConfigured,
|
||||||
|
"casdoor_endpoint": cfg.Backend.Casdoor.Endpoint,
|
||||||
|
"casdoor_client_id": cfg.Backend.Casdoor.ClientID,
|
||||||
|
"casdoor_organization": cfg.Backend.Casdoor.Organization,
|
||||||
|
"casdoor_application": cfg.Backend.Casdoor.Application,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/marketplace/auth/login
|
||||||
|
|
||||||
|
func (h *Handler) LoginProxy(c *gin.Context) {
|
||||||
|
cfg := h.Config
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": http.StatusBadRequest, "msg": "PUT > invalid request body", "data": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy to Casdoor OAuth token endpoint
|
||||||
|
tokenURL := cfg.Backend.Casdoor.Endpoint + "/api/login/oauth/access_token"
|
||||||
|
values := url.Values{
|
||||||
|
"grant_type": {"password"},
|
||||||
|
"client_id": {cfg.Backend.Casdoor.ClientID},
|
||||||
|
"client_secret": {cfg.Backend.Casdoor.ClientSecret},
|
||||||
|
"username": {req.Username},
|
||||||
|
"password": {req.Password},
|
||||||
|
"scope": {"read"},
|
||||||
|
}
|
||||||
|
reqHTTP, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, tokenURL, strings.NewReader(values.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusBadGateway, "Invalid authentication server configuration")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reqHTTP.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
resp, err := h.HTTP.Do(reqHTTP)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] AUTH > login proxy error: %v", err)
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"code": http.StatusBadGateway, "msg": "auth server unreachable", "data": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||||
|
if readErr != nil {
|
||||||
|
fail(c, http.StatusBadGateway, "Unable to read authentication response")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"code": http.StatusUnauthorized, "msg": "invalid credentials", "data": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "application/json")
|
||||||
|
c.String(http.StatusOK, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/marketplace/auth/signup
|
||||||
|
|
||||||
|
func (h *Handler) SignupProxy(c *gin.Context) {
|
||||||
|
cfg := h.Config
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CaptchaId string `json:"captchaId"`
|
||||||
|
CaptchaCode string `json:"captchaCode"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": http.StatusBadRequest, "msg": "PUT > invalid request body", "data": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Username == "" || req.Password == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": http.StatusBadRequest, "msg": "用户名和密码为必填项", "data": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate captcha
|
||||||
|
if !captcha.VerifyString(req.CaptchaId, req.CaptchaCode) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": http.StatusBadRequest, "msg": "验证码错误或已过期", "data": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy to Casdoor signup API
|
||||||
|
signupData := map[string]interface{}{
|
||||||
|
"organization": cfg.Backend.Casdoor.Organization,
|
||||||
|
"username": req.Username,
|
||||||
|
"password": req.Password,
|
||||||
|
"application": cfg.Backend.Casdoor.Application,
|
||||||
|
}
|
||||||
|
if req.Email != "" {
|
||||||
|
signupData["email"] = req.Email
|
||||||
|
}
|
||||||
|
if req.Name != "" {
|
||||||
|
signupData["name"] = req.Name
|
||||||
|
}
|
||||||
|
signupURL := cfg.Backend.Casdoor.Endpoint + "/api/signup"
|
||||||
|
signupBody, _ := json.Marshal(signupData)
|
||||||
|
reqHTTP, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, signupURL, bytes.NewReader(signupBody))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusBadGateway, "Invalid authentication server configuration")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reqHTTP.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := h.HTTP.Do(reqHTTP)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] AUTH > signup proxy error: %v", err)
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"code": http.StatusBadGateway, "msg": "auth server unreachable", "data": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
json.Unmarshal(body, &result)
|
||||||
|
|
||||||
|
if status, ok := result["status"]; !ok || status != "ok" {
|
||||||
|
msg := "注册失败"
|
||||||
|
if m, ok := result["msg"]; ok {
|
||||||
|
msg = fmt.Sprintf("%v", m)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": http.StatusBadRequest, "msg": msg, "data": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[I] AUTH > signup successful for user: %s", req.Username)
|
||||||
|
c.Header("Content-Type", "application/json")
|
||||||
|
c.String(http.StatusOK, string(body))
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package themes_handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"autolibrary.top/webserver/backend/services/theme_files"
|
||||||
|
"autolibrary.top/webserver/backend/services/themes_service"
|
||||||
|
"autolibrary.top/webserver/backend/services/users_service"
|
||||||
|
"autolibrary.top/webserver/config"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler shares explicit dependencies across the marketplace HTTP handlers.
|
||||||
|
// A separate instance can be constructed for each database and storage root.
|
||||||
|
type Handler struct {
|
||||||
|
DB *gorm.DB
|
||||||
|
Config *config.Config
|
||||||
|
Files *theme_files.Store
|
||||||
|
Users *users_service.Cache
|
||||||
|
HTTP *http.Client
|
||||||
|
Themes *themes_service.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *gorm.DB, cfg *config.Config, files *theme_files.Store, users *users_service.Cache) *Handler {
|
||||||
|
return &Handler{DB: db, Config: cfg, Files: files, Users: users, Themes: &themes_service.Service{DB: db, Files: files}, HTTP: &http.Client{Timeout: 15 * time.Second}}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package themes_handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"autolibrary.top/webserver/backend/models/themes_model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) DownloadTheme(c *gin.Context) { h.serveTheme(c, true) }
|
||||||
|
func (h *Handler) AdminDownloadTheme(c *gin.Context) { h.serveTheme(c, false) }
|
||||||
|
|
||||||
|
func (h *Handler) serveTheme(c *gin.Context, public bool) {
|
||||||
|
id, err := parseThemeID(c)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "Invalid theme ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var theme themes_model.Theme
|
||||||
|
query := h.DB
|
||||||
|
if public {
|
||||||
|
query = query.Where("status = ?", themes_model.ThemeApproved)
|
||||||
|
}
|
||||||
|
err = query.First(&theme, id).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
fail(c, 404, "Theme not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fail(c, 500, "Unable to load theme")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := h.Themes.ArchivePath(theme.ID)
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
fail(c, 404, "Theme file not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if public {
|
||||||
|
h.DB.Model(&theme).UpdateColumn("download_count", gorm.Expr("download_count + 1"))
|
||||||
|
}
|
||||||
|
version := strings.TrimLeft(theme.Version, "vV")
|
||||||
|
name := fmt.Sprintf("%s_v%s.altheme", theme.Name, version)
|
||||||
|
c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name}))
|
||||||
|
c.Header("Content-Type", "application/zip")
|
||||||
|
c.File(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ServePreview(c *gin.Context) {
|
||||||
|
name := c.Param("filename")
|
||||||
|
if name == "." || name == ".." || strings.ContainsAny(name, `/\`) || filepath.Base(name) != name {
|
||||||
|
fail(c, 400, "Invalid preview filename")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := filepath.Join(h.Files.Root(), "previews", name)
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
fail(c, 404, "Preview not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Cache-Control", "public, max-age=3600")
|
||||||
|
c.File(path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package themes_handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"autolibrary.top/webserver/backend/models/themes_model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) GetFeaturedThemes(c *gin.Context) {
|
||||||
|
limitStr := c.Query("limit")
|
||||||
|
limit := 6
|
||||||
|
if limitStr != "" {
|
||||||
|
if v, err := strconv.Atoi(limitStr); err == nil && v > 0 && v <= 20 {
|
||||||
|
limit = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var themes []themes_model.Theme
|
||||||
|
result := h.DB.
|
||||||
|
Where("status = ?", themes_model.ThemeApproved).
|
||||||
|
Order("download_count DESC").
|
||||||
|
Limit(limit).
|
||||||
|
Find(&themes)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[E] GET > occur an error when get featured themes: %v", result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"msg": "GET > occur an error when get featured themes",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range themes {
|
||||||
|
themes[i].Author = h.Users.ResolveAuthor(themes[i].UserID, themes[i].Author)
|
||||||
|
themes[i].Verified = h.Users.IsVerified(themes[i].UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[I] GET > get featured themes (limit=%d) successful", limit)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"msg": "GET > get featured themes successful",
|
||||||
|
"data": themes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/themes?page=1&per_page=9&need_theme=&tags=&search=&sort=popular
|
||||||
|
|
||||||
|
func (h *Handler) GetThemes(c *gin.Context) {
|
||||||
|
pageStr := c.Query("page")
|
||||||
|
perPageStr := c.Query("per_page")
|
||||||
|
needTheme := c.Query("need_theme")
|
||||||
|
tagsStr := c.Query("tags")
|
||||||
|
search := c.Query("search")
|
||||||
|
sort := c.Query("sort")
|
||||||
|
|
||||||
|
if pageStr == "" {
|
||||||
|
pageStr = "1"
|
||||||
|
}
|
||||||
|
if perPageStr == "" {
|
||||||
|
perPageStr = "9"
|
||||||
|
}
|
||||||
|
if sort == "" {
|
||||||
|
sort = "popular"
|
||||||
|
}
|
||||||
|
|
||||||
|
page, err := strconv.Atoi(pageStr)
|
||||||
|
if err != nil || page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
perPage, err := strconv.Atoi(perPageStr)
|
||||||
|
if err != nil || perPage <= 0 || perPage > 50 {
|
||||||
|
perPage = 9
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (page - 1) * perPage
|
||||||
|
|
||||||
|
query := h.DB.Where("status = ?", themes_model.ThemeApproved)
|
||||||
|
|
||||||
|
if needTheme != "" {
|
||||||
|
if needTheme == "both" {
|
||||||
|
query = query.Where("need_theme = ?", "both")
|
||||||
|
} else if needTheme == "dark" || needTheme == "light" {
|
||||||
|
query = query.Where("need_theme = ? OR need_theme = ?", needTheme, "both")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tagsStr != "" {
|
||||||
|
tags := strings.Split(tagsStr, ",")
|
||||||
|
for _, tag := range tags {
|
||||||
|
tag = strings.TrimSpace(tag)
|
||||||
|
if tag != "" {
|
||||||
|
query = query.Where("tags LIKE ?", fmt.Sprintf("%%%s%%", tag))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if search != "" {
|
||||||
|
like := fmt.Sprintf("%%%s%%", search)
|
||||||
|
query = query.Where("name LIKE ? OR brief LIKE ? OR author LIKE ?", like, like, like)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch sort {
|
||||||
|
case "newest":
|
||||||
|
query = query.Order("created_at DESC")
|
||||||
|
case "name":
|
||||||
|
query = query.Order("name ASC")
|
||||||
|
default: // popular
|
||||||
|
query = query.Order("download_count DESC")
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if result := query.Model(&themes_model.Theme{}).Count(&total); result.Error != nil {
|
||||||
|
log.Printf("[E] GET > occur an error when count themes: %v", result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"msg": "GET > occur an error when get themes",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var themes []themes_model.Theme
|
||||||
|
result := query.Limit(perPage).Offset(offset).Find(&themes)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[E] GET > occur an error when get themes (page=%d, per_page=%d): %v", page, perPage, result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"msg": "GET > occur an error when get themes",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range themes {
|
||||||
|
themes[i].Author = h.Users.ResolveAuthor(themes[i].UserID, themes[i].Author)
|
||||||
|
themes[i].Verified = h.Users.IsVerified(themes[i].UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPages := int(total) / perPage
|
||||||
|
if int(total)%perPage != 0 {
|
||||||
|
totalPages++
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[I] GET > get themes (page=%d, per_page=%d, total=%d) successful", page, perPage, total)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"msg": "GET > get themes successful",
|
||||||
|
"data": gin.H{
|
||||||
|
"themes": themes,
|
||||||
|
"meta": gin.H{
|
||||||
|
"page": page,
|
||||||
|
"per_page": perPage,
|
||||||
|
"total": total,
|
||||||
|
"total_pages": totalPages,
|
||||||
|
"has_next": page < totalPages,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/themes/:id
|
||||||
|
|
||||||
|
func (h *Handler) GetThemeByID(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] GET > try to get theme, but got invalid id: '%s'", c.Param("id"))
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"msg": "GET > invalid id",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var theme themes_model.Theme
|
||||||
|
result := h.DB.Where("status = ?", themes_model.ThemeApproved).First(&theme, id)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[E] GET > theme (id=%d) can not found: %v", id, result.Error)
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"code": http.StatusNotFound,
|
||||||
|
"msg": "GET > theme not found",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
theme.Author = h.Users.ResolveAuthor(theme.UserID, theme.Author)
|
||||||
|
theme.Verified = h.Users.IsVerified(theme.UserID)
|
||||||
|
|
||||||
|
log.Printf("[I] GET > get theme (id=%d) successful", id)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"msg": "GET > get theme successful",
|
||||||
|
"data": theme,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/themes/:id/download
|
||||||
|
|
||||||
|
func (h *Handler) GetUserThemes(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
|
||||||
|
var themes []themes_model.Theme
|
||||||
|
result := h.DB.
|
||||||
|
Where("user_id = ?", userID.(string)).
|
||||||
|
Order("created_at DESC").
|
||||||
|
Find(&themes)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[E] GET > occur an error when get user themes: %v", result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": http.StatusInternalServerError, "msg": "获取用户主题失败", "data": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range themes {
|
||||||
|
themes[i].Author = h.Users.ResolveAuthor(themes[i].UserID, themes[i].Author)
|
||||||
|
themes[i].Verified = h.Users.IsVerified(themes[i].UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[I] GET > get user themes (user_id=%s, count=%d) successful", userID.(string), len(themes))
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "msg": "GET > get user themes successful", "data": themes})
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package themes_handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"autolibrary.top/webserver/backend/models/themes_model"
|
||||||
|
"autolibrary.top/webserver/utils"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) GetPendingThemes(c *gin.Context) {
|
||||||
|
var themes []themes_model.Theme
|
||||||
|
result := h.DB.
|
||||||
|
Where("status = ?", themes_model.ThemePending).
|
||||||
|
Order("created_at ASC").
|
||||||
|
Find(&themes)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[E] GET > occur an error when get pending themes: %v", result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"msg": "GET > occur an error when get pending themes",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range themes {
|
||||||
|
themes[i].Author = h.Users.ResolveAuthor(themes[i].UserID, themes[i].Author)
|
||||||
|
themes[i].Verified = h.Users.IsVerified(themes[i].UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[I] GET > get pending themes (count=%d) successful", len(themes))
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"msg": "GET > get pending themes successful",
|
||||||
|
"data": themes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/admin/themes/:id/review
|
||||||
|
|
||||||
|
func (h *Handler) ReviewTheme(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] PUT > try to review theme, but got invalid id: '%s'", c.Param("id"))
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"msg": "GET > invalid id",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
log.Printf("[E] PUT > try to review theme (id=%d), but got invalid request data: %v", id, err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"msg": "PUT > invalid request body",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.Action != "approved" && body.Action != "rejected" {
|
||||||
|
log.Printf("[E] PUT > try to review theme (id=%d), but got invalid action: '%s'", id, body.Action)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"msg": "操作必须为通过或退回",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var theme themes_model.Theme
|
||||||
|
result := h.DB.First(&theme, id)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[E] PUT > theme (id=%d) can not found for review: %v", id, result.Error)
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"code": http.StatusNotFound,
|
||||||
|
"msg": "GET > theme not found",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
theme.Status = themes_model.ThemeStatus(body.Action)
|
||||||
|
theme.ReviewComment = body.Comment
|
||||||
|
theme.ReviewedBy = c.GetString("user_name")
|
||||||
|
if theme.ReviewedBy == "" {
|
||||||
|
theme.ReviewedBy = "api-key"
|
||||||
|
}
|
||||||
|
theme.UpdatedAt = utils.NowInShanghai()
|
||||||
|
|
||||||
|
result = h.DB.Save(&theme)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[E] PUT > occur an error when review theme (id=%d): %v", id, result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"msg": "审核操作失败",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
theme.Author = h.Users.ResolveAuthor(theme.UserID, theme.Author)
|
||||||
|
theme.Verified = h.Users.IsVerified(theme.UserID)
|
||||||
|
|
||||||
|
log.Printf("[I] PUT > review theme (id=%d, action=%s) successful", id, body.Action)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"msg": "PUT > review theme successful",
|
||||||
|
"data": theme,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package themes_handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"autolibrary.top/webserver/backend/models/themes_model"
|
||||||
|
"autolibrary.top/webserver/utils"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseThemeID(c *gin.Context) (uint, error) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return 0, strconv.ErrSyntax
|
||||||
|
}
|
||||||
|
return uint(id), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) PutThemeByID(c *gin.Context) {
|
||||||
|
theme, ok := h.ownTheme(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var update struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Brief string `json:"brief"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
NeedTheme string `json:"need_theme"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
CompatVersion string `json:"compat_version"`
|
||||||
|
PreviewURL string `json:"preview_url"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&update); err != nil {
|
||||||
|
fail(c, 400, "Invalid theme metadata")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if update.Name != "" && strings.TrimSpace(update.Name) != theme.Name {
|
||||||
|
var count int64
|
||||||
|
if err := h.DB.Model(&themes_model.Theme{}).Where("user_id = ? AND name = ? AND id != ?", theme.UserID, strings.TrimSpace(update.Name), theme.ID).Count(&count).Error; err != nil {
|
||||||
|
fail(c, 500, "Unable to check theme name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
fail(c, 409, "You already have a theme with this name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
theme.Name = strings.TrimSpace(update.Name)
|
||||||
|
}
|
||||||
|
if update.NeedTheme != "" && update.NeedTheme != "dark" && update.NeedTheme != "light" && update.NeedTheme != "both" {
|
||||||
|
fail(c, 400, "Invalid theme mode")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(update.Brief) > 50 || utf8.RuneCountInString(update.Description) > 100 {
|
||||||
|
fail(c, 400, "Theme description is too long")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(update.Brief) != "" {
|
||||||
|
theme.Brief = strings.TrimSpace(update.Brief)
|
||||||
|
}
|
||||||
|
theme.Description = update.Description
|
||||||
|
if update.NeedTheme != "" {
|
||||||
|
theme.NeedTheme = update.NeedTheme
|
||||||
|
}
|
||||||
|
if update.Tags != nil {
|
||||||
|
theme.Tags = update.Tags
|
||||||
|
}
|
||||||
|
if update.Version != "" {
|
||||||
|
theme.Version = update.Version
|
||||||
|
}
|
||||||
|
theme.CompatVersion = update.CompatVersion
|
||||||
|
// Preview changes go through the authenticated image upload endpoint. Preserve
|
||||||
|
// the legacy field only when it refers to the theme's existing preview.
|
||||||
|
if update.PreviewURL != "" && update.PreviewURL != theme.PreviewURL {
|
||||||
|
fail(c, 400, "Use the preview upload endpoint to replace an image")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
theme.Status = themes_model.ThemePending
|
||||||
|
theme.UpdatedAt = utils.NowInShanghai()
|
||||||
|
if err := h.Themes.Save(theme, nil); err != nil {
|
||||||
|
fail(c, 500, "Unable to save theme and archive")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, 200, "Theme updated; review required", theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteThemeByID(c *gin.Context) {
|
||||||
|
theme, ok := h.ownTheme(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Themes.Delete(theme); err != nil {
|
||||||
|
fail(c, 500, "Unable to delete theme")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, 200, "Theme deleted", nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package themes_handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"autolibrary.top/webserver/backend/models/themes_model"
|
||||||
|
"autolibrary.top/webserver/backend/services/theme_files"
|
||||||
|
"autolibrary.top/webserver/utils"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fail(c *gin.Context, status int, message string) {
|
||||||
|
c.JSON(status, gin.H{"code": status, "msg": message, "data": nil})
|
||||||
|
}
|
||||||
|
func success(c *gin.Context, status int, message string, data interface{}) {
|
||||||
|
c.JSON(status, gin.H{"code": status, "msg": message, "data": data})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ownTheme(c *gin.Context) (*themes_model.Theme, bool) {
|
||||||
|
var theme themes_model.Theme
|
||||||
|
id, err := parseThemeID(c)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "Invalid theme ID")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
err = h.DB.First(&theme, id).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
fail(c, http.StatusNotFound, "Theme not found")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "Unable to load theme")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if theme.UserID != c.GetString("user_id") {
|
||||||
|
fail(c, http.StatusForbidden, "You can only edit your own themes")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &theme, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// readUpload enforces the same limits advertised by the web form.
|
||||||
|
func readUpload(c *gin.Context, field string, extensions []string, max int64) ([]byte, error) {
|
||||||
|
file, header, err := c.Request.FormFile(field)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||||
|
valid := false
|
||||||
|
for _, candidate := range extensions {
|
||||||
|
if ext == candidate {
|
||||||
|
valid = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return nil, fmt.Errorf("invalid %s file type", field)
|
||||||
|
}
|
||||||
|
if header.Size > max {
|
||||||
|
return nil, fmt.Errorf("%s file is too large", field)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(file, max+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if int64(len(data)) > max {
|
||||||
|
return nil, fmt.Errorf("%s file is too large", field)
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil, fmt.Errorf("%s file is empty", field)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) savePreview(data []byte) (string, error) {
|
||||||
|
compressed, err := theme_files.CompressPreviewImage(data)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
name := fmt.Sprintf("preview_%d.jpg", utils.NowInShanghai().UnixNano())
|
||||||
|
if err := h.Files.WriteFile(filepath.Join(h.Files.Root(), "previews", name), compressed, 0644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "/marketplace/themes/preview/" + name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) PostTheme(c *gin.Context) {
|
||||||
|
var metadata struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Brief string `json:"brief"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
NeedTheme string `json:"need_theme"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
CompatVersion string `json:"compat_version"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(c.PostForm("metadata")), &metadata); err != nil {
|
||||||
|
fail(c, 400, "Invalid theme metadata")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
metadata.Name = strings.TrimSpace(metadata.Name)
|
||||||
|
metadata.Brief = strings.TrimSpace(metadata.Brief)
|
||||||
|
if metadata.Name == "" || metadata.Brief == "" || utf8.RuneCountInString(metadata.Brief) > 50 || utf8.RuneCountInString(metadata.Description) > 100 {
|
||||||
|
fail(c, 400, "Invalid name, brief or description")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if metadata.NeedTheme != "dark" && metadata.NeedTheme != "light" && metadata.NeedTheme != "both" {
|
||||||
|
fail(c, 400, "Invalid theme mode")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := h.DB.Model(&themes_model.Theme{}).Where("user_id = ? AND name = ?", c.GetString("user_id"), metadata.Name).Count(&count).Error; err != nil {
|
||||||
|
fail(c, 500, "Unable to check theme name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
fail(c, 409, "You already have a theme with this name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
qss, err := readUpload(c, "file", []string{".qss"}, 8<<20)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previewData, err := readUpload(c, "preview", []string{".png", ".jpg", ".jpeg"}, 4<<20)
|
||||||
|
if err != nil && !errors.Is(err, http.ErrMissingFile) {
|
||||||
|
fail(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var preview string
|
||||||
|
if len(previewData) > 0 {
|
||||||
|
preview, err = h.savePreview(previewData)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, 400, "Unable to process preview image")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
author := c.GetString("display_name")
|
||||||
|
if author == "" {
|
||||||
|
author = c.GetString("user_name")
|
||||||
|
}
|
||||||
|
theme := themes_model.Theme{Name: metadata.Name, Brief: metadata.Brief, Description: metadata.Description, NeedTheme: metadata.NeedTheme, Tags: metadata.Tags, Version: metadata.Version, CompatVersion: metadata.CompatVersion, UserID: c.GetString("user_id"), Author: author, Status: themes_model.ThemePending, PreviewURL: preview, CreatedAt: utils.NowInShanghai(), UpdatedAt: utils.NowInShanghai()}
|
||||||
|
if err := h.Themes.Create(&theme, qss); err != nil {
|
||||||
|
if preview != "" {
|
||||||
|
_ = h.Files.Remove(filepath.Join(h.Files.Root(), "previews", filepath.Base(preview)))
|
||||||
|
}
|
||||||
|
fail(c, 500, "Unable to save theme and archive")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, http.StatusCreated, "Theme submitted for review", theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CheckThemeName(c *gin.Context) {
|
||||||
|
name := strings.TrimSpace(c.Query("name"))
|
||||||
|
if name == "" {
|
||||||
|
fail(c, 400, "Name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := h.DB.Model(&themes_model.Theme{}).Where("user_id = ? AND name = ?", c.GetString("user_id"), name).Count(&count).Error; err != nil {
|
||||||
|
fail(c, 500, "Unable to check theme name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, 200, "Theme name checked", count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UploadPreview(c *gin.Context) {
|
||||||
|
theme, ok := h.ownTheme(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := readUpload(c, "preview", []string{".png", ".jpg", ".jpeg"}, 4<<20)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
preview, err := h.savePreview(data)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, 400, "Unable to process preview image")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previous := theme.PreviewURL
|
||||||
|
theme.PreviewURL = preview
|
||||||
|
theme.Status = themes_model.ThemePending
|
||||||
|
theme.UpdatedAt = utils.NowInShanghai()
|
||||||
|
if err := h.Themes.Save(theme, nil); err != nil {
|
||||||
|
_ = h.Files.Remove(filepath.Join(h.Files.Root(), "previews", filepath.Base(preview)))
|
||||||
|
fail(c, 500, "Unable to save preview")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if previous != "" {
|
||||||
|
_ = h.Files.Remove(filepath.Join(h.Files.Root(), "previews", filepath.Base(previous)))
|
||||||
|
}
|
||||||
|
success(c, 200, "Preview updated; review required", gin.H{"preview_url": preview})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ReplaceThemeFile(c *gin.Context) {
|
||||||
|
theme, ok := h.ownTheme(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
qss, err := readUpload(c, "file", []string{".qss"}, 8<<20)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
theme.Status = themes_model.ThemePending
|
||||||
|
theme.UpdatedAt = utils.NowInShanghai()
|
||||||
|
if err := h.Themes.Save(theme, qss); err != nil {
|
||||||
|
fail(c, 500, "Unable to replace theme file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success(c, 200, "File replaced; review required", theme)
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package versions_handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"autolibrary.top/webserver/backend/database/versions_database"
|
||||||
|
"autolibrary.top/webserver/backend/models/versions_model"
|
||||||
|
"autolibrary.top/webserver/utils"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
These handlers are used to manage download versions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// GetLatestDownload returns the newest published stable release, or null.
|
||||||
|
func GetLatestDownload(c *gin.Context) {
|
||||||
|
var version versions_model.DownloadVersion
|
||||||
|
err := versions_database.DB.Where("type = ? AND release_date <= ?", "stable", utils.NowInShanghai()).Order("release_date DESC, id DESC").First(&version).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "msg": "No stable release available", "data": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": http.StatusInternalServerError, "msg": "Unable to load latest release", "data": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "msg": "Latest stable release", "data": version})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /download/versions?page={number}&per_page={number<=50}
|
||||||
|
func GetDownloadVersions(c *gin.Context) {
|
||||||
|
// Parse page and perPage parameters
|
||||||
|
pageStr := c.Query("page")
|
||||||
|
perPageStr := c.Query("per_page")
|
||||||
|
|
||||||
|
// Default values for page and perPage
|
||||||
|
if pageStr == "" {
|
||||||
|
pageStr = "1"
|
||||||
|
}
|
||||||
|
if perPageStr == "" {
|
||||||
|
perPageStr = "9"
|
||||||
|
}
|
||||||
|
|
||||||
|
page, err := strconv.Atoi(pageStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] GET > try to get downloads, but got invalid parameter: 'page=%s'", pageStr)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"msg": "GET > try to get downloads, but got invalid parameter: 'page'",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
perPage, err := strconv.Atoi(perPageStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] GET > try to get downloads (page=%d), but got invalid parameter: 'per_page=%s'", page, perPageStr)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"msg": "GET > try to get downloads, but got invalid parameter: 'per_page'",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// constrain page and perPage parameters
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if perPage <= 0 || perPage > 50 {
|
||||||
|
perPage = 9
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate offset
|
||||||
|
offset := (page - 1) * perPage
|
||||||
|
|
||||||
|
var downloads []versions_model.DownloadVersion
|
||||||
|
result := versions_database.DB.Order("id DESC").Limit(perPage).Offset(offset).Find(&downloads)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[E] GET > occur an error when get downloads (page=%d, per_page=%d): %v", page, perPage, result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"msg": "GET > occur an error when get downloads",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
result = versions_database.DB.Model(&versions_model.DownloadVersion{}).Count(&total)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[E] GET > occur an error when get downloads (page=%d, per_page=%d): %v", page, perPage, result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"msg": "GET > occur an error when get downloads",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
totalPages := int(total) / perPage
|
||||||
|
if int(total)%perPage != 0 {
|
||||||
|
totalPages++
|
||||||
|
}
|
||||||
|
pageMeta := gin.H{
|
||||||
|
"page": page,
|
||||||
|
"per_page": perPage,
|
||||||
|
"total": total,
|
||||||
|
"total_pages": totalPages,
|
||||||
|
"has_next": page < totalPages,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[I] GET > get downloads (page=%d, per_page=%d) successful", page, perPage)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"msg": "GET > get downloads successful",
|
||||||
|
"data": gin.H{
|
||||||
|
"downloads": downloads,
|
||||||
|
"meta": pageMeta,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /download/versions/:id
|
||||||
|
func GetDownloadVersionByID(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] GET > try to get download, but got invalid id: '%s'", c.Param("id"))
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"msg": "GET > try to get download, but got invalid id",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var download versions_model.DownloadVersion
|
||||||
|
result := versions_database.DB.First(&download, id)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[E] GET > download (id=%d) can not found: %v", id, result.Error)
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"code": http.StatusNotFound,
|
||||||
|
"msg": "GET > download can not found",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[I] GET > get download (id=%d) successful", id)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"msg": "GET > get download successful",
|
||||||
|
"data": download,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /download/versions
|
||||||
|
func PostDownloadVersion(c *gin.Context) {
|
||||||
|
var download versions_model.DownloadVersion
|
||||||
|
if err := c.ShouldBindJSON(&download); err != nil {
|
||||||
|
log.Printf("[E] POST > try to create download, but got invalid request data: %v", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"msg": "POST > try to create download, but got invalid request data",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
download.CreatedAt = utils.NowInShanghai()
|
||||||
|
result := versions_database.DB.Create(&download)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[E] POST > occur an error when create download: %v", result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"msg": "POST > occur an error when create download",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := strconv.FormatUint(uint64(download.ID), 10)
|
||||||
|
log.Printf("[I] POST > create download (id=%s) successful", id)
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"code": http.StatusCreated,
|
||||||
|
"msg": "POST > create download successful",
|
||||||
|
"data": download,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /download/versions/:id
|
||||||
|
func PutDownloadVersionByID(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] PUT > try to update download, but got invalid id: '%s'", c.Param("id"))
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"msg": "PUT > try to update download, but got invalid id",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateDownload versions_model.DownloadVersion
|
||||||
|
if err = c.ShouldBindJSON(&updateDownload); err != nil {
|
||||||
|
log.Printf("[E] PUT > try to update download (id=%d), but got invalid request data: %v", id, err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"msg": "PUT > try to update download, but got invalid request data",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingDownload versions_model.DownloadVersion
|
||||||
|
result := versions_database.DB.First(&existingDownload, id)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[E] PUT > download (id=%d) can not found: %v", id, result.Error)
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"code": http.StatusNotFound,
|
||||||
|
"msg": "PUT > download can not found",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result = versions_database.DB.Model(&versions_model.DownloadVersion{}).Where("id = ?", id).Updates(updateDownload)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[E] PUT > occur an error when update download (id=%d): %v", id, result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"msg": "PUT > occur an error when update download",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// post check after update
|
||||||
|
var updatedDownload versions_model.DownloadVersion
|
||||||
|
if result = versions_database.DB.First(&updatedDownload, id); result.Error == nil {
|
||||||
|
log.Printf("[I] PUT > update download (id=%d) successful", id)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"msg": "PUT > update download successful",
|
||||||
|
"data": updatedDownload,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
log.Printf("[W] PUT > update successful but failed to retrieve updated download (id=%d): %v", id, result.Error)
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{
|
||||||
|
"code": http.StatusAccepted,
|
||||||
|
"msg": "PUT > update successful but failed to retrieve updated download",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /download/versions/:id
|
||||||
|
func DeleteDownloadVersionByID(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[E] DELETE > try to delete download, but got invalid id: %s", c.Param("id"))
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"code": http.StatusBadRequest,
|
||||||
|
"msg": "DELETE > try to delete download, but got invalid id",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := versions_database.DB.Delete(&versions_model.DownloadVersion{}, id)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Printf("[E] DELETE > occur an error when delete download (id=%d): %v", id, result.Error)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"msg": "DELETE > occur an error when delete download",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
log.Printf("[E] DELETE > try to delete download (id=%d), but RowsAffected is 0", id)
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"code": http.StatusNotFound,
|
||||||
|
"msg": "DELETE > delete download has no effect",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[I] DELETE > delete download (id=%d) successful", id)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": http.StatusOK,
|
||||||
|
"msg": "DELETE > delete download successful",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package middlewares
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "autolibrary.top/webserver/backend/middlewares/auth_middleware"
|
||||||
|
_ "autolibrary.top/webserver/backend/middlewares/casdoor_middleware"
|
||||||
|
)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package auth_middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"autolibrary.top/webserver/config"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func APIKeyAuth() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
if !cfg.Backend.Auth.Required {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey := c.GetHeader("X-API-Key")
|
||||||
|
if apiKey == "" {
|
||||||
|
apiKey = c.Query("api_key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if API key is configured
|
||||||
|
expectedKey := cfg.Backend.Auth.Key
|
||||||
|
if expectedKey == "" || expectedKey == "null" {
|
||||||
|
log.Printf("[E] AUTH > API key not configured")
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"code": http.StatusInternalServerError,
|
||||||
|
"msg": "AUTH > API key has not been configured on this server",
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if API key is valid
|
||||||
|
if apiKey == "" || apiKey != expectedKey {
|
||||||
|
log.Printf("[W] AUTH > invalid API key or key is missing")
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"code": http.StatusUnauthorized,
|
||||||
|
"msg": "AUTH > invalid API key or key is missing",
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package casdoor_middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"autolibrary.top/webserver/config"
|
||||||
|
|
||||||
|
"github.com/casdoor/casdoor-go-sdk/casdoorsdk"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CasdoorAuth() gin.HandlerFunc {
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// If Casdoor is not configured, return 501 Not Implemented
|
||||||
|
if cfg.Backend.Casdoor.Endpoint == "" || cfg.Backend.Casdoor.ClientID == "" {
|
||||||
|
c.JSON(http.StatusNotImplemented, gin.H{
|
||||||
|
"code": http.StatusNotImplemented,
|
||||||
|
"msg": "AUTH > Casdoor authentication is not configured on this server",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"code": http.StatusUnauthorized,
|
||||||
|
"msg": "AUTH > missing Authorization header",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
if token == authHeader {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"code": http.StatusUnauthorized,
|
||||||
|
"msg": "AUTH > Authorization header must be Bearer <token>",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := casdoorsdk.ParseJwtToken(token)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[W] AUTH > invalid JWT token: %v", err)
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"code": http.StatusUnauthorized,
|
||||||
|
"msg": "AUTH > invalid or expired token",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("user_id", claims.User.Id)
|
||||||
|
c.Set("user_name", claims.User.Name)
|
||||||
|
c.Set("display_name", claims.User.DisplayName)
|
||||||
|
c.Set("is_admin", claims.User.IsAdmin)
|
||||||
|
c.Set("user_tag", claims.User.Tag)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CasdoorAdminAuth requires both valid Casdoor JWT and admin privileges
|
||||||
|
func CasdoorAdminAuth() gin.HandlerFunc {
|
||||||
|
auth := CasdoorAuth()
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
auth(c)
|
||||||
|
if c.IsAborted() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isAdmin, _ := c.Get("is_admin")
|
||||||
|
if isAdmin == nil || !isAdmin.(bool) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{
|
||||||
|
"code": http.StatusForbidden,
|
||||||
|
"msg": "AUTH > admin privileges required",
|
||||||
|
"data": nil,
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "autolibrary.top/webserver/backend/models/base_model"
|
||||||
|
_ "autolibrary.top/webserver/backend/models/versions_model"
|
||||||
|
_ "autolibrary.top/webserver/backend/models/bulletins_model"
|
||||||
|
_ "autolibrary.top/webserver/backend/models/themes_model"
|
||||||
|
)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package base_model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"encoding/json"
|
||||||
|
"database/sql/driver"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StringArray []string
|
||||||
|
|
||||||
|
func (s StringArray) Value() (driver.Value, error) {
|
||||||
|
if len(s) == 0 {
|
||||||
|
return "[]", nil // empty array
|
||||||
|
}
|
||||||
|
return json.Marshal(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StringArray) Scan(value interface{}) error {
|
||||||
|
if value == nil {
|
||||||
|
*s = StringArray{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes []byte
|
||||||
|
switch v := value.(type) {
|
||||||
|
case []byte:
|
||||||
|
bytes = v
|
||||||
|
case string:
|
||||||
|
bytes = []byte(v)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported type: %T", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(bytes) == 0 {
|
||||||
|
*s = StringArray{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(bytes) == "[]" {
|
||||||
|
*s = StringArray{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Unmarshal(bytes, s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package bulletins_model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Bulletin struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
DateTime time.Time `json:"dateTime" gorm:"index"` // Time zone: Asia/Shanghai
|
||||||
|
Author string `json:"author" gorm:"type:varchar(20);index"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
// we use soft delete, to keep the uniqueness of ID
|
||||||
|
LastEditAt time.Time `json:"lastEditAt"` // Time zone: Asia/Shanghai
|
||||||
|
IsEdited bool `json:"isEdited" gorm:"default:false"`
|
||||||
|
IsDeleted bool `json:"isDeleted" gorm:"default:false"`
|
||||||
|
CreateAt time.Time `json:"createAt"` // Time zone: Asia/Shanghai
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package themes_model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"autolibrary.top/webserver/backend/models/base_model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ThemeStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ThemePending ThemeStatus = "pending"
|
||||||
|
ThemeApproved ThemeStatus = "approved"
|
||||||
|
ThemeRejected ThemeStatus = "rejected"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Theme struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
UserID string `json:"userId" gorm:"index"`
|
||||||
|
NeedTheme string `json:"needTheme" gorm:"type:varchar(10);index"`
|
||||||
|
Brief string `json:"brief"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Tags base_model.StringArray `json:"tags" gorm:"type:text"`
|
||||||
|
Status ThemeStatus `json:"status" gorm:"type:varchar(20);index;default:pending"`
|
||||||
|
FilePath string `json:"-" gorm:"type:text"`
|
||||||
|
FileSize string `json:"fileSize"`
|
||||||
|
PreviewURL string `json:"previewUrl"`
|
||||||
|
DownloadCount uint `json:"downloadCount" gorm:"default:0"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
CompatVersion string `json:"compatVersion"`
|
||||||
|
ReviewedBy string `json:"reviewedBy"`
|
||||||
|
ReviewComment string `json:"reviewComment"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
Verified bool `json:"verified" gorm:"-"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package versions_model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VersionType string
|
||||||
|
|
||||||
|
type DownloadVersion struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Type VersionType `json:"type" gorm:"type:varchar(20);index"`
|
||||||
|
ReleaseDate time.Time `json:"releaseDate"` // Time zone: Asia/Shanghai
|
||||||
|
FileSize string `json:"fileSize"`
|
||||||
|
SupportOS string `json:"supportOS"`
|
||||||
|
Features string `json:"features"` // separated by ','
|
||||||
|
GitHubZipURL string `json:"githubZipUrl"`
|
||||||
|
GitHubInstallerURL string `json:"githubInstallerUrl"`
|
||||||
|
CloudZipURL string `json:"cloudZipUrl"`
|
||||||
|
CloudInstallerURL string `json:"cloudInstallerUrl"`
|
||||||
|
GitHubDmgURL string `json:"githubDmgUrl"`
|
||||||
|
GiteaZipURL string `json:"giteaZipUrl"`
|
||||||
|
GiteaInstallerURL string `json:"giteaInstallerUrl"`
|
||||||
|
GiteaDmgURL string `json:"giteaDmgUrl"`
|
||||||
|
ReleaseNotesURL string `json:"releaseNotesUrl"`
|
||||||
|
CloudDmgURL string `json:"cloudDmgUrl"`
|
||||||
|
CloudPassword string `json:"cloudPassword"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package theme_files
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"autolibrary.top/webserver/backend/models/themes_model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CompressPreviewImage(data []byte) ([]byte, error) {
|
||||||
|
config, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||||
|
if err != nil || config.Width <= 0 || config.Height <= 0 || int64(config.Width)*int64(config.Height) > 40_000_000 {
|
||||||
|
return nil, fmt.Errorf("invalid image or image dimensions exceed the limit")
|
||||||
|
}
|
||||||
|
img, format, err := image.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot decode image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize if width exceeds 800px
|
||||||
|
bounds := img.Bounds()
|
||||||
|
w := bounds.Dx()
|
||||||
|
h := bounds.Dy()
|
||||||
|
maxW := 800
|
||||||
|
if w > maxW {
|
||||||
|
h = h * maxW / w
|
||||||
|
if h < 1 {
|
||||||
|
h = 1
|
||||||
|
}
|
||||||
|
w = maxW
|
||||||
|
// Simple resize using standard library
|
||||||
|
scaled := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
srcX := x * bounds.Dx() / w
|
||||||
|
srcY := y * bounds.Dy() / h
|
||||||
|
scaled.Set(x, y, img.At(srcX, srcY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img = scaled
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot encode JPEG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[I] image compressed: %s %dx%d, %d -> %d bytes", format, bounds.Dx(), bounds.Dy(), len(data), buf.Len())
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/themes/featured?limit=6
|
||||||
|
|
||||||
|
func ValidateAlthemeFile(data []byte) (map[string]interface{}, error) {
|
||||||
|
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid zip file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasInfoJSON, hasThemeQSS bool
|
||||||
|
var info map[string]interface{}
|
||||||
|
|
||||||
|
for _, f := range reader.File {
|
||||||
|
if f.Name == "info.json" {
|
||||||
|
hasInfoJSON = true
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot open info.json: %v", err)
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(rc)
|
||||||
|
if err := decoder.Decode(&info); err != nil {
|
||||||
|
rc.Close()
|
||||||
|
return nil, fmt.Errorf("invalid info.json: %v", err)
|
||||||
|
}
|
||||||
|
rc.Close()
|
||||||
|
}
|
||||||
|
if f.Name == "theme.qss" {
|
||||||
|
hasThemeQSS = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasInfoJSON {
|
||||||
|
return nil, fmt.Errorf("missing info.json in .altheme file")
|
||||||
|
}
|
||||||
|
if !hasThemeQSS {
|
||||||
|
return nil, fmt.Errorf("missing theme.qss in .altheme file")
|
||||||
|
}
|
||||||
|
|
||||||
|
required := []string{"name", "author", "need_theme", "brief"}
|
||||||
|
for _, key := range required {
|
||||||
|
if _, ok := info[key]; !ok {
|
||||||
|
return nil, fmt.Errorf("info.json missing required key: %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildInfoJSON constructs the info.json map from a Theme DB record.
|
||||||
|
// It merges Brief and Description with a newline separator into the "brief" field.
|
||||||
|
|
||||||
|
func BuildInfoJSON(theme themes_model.Theme) map[string]interface{} {
|
||||||
|
brief := theme.Brief
|
||||||
|
if theme.Description != "" {
|
||||||
|
brief = brief + "\n" + theme.Description
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"qss": "theme.qss",
|
||||||
|
"name": theme.Name,
|
||||||
|
"author": theme.Author,
|
||||||
|
"need_theme": theme.NeedTheme,
|
||||||
|
"brief": brief,
|
||||||
|
"version": theme.Version,
|
||||||
|
"compatible_version": theme.CompatVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createAlthemeArchive creates a .altheme ZIP archive in memory
|
||||||
|
// containing theme.qss (from qssData) and info.json (from info map).
|
||||||
|
|
||||||
|
func CreateAlthemeArchive(qssData []byte, info map[string]interface{}) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := zip.NewWriter(&buf)
|
||||||
|
|
||||||
|
// Add theme.qss
|
||||||
|
qssWriter, err := w.Create("theme.qss")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot create theme.qss in archive: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := qssWriter.Write(qssData); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot write theme.qss to archive: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add info.json
|
||||||
|
infoWriter, err := w.Create("info.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot create info.json in archive: %v", err)
|
||||||
|
}
|
||||||
|
infoBytes, err := json.MarshalIndent(info, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot marshal info.json: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := infoWriter.Write(infoBytes); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot write info.json to archive: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot close archive: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractQSSFromAltheme reads an existing .altheme ZIP file from disk
|
||||||
|
// and returns the raw bytes of its theme.qss entry.
|
||||||
|
|
||||||
|
func ExtractQSSFromAltheme(filePath string) ([]byte, error) {
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot read .altheme file: %v", err)
|
||||||
|
}
|
||||||
|
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot open .altheme as zip: %v", err)
|
||||||
|
}
|
||||||
|
for _, f := range reader.File {
|
||||||
|
if f.Name == "theme.qss" {
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot open theme.qss in archive: %v", err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
return io.ReadAll(rc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("theme.qss not found in .altheme archive")
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package theme_files
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store owns writes under a single configured storage directory. Replacing an
|
||||||
|
// archive uses a temporary file so readers never receive partially written ZIPs.
|
||||||
|
type Store struct{ root string }
|
||||||
|
|
||||||
|
func NewStore(root string) (*Store, error) {
|
||||||
|
absolute, err := filepath.Abs(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(absolute, "previews"), 0755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Store{root: absolute}, nil
|
||||||
|
}
|
||||||
|
func (s *Store) Root() string { return s.root }
|
||||||
|
func (s *Store) checked(path string) (string, error) {
|
||||||
|
absolute, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
relative, err := filepath.Rel(s.root, absolute)
|
||||||
|
if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
|
||||||
|
return "", fmt.Errorf("file path is outside theme storage")
|
||||||
|
}
|
||||||
|
return absolute, nil
|
||||||
|
}
|
||||||
|
func (s *Store) WriteFile(path string, data []byte, mode os.FileMode) error {
|
||||||
|
target, err := s.checked(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
file, err := os.CreateTemp(filepath.Dir(target), ".upload-*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
temp := file.Name()
|
||||||
|
defer os.Remove(temp)
|
||||||
|
if _, err := file.Write(data); err != nil {
|
||||||
|
file.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.Chmod(mode); err != nil {
|
||||||
|
file.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(temp, target)
|
||||||
|
}
|
||||||
|
func (s *Store) Remove(path string) error {
|
||||||
|
target, err := s.checked(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Remove(target); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package themes_service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"autolibrary.top/webserver/backend/models/themes_model"
|
||||||
|
"autolibrary.top/webserver/backend/services/theme_files"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service coordinates database changes and their corresponding theme archive.
|
||||||
|
type Service struct {
|
||||||
|
DB *gorm.DB
|
||||||
|
Files *theme_files.Store
|
||||||
|
locks sync.Map
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) lock(id uint) func() {
|
||||||
|
value, _ := s.locks.LoadOrStore(id, &sync.Mutex{})
|
||||||
|
mutex := value.(*sync.Mutex)
|
||||||
|
mutex.Lock()
|
||||||
|
return mutex.Unlock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ArchivePath(id uint) string {
|
||||||
|
return filepath.Join(s.Files.Root(), fmt.Sprintf("%d.altheme", id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Create(theme *themes_model.Theme, qss []byte) error {
|
||||||
|
var path string
|
||||||
|
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(theme).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path = s.ArchivePath(theme.ID)
|
||||||
|
data, err := theme_files.CreateAlthemeArchive(qss, theme_files.BuildInfoJSON(*theme))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.Files.WriteFile(path, data, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
theme.FilePath = path
|
||||||
|
theme.FileSize = fmt.Sprintf("%.1f KB", float64(len(data))/1024)
|
||||||
|
return tx.Save(theme).Error
|
||||||
|
})
|
||||||
|
if err != nil && path != "" {
|
||||||
|
_ = s.Files.Remove(path)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Save(theme *themes_model.Theme, qss []byte) error {
|
||||||
|
unlock := s.lock(theme.ID)
|
||||||
|
defer unlock()
|
||||||
|
path := s.ArchivePath(theme.ID)
|
||||||
|
previous, readErr := os.ReadFile(path)
|
||||||
|
if qss == nil {
|
||||||
|
var err error
|
||||||
|
qss, err = theme_files.ExtractQSSFromAltheme(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot read existing theme archive: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := theme_files.CreateAlthemeArchive(qss, theme_files.BuildInfoJSON(*theme))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
theme.FilePath = path
|
||||||
|
theme.FileSize = fmt.Sprintf("%.1f KB", float64(len(data))/1024)
|
||||||
|
wrote := false
|
||||||
|
err = s.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Save(theme).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.Files.WriteFile(path, data, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
wrote = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil && wrote {
|
||||||
|
var restoreErr error
|
||||||
|
if readErr == nil {
|
||||||
|
restoreErr = s.Files.WriteFile(path, previous, 0644)
|
||||||
|
} else {
|
||||||
|
restoreErr = s.Files.Remove(path)
|
||||||
|
}
|
||||||
|
if restoreErr != nil {
|
||||||
|
log.Printf("[E] archive rollback failed for theme %d: %v", theme.ID, restoreErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Delete(theme *themes_model.Theme) error {
|
||||||
|
unlock := s.lock(theme.ID)
|
||||||
|
defer unlock()
|
||||||
|
if err := s.DB.Delete(theme).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
paths := []string{s.ArchivePath(theme.ID)}
|
||||||
|
if theme.PreviewURL != "" {
|
||||||
|
paths = append(paths, filepath.Join(s.Files.Root(), "previews", filepath.Base(theme.PreviewURL)))
|
||||||
|
}
|
||||||
|
for _, path := range paths {
|
||||||
|
if err := s.Files.Remove(path); err != nil {
|
||||||
|
log.Printf("[W] theme %d file cleanup: %v", theme.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package users_service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/casdoor/casdoor-go-sdk/casdoorsdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type user struct {
|
||||||
|
DisplayName string
|
||||||
|
Verified bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache atomically replaces snapshots and throttles refreshes, including misses.
|
||||||
|
// Failed authentication lookups preserve the last usable display-name snapshot.
|
||||||
|
type Cache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
refresh sync.Mutex
|
||||||
|
users map[string]user
|
||||||
|
updated time.Time
|
||||||
|
enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(enabled bool) *Cache { return &Cache{users: make(map[string]user), enabled: enabled} }
|
||||||
|
|
||||||
|
func (c *Cache) Refresh() {
|
||||||
|
if !c.enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.refresh.Lock()
|
||||||
|
defer c.refresh.Unlock()
|
||||||
|
c.mu.RLock()
|
||||||
|
recent := time.Since(c.updated) < time.Minute
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if recent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users, err := casdoorsdk.GetUsers()
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.updated = time.Now()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[W] cannot refresh author cache: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
snapshot := make(map[string]user, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
name := u.DisplayName
|
||||||
|
if name == "" {
|
||||||
|
name = u.Name
|
||||||
|
}
|
||||||
|
snapshot[u.Id] = user{DisplayName: name, Verified: u.IsAdmin || u.Tag == "verified"}
|
||||||
|
}
|
||||||
|
c.users = snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) lookup(id string) (user, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
stale := time.Since(c.updated) >= time.Minute
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if stale {
|
||||||
|
c.Refresh()
|
||||||
|
}
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
u, ok := c.users[id]
|
||||||
|
return u, ok
|
||||||
|
}
|
||||||
|
func (c *Cache) ResolveAuthor(id, fallback string) string {
|
||||||
|
if u, ok := c.lookup(id); ok && u.DisplayName != "" {
|
||||||
|
return u.DisplayName
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
func (c *Cache) IsVerified(id string) bool { u, ok := c.lookup(id); return ok && u.Verified }
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/github.min.css">
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
|
||||||
|
<title>{{.title}}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #dc2626;
|
||||||
|
border-bottom: 1px solid #e5e5e5;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.json-container {
|
||||||
|
background: #f8f8f8;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
font-family: 'Monaco', 'Consolas', monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.links {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
.links a {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.links a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
background: #eff6ff;
|
||||||
|
border-left: 4px solid #3b82f6;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.code {
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-family: 'Monaco', 'Consolas', monospace;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
color: #e5e5e5;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #f87171;
|
||||||
|
border-bottom-color: #404040;
|
||||||
|
}
|
||||||
|
.json-container {
|
||||||
|
background-color: #2d2d2d;
|
||||||
|
border-color: #404040;
|
||||||
|
color: #d4d4d4;
|
||||||
|
}
|
||||||
|
.json-container > .hljs-string {
|
||||||
|
color: #a8d0ff !important;
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
background-color: #1e3a5f;
|
||||||
|
border-left-color: #60a5fa;
|
||||||
|
}
|
||||||
|
.links a {
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
.links a:hover {
|
||||||
|
color: #bfdbfe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>404 - Not Found</h1>
|
||||||
|
<p>The requested API endpoint '<span class="code">{{.path}}</span>' was not found on this server.</p>
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
<strong>API Response (JSON format):</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="json-container" id="jsonData">
|
||||||
|
{{.jsonData}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="links">
|
||||||
|
<strong>API links:</strong><br>
|
||||||
|
<a href="/health" target="_blank">Check API health</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
try {
|
||||||
|
const jsonContainer = document.getElementById('jsonData');
|
||||||
|
const jsonData = JSON.parse(jsonContainer.textContent.replace(/"/g, '"'));
|
||||||
|
const formattedJson = JSON.stringify(jsonData, null, 2);
|
||||||
|
jsonContainer.textContent = formattedJson;
|
||||||
|
hljs.highlightElement(jsonContainer);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error formatting JSON:', e);
|
||||||
|
jsonContainer.style.color = 'red';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
appName = "webserver"
|
||||||
|
appVersion = "0.0.2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewApp() *cli.App {
|
||||||
|
return &cli.App{
|
||||||
|
Name: appName,
|
||||||
|
Version: appVersion,
|
||||||
|
Usage: "AutoLibrary Web Server CLI",
|
||||||
|
Commands: []*cli.Command{
|
||||||
|
&cmdStartAll,
|
||||||
|
&cmdFrontend,
|
||||||
|
&cmdBackend,
|
||||||
|
&cmdConfig,
|
||||||
|
},
|
||||||
|
Action: nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"autolibrary.top/webserver/backend"
|
||||||
|
"autolibrary.top/webserver/config"
|
||||||
|
"autolibrary.top/webserver/frontend"
|
||||||
|
"autolibrary.top/webserver/utils"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getConfigPath(c *cli.Context) string {
|
||||||
|
rootPath := utils.GetRootPath()
|
||||||
|
configPath := filepath.Join(rootPath, "config.json")
|
||||||
|
if c.String("config") != "" {
|
||||||
|
configPath = c.String("config")
|
||||||
|
if !filepath.IsAbs(configPath) {
|
||||||
|
configPath = filepath.Join(rootPath, configPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return configPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// runServices propagates startup failures and drains every running service.
|
||||||
|
func runServices(c *cli.Context, startFrontend, startBackend bool) error {
|
||||||
|
if _, err := config.LoadConfig(getConfigPath(c), !c.IsSet("config")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx, stop := signal.NotifyContext(c.Context, os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
results := make(chan error, 2)
|
||||||
|
count := 0
|
||||||
|
if startBackend {
|
||||||
|
count++
|
||||||
|
go func() { results <- backend.StartBackendContext(ctx) }()
|
||||||
|
}
|
||||||
|
if startFrontend {
|
||||||
|
count++
|
||||||
|
go func() { results <- frontend.StartFrontendContext(ctx) }()
|
||||||
|
}
|
||||||
|
var failures []error
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
err := <-results
|
||||||
|
if err != nil {
|
||||||
|
failures = append(failures, err)
|
||||||
|
}
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
log.Printf("[I] server shutdown complete")
|
||||||
|
return errors.Join(failures...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// These are the commands function for the CLI application
|
||||||
|
/*
|
||||||
|
command : webserver backend
|
||||||
|
|
||||||
|
Start backend server
|
||||||
|
*/
|
||||||
|
func commandBackend(c *cli.Context) error {
|
||||||
|
return runServices(c, false, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// command instance for backend
|
||||||
|
var cmdBackend = cli.Command{
|
||||||
|
Name: "backend",
|
||||||
|
Aliases: []string{"b"},
|
||||||
|
Usage: "Start only the backend server",
|
||||||
|
Action: commandBackend,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "config",
|
||||||
|
Aliases: []string{"c"},
|
||||||
|
Value: "config.json",
|
||||||
|
Usage: "Load configuration from `FILE`",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
command : webserver frontend
|
||||||
|
|
||||||
|
Start frontend server
|
||||||
|
*/
|
||||||
|
func commandFrontend(c *cli.Context) error {
|
||||||
|
return runServices(c, true, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// command instance for frontend
|
||||||
|
var cmdFrontend = cli.Command{
|
||||||
|
Name: "frontend",
|
||||||
|
Aliases: []string{"f"},
|
||||||
|
Usage: "Start only the frontend server",
|
||||||
|
Action: commandFrontend,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "config",
|
||||||
|
Aliases: []string{"c"},
|
||||||
|
Value: "config.json",
|
||||||
|
Usage: "Load configuration from `FILE`",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
command : webserver start
|
||||||
|
|
||||||
|
Start both backend and frontend servers
|
||||||
|
*/
|
||||||
|
func commandStartAll(c *cli.Context) error {
|
||||||
|
return runServices(c, true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// command instance for start all
|
||||||
|
var cmdStartAll = cli.Command{
|
||||||
|
Name: "start",
|
||||||
|
Aliases: []string{"s"},
|
||||||
|
Usage: "Start both backend and frontend servers",
|
||||||
|
Action: commandStartAll,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "config",
|
||||||
|
Aliases: []string{"c"},
|
||||||
|
Value: "config.json",
|
||||||
|
Usage: "Load configuration from `FILE`",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
command : webserver config
|
||||||
|
|
||||||
|
Show current configuration
|
||||||
|
*/
|
||||||
|
func commandConfig(c *cli.Context) error {
|
||||||
|
configPath := getConfigPath(c)
|
||||||
|
cfg, err := config.LoadConfig(configPath, !c.IsSet("config"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Current Configuration (Path: %s):\n", configPath)
|
||||||
|
fmt.Printf(" Backend: %s:%s\n", cfg.Backend.Host, cfg.Backend.Port)
|
||||||
|
fmt.Printf(" Auth:\n")
|
||||||
|
fmt.Printf(" - Required: %v\n", cfg.Backend.Auth.Required)
|
||||||
|
fmt.Printf(" - Key: %s\n", cfg.Backend.Auth.Key)
|
||||||
|
fmt.Printf(" Database:\n")
|
||||||
|
fmt.Printf(" - Versions: %s\n", cfg.Backend.Database.Versions.Path)
|
||||||
|
fmt.Printf(" - Bulletins: %s\n", cfg.Backend.Database.Bulletins.Path)
|
||||||
|
fmt.Printf(" - Themes: %s\n", cfg.Backend.Database.Themes.Path)
|
||||||
|
fmt.Printf(" - Themes Files: %s\n", cfg.Backend.Database.ThemesFiles.Path)
|
||||||
|
fmt.Printf(" Casdoor:\n")
|
||||||
|
fmt.Printf(" - Endpoint: %s\n", cfg.Backend.Casdoor.Endpoint)
|
||||||
|
fmt.Printf(" - Organization: %s\n", cfg.Backend.Casdoor.Organization)
|
||||||
|
fmt.Printf(" - Application: %s\n", cfg.Backend.Casdoor.Application)
|
||||||
|
fmt.Printf(" Frontend: %s:%s\n", cfg.Frontend.Host, cfg.Frontend.Port)
|
||||||
|
fmt.Printf(" Static:\n")
|
||||||
|
fmt.Printf(" - Path: %s\n", cfg.Frontend.Static.Path)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// command instance for config
|
||||||
|
var cmdConfig = cli.Command{
|
||||||
|
Name: "config",
|
||||||
|
Aliases: []string{"cfg"},
|
||||||
|
Usage: "Show current configuration",
|
||||||
|
Action: commandConfig,
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "config",
|
||||||
|
Aliases: []string{"c"},
|
||||||
|
Value: "config.json",
|
||||||
|
Usage: "Load configuration from `FILE`",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"backend": {
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": "8080",
|
||||||
|
"auth": { "required": true, "key": "replace-with-your-api-key" },
|
||||||
|
"database": {
|
||||||
|
"versions": { "path": "./backend/data/versions.db" },
|
||||||
|
"bulletins": { "path": "./backend/data/bulletins.db" },
|
||||||
|
"themes": { "path": "./backend/data/themes.db" },
|
||||||
|
"themes_files": { "path": "./backend/data/themes_files" }
|
||||||
|
},
|
||||||
|
"casdoor": {
|
||||||
|
"endpoint": "http://127.0.0.1:8000",
|
||||||
|
"client_id": "",
|
||||||
|
"client_secret": "",
|
||||||
|
"certificate": "",
|
||||||
|
"organization": "autolibrary",
|
||||||
|
"application": "marketplace"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"frontend": {
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": "3000",
|
||||||
|
"static": { "path": "./frontend/dist" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Backend struct {
|
||||||
|
Port string `json:"port"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Auth struct {
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
} `json:"auth"`
|
||||||
|
Database struct {
|
||||||
|
Versions struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
} `json:"versions"`
|
||||||
|
Bulletins struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
} `json:"bulletins"`
|
||||||
|
Themes struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
} `json:"themes"`
|
||||||
|
ThemesFiles struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
} `json:"themes_files"`
|
||||||
|
} `json:"database"`
|
||||||
|
Casdoor struct {
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
ClientSecret string `json:"client_secret"`
|
||||||
|
Certificate string `json:"certificate"`
|
||||||
|
Organization string `json:"organization"`
|
||||||
|
Application string `json:"application"`
|
||||||
|
} `json:"casdoor"`
|
||||||
|
} `json:"backend"`
|
||||||
|
|
||||||
|
Frontend struct {
|
||||||
|
Port string `json:"port"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Static struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
} `json:"static"`
|
||||||
|
} `json:"frontend"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
instance *Config
|
||||||
|
once sync.Once
|
||||||
|
loadErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
func createDefaultConfig() *Config {
|
||||||
|
config := &Config{}
|
||||||
|
config.Backend.Port = "8080"
|
||||||
|
config.Backend.Host = "127.0.0.1"
|
||||||
|
config.Backend.Auth.Required = false
|
||||||
|
config.Backend.Auth.Key = "null"
|
||||||
|
config.Backend.Database.Versions.Path = "./backend/data/versions.db"
|
||||||
|
config.Backend.Database.Bulletins.Path = "./backend/data/bulletins.db"
|
||||||
|
config.Backend.Database.Themes.Path = "./backend/data/themes.db"
|
||||||
|
config.Backend.Database.ThemesFiles.Path = "./backend/data/themes_files"
|
||||||
|
|
||||||
|
config.Backend.Casdoor.Endpoint = "http://127.0.0.1:8000"
|
||||||
|
config.Backend.Casdoor.ClientID = ""
|
||||||
|
config.Backend.Casdoor.ClientSecret = ""
|
||||||
|
config.Backend.Casdoor.Certificate = ""
|
||||||
|
config.Backend.Casdoor.Organization = "autolibrary"
|
||||||
|
config.Backend.Casdoor.Application = "marketplace"
|
||||||
|
|
||||||
|
config.Frontend.Port = "3000"
|
||||||
|
config.Frontend.Host = "127.0.0.1"
|
||||||
|
config.Frontend.Static.Path = "./frontend/dist"
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig(configPath string, allowDefaults bool) (*Config, error) {
|
||||||
|
once.Do(func() {
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
loadErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := createDefaultConfig()
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
loadErr = fmt.Errorf("decode config %q: %w", configPath, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
loadErr = fmt.Errorf("config %q must be a JSON object", configPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
instance = cfg
|
||||||
|
})
|
||||||
|
if loadErr != nil {
|
||||||
|
if !allowDefaults {
|
||||||
|
return nil, loadErr
|
||||||
|
}
|
||||||
|
log.Printf("[W] using default config: %v", loadErr)
|
||||||
|
return createDefaultConfig(), nil
|
||||||
|
}
|
||||||
|
log.Printf("[I] config loaded successfully from: %s", configPath)
|
||||||
|
return instance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetConfig() *Config {
|
||||||
|
if instance == nil {
|
||||||
|
return createDefaultConfig()
|
||||||
|
}
|
||||||
|
return instance
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AutoLibrary Website
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=exec
|
||||||
|
User=autolibrary
|
||||||
|
Group=autolibrary
|
||||||
|
WorkingDirectory=/opt/autolibrary/current
|
||||||
|
ExecStart=/opt/autolibrary/current/webserver start --config /opt/autolibrary/shared/config.json
|
||||||
|
Environment=GIN_MODE=release
|
||||||
|
UMask=0027
|
||||||
|
NoNewPrivileges=true
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
TimeoutStopSec=20s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||||
|
|
||||||
|
export CGO_ENABLED=1
|
||||||
|
go test ./...
|
||||||
|
mkdir -p .deploy
|
||||||
|
go build -trimpath -o .deploy/webserver .
|
||||||
|
npm --prefix frontend ci
|
||||||
|
npm --prefix frontend run build
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||||
|
|
||||||
|
root=/opt/autolibrary
|
||||||
|
service=autolibrary-webserver.service
|
||||||
|
current=$root/current
|
||||||
|
sha=${RELEASE_SHA:?Missing RELEASE_SHA}
|
||||||
|
port=${AUTOLIBRARY_PORT:-3003}
|
||||||
|
[[ $sha =~ ^[0-9a-f]{40}$ && $(git rev-parse HEAD) == "$sha" ]]
|
||||||
|
[[ $port =~ ^[1-9][0-9]{0,4}$ && $port -le 65535 ]]
|
||||||
|
base_url=http://127.0.0.1:$port
|
||||||
|
|
||||||
|
healthy() {
|
||||||
|
local path
|
||||||
|
for path in / /api/health; do
|
||||||
|
[[ $(curl --silent --show-error --noproxy '*' --max-time 2 \
|
||||||
|
--output /dev/null --write-out '%{http_code}' "$base_url$path") == 200 ]] || return 1
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_service() {
|
||||||
|
local attempt
|
||||||
|
for ((attempt = 0; attempt < 20; attempt++)); do
|
||||||
|
if systemctl is-active --quiet "$service" && healthy; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Serialize releases, including manual reruns of the workflow.
|
||||||
|
exec 9>"$root/releases/.deploy.lock"
|
||||||
|
flock -x 9
|
||||||
|
remote_head=$(git ls-remote --exit-code origin refs/heads/main)
|
||||||
|
if [[ ${remote_head%%$'\t'*} != "$sha" ]]; then
|
||||||
|
echo "Skipping $sha because main has moved."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
previous=
|
||||||
|
if [[ -L $current ]]; then
|
||||||
|
previous=$(readlink "$current")
|
||||||
|
[[ -d $current ]] || { echo "Broken release link: $current" >&2; exit 1; }
|
||||||
|
elif [[ -e $current ]]; then
|
||||||
|
echo "Expected a release symlink at $current" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
test -f "$root/shared/config.json"
|
||||||
|
sudo -n -l /usr/bin/systemctl restart "$service" >/dev/null
|
||||||
|
sudo -n -l /usr/bin/systemctl stop "$service" >/dev/null
|
||||||
|
|
||||||
|
release=$(mktemp -d "$root/releases/$sha.XXXXXXXX")
|
||||||
|
switch_dir=
|
||||||
|
finished=false
|
||||||
|
|
||||||
|
switch_to() {
|
||||||
|
ln -s -- "$1" "$switch_dir/current" &&
|
||||||
|
mv -Tf -- "$switch_dir/current" "$current"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local status=$?
|
||||||
|
trap - EXIT
|
||||||
|
trap '' INT TERM
|
||||||
|
set +e
|
||||||
|
if ! $finished && [[ $(readlink "$current") == "$release" ]]; then
|
||||||
|
echo "Deployment failed. Restoring the previous service state." >&2
|
||||||
|
if [[ -n $previous ]]; then
|
||||||
|
rm -f -- "$switch_dir/current"
|
||||||
|
if ! switch_to "$previous" || ! sudo -n /usr/bin/systemctl restart "$service" ||
|
||||||
|
! wait_for_service; then
|
||||||
|
echo "Rollback failed. Check journalctl -u $service." >&2
|
||||||
|
status=1
|
||||||
|
fi
|
||||||
|
elif ! sudo -n /usr/bin/systemctl stop "$service" || ! rm -f -- "$current"; then
|
||||||
|
echo "Could not clean up the first deployment. Check $service and $current." >&2
|
||||||
|
status=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if ! $finished && [[ $(readlink "$current") != "$release" ]]; then
|
||||||
|
rm -rf -- "$release"
|
||||||
|
fi
|
||||||
|
if [[ -n $switch_dir ]]; then
|
||||||
|
rm -rf -- "$switch_dir"
|
||||||
|
fi
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
switch_dir=$(mktemp -d "$root/.switch.XXXXXXXX")
|
||||||
|
install -m 0755 .deploy/webserver "$release/webserver"
|
||||||
|
install -d -m 0755 "$release/frontend" "$release/backend"
|
||||||
|
cp -r frontend/dist "$release/frontend/dist"
|
||||||
|
cp -r backend/templates "$release/backend/templates"
|
||||||
|
test -s "$release/frontend/dist/index.html"
|
||||||
|
test -s "$release/backend/templates/404.html"
|
||||||
|
ln -s "$root/shared/logs" "$release/logs"
|
||||||
|
chmod -R a+rX "$release"
|
||||||
|
|
||||||
|
switch_to "$release"
|
||||||
|
sudo -n /usr/bin/systemctl restart "$service"
|
||||||
|
wait_for_service
|
||||||
|
finished=true
|
||||||
|
echo "Deployed $sha to $release"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# 页面设计与交互约定
|
||||||
|
|
||||||
|
保留 AutoLibrary 的书本标识、绿色强调色、图书馆文案与真实软件截图。首页用紧凑功能带、横向标签与通栏截图、编号资源清单形成不同的信息层次,避免每节重复大标题与卡片网格。
|
||||||
|
|
||||||
|
参考 [Paco Coursey](https://paco.me/) 的克制排版与目录组织,以及 [Zed](https://zed.dev/) 对真实产品界面的呈现;不引入其品牌元素或外部素材。
|
||||||
|
|
||||||
|
- `ThemeToggle.vue` 以太阳/月亮图标切换当前主题,状态由 `stores/theme.ts` 统一维护并持久保存。首次访问仍跟随系统;图标旋转、淡入和缩放遵守减少动态效果偏好。
|
||||||
|
- 顶部主题与登录按钮统一为 32px 高;键盘焦点保留清晰轮廓。
|
||||||
|
- 功能与画廊板块标题共用 `home-section-title`:桌面端 22px、手机端 20px,字重、行高和标题下方间距一致。标题旁不添加辅助说明或装饰圆点。
|
||||||
|
- `ProductGallery.vue` 管理截图与目录内容;`useGallery.ts` 管理自动播放。画廊至少 20% 可见且页面处于前台时,每 6 秒切换;鼠标悬停和控件获得焦点不暂停,手动选择后重新计时。提供播放/暂停、上一张/下一张和左右方向键操作;用户主动暂停后,手动选择不会重新开启播放。系统要求减少动态效果时默认停止自动播放。
|
||||||
|
- 画廊样式放在 `styles/home.css`,桌面端标签位于通栏截图上方,手机端改为两列。保留当前标签底部的播放进度条,界面不展示切换间隔文案。第一张截图跟随网站深浅主题,其余保留原始实机截图。
|
||||||
|
- 验证码始终使用明亮背景、接近原生的 150×50 比例,不反色或滤镜处理;输入框有明确标签,点击图片可刷新。
|
||||||
|
- 首页与下载页的平台标识共用 `PlatformLogo.vue`。`DownloadSource.vue` 使用原生 `details/summary` 展开 Gitea、云盘资源;桌面悬停展开,也支持点击、键盘和 Escape,手机端点击后在页面内展开。同一时刻只展开一个入口,云盘提取码位于资源链接上方。
|
||||||
|
- 主按钮悬停与键盘焦点使用独立的背景和文字颜色变量,不整体叠加亮度滤镜,以维持深浅主题下的清晰对比。
|
||||||
|
|
||||||
|
维护时运行 `npm --prefix frontend run build` 完成类型、代码风格与生产构建检查。另在浏览器检查双主题、手机宽度、自动轮播与验证码显示,无需新增测试用例。
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# 通过 Gitea Actions 部署官网
|
||||||
|
|
||||||
|
推送到网站仓库的 `main` 后,Gitea 把任务交给 Ubuntu 部署机上的专用 Runner。Runner 检出该次提交,使用锁文件安装前端依赖,执行前端构建、`go test ./...` 和启用 CGO 的 Go 编译。每次发布生成独立目录,原子切换 `current`,重启 systemd 服务并检查首页和 `/api/health`。检查失败时自动切回上一发布,并将本次任务标记为失败。
|
||||||
|
|
||||||
|
## 1. 准备 Ubuntu 部署机
|
||||||
|
|
||||||
|
安装 Bash、Git、curl、sudo、`build-essential`、`util-linux`、Go 1.25.6 或更新版本、Node.js 22.13 或更新版本。确认 Runner 的服务环境中能找到 `git`、`node`、`npm`、`go` 和 `gcc`,并能访问 Gitea、GitHub 上固定版本的 checkout Action、npm registry 和 Go 模块代理。
|
||||||
|
|
||||||
|
部署使用两个专用账号。首次创建账号及目录时运行以下命令。已有同名账号时复用,并确认组和权限与示例相符。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo useradd --system --user-group --create-home --home-dir /var/lib/autolibrary-runner autolibrary-deploy
|
||||||
|
sudo useradd --system --user-group --no-create-home autolibrary
|
||||||
|
sudo chmod 0700 /var/lib/autolibrary-runner
|
||||||
|
sudo install -d -o root -g autolibrary-deploy -m 1775 /opt/autolibrary
|
||||||
|
sudo install -d -o autolibrary-deploy -g autolibrary-deploy -m 0755 /opt/autolibrary/releases
|
||||||
|
sudo install -d -o root -g autolibrary -m 0771 /opt/autolibrary/shared
|
||||||
|
sudo install -d -o autolibrary -g autolibrary -m 0750 /opt/autolibrary/shared/logs
|
||||||
|
```
|
||||||
|
|
||||||
|
从现有站点迁移时,按 [Ubuntu 部署说明](migration-to-ubuntu.md) 停服备份,再迁入现有配置和数据。新站点可以从 `config.example.json` 起步并填入实际参数。生产配置路径为 `/opt/autolibrary/shared/config.json`,文件权限设为 `0640`,所有者设为 `root:autolibrary`。配置应包括:
|
||||||
|
|
||||||
|
- SQLite 数据库和主题文件使用发布目录之外的绝对路径,例如 `/opt/autolibrary/shared/themes.db` 与 `/opt/autolibrary/shared/themes_files`。运行用户 `autolibrary` 需要对数据库所在目录和主题文件目录有写权限。
|
||||||
|
- `frontend.host` 监听 `127.0.0.1`,`frontend.static.path` 使用 `./frontend/dist`。生产端口与现有 Nginx upstream 对齐。
|
||||||
|
- 保留生产 API Key、Casdoor 配置及独立运行的 Casdoor 服务。启用 API Key 校验时填写实际的 Key。
|
||||||
|
|
||||||
|
已有 `current` 时,它应为指向上一版的符号链接。核实上一版可用新 unit 和共享配置启动,并把该链接本身交给部署账号(`sudo chown -h autolibrary-deploy:autolibrary-deploy /opt/autolibrary/current`),以便部署替换和回滚。若原站使用其他 unit,在第一次自动发布前停止并禁用该旧 unit,避免争用端口。
|
||||||
|
|
||||||
|
## 2. 安装网站服务和 Runner
|
||||||
|
|
||||||
|
从网站源码目录安装 unit。它在首次发布时启动。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -m 0644 deploy/autolibrary-webserver.service /etc/systemd/system/autolibrary-webserver.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable autolibrary-webserver.service
|
||||||
|
sudo visudo -f /etc/sudoers.d/autolibrary-deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
加入以下精确授权,使 Runner 可以操作网站 unit。
|
||||||
|
|
||||||
|
```sudoers
|
||||||
|
autolibrary-deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart autolibrary-webserver.service, /usr/bin/systemctl stop autolibrary-webserver.service
|
||||||
|
```
|
||||||
|
|
||||||
|
在 Gitea 创建一个空的网站源码仓库,启用仓库的 Actions 功能并保护 `main`。使用该网站仓库的 Runner 注册令牌,在部署机安装 [Gitea Runner](https://docs.gitea.com/runner/),然后以部署账号交互注册:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo -u autolibrary-deploy -H sh -c 'cd "$HOME" && gitea-runner register'
|
||||||
|
```
|
||||||
|
|
||||||
|
输入实例地址和仓库级令牌,标签只填写 `autolibrary-production:host`。以同一账号和工作目录运行 `gitea-runner daemon`。可用下面的 systemd unit 持久运行,将 `ExecStart` 与 `PATH` 调整到实际安装位置。
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=AutoLibrary Gitea Runner
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=autolibrary-deploy
|
||||||
|
WorkingDirectory=/var/lib/autolibrary-runner
|
||||||
|
Environment=PATH=/usr/local/go/bin:/usr/local/bin:/usr/bin:/bin
|
||||||
|
ExecStart=/usr/local/bin/gitea-runner daemon
|
||||||
|
Restart=on-failure
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
例如保存为 `/etc/systemd/system/autolibrary-runner.service` 后,执行 `sudo systemctl daemon-reload` 和 `sudo systemctl enable --now autolibrary-runner.service`。旧版 Runner 的二进制名可能为 `act_runner`,按实际名称替换。确认仓库设置页中 Runner 在线,并且标签与工作流的 `runs-on` 一致。宿主机任务使用部署账号的权限直接运行,只将这个 Runner 分配给受信任的网站仓库。
|
||||||
|
|
||||||
|
## 3. 首次推送
|
||||||
|
|
||||||
|
在 Gitea 仓库的 Actions Variables 中将 `AUTOLIBRARY_PORT` 设为生产 `frontend.port`;省略时为 `3003`,若沿用通用示例配置则设置为 `3000`。
|
||||||
|
|
||||||
|
在本地网站源码目录绑定新建仓库的克隆地址并推送:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git remote add origin <Gitea 网站源码仓库的克隆地址>
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
在 Gitea 的 Actions 页确认部署任务完成。之后日常发布只需推送 `main`。发布脚本在切换前读取远端 `main`,已过时的任务会跳过部署。生产密钥、数据库、上传目录和 Casdoor 实例数据由部署机保存。
|
||||||
|
|
||||||
|
## 排查和回滚
|
||||||
|
|
||||||
|
- 任务排队时,检查仓库 Actions 设置、Runner 在线状态和标签。
|
||||||
|
- 重启或健康检查失败时,检查 Actions 日志和 `sudo journalctl -u autolibrary-webserver.service -n 100`。检查端口、共享配置、数据路径和权限。
|
||||||
|
- 发生业务回归时,用 `git revert` 后推送 `main` 重新构建上一份可用代码。紧急切回已有目录时,先确保 Runner 没有在部署,再按 [Ubuntu 部署说明](migration-to-ubuntu.md#回滚) 切换 `current`。清理历史发布目录前确认其未被 `current` 使用,并至少保留一个可用的上一版。
|
||||||
|
- 自动回滚切换的是程序和静态文件。涉及数据库结构变化时,上线前备份共享数据,并确认旧版本兼容变更后的结构。
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# AutoLibrary Ubuntu 部署与迁移
|
||||||
|
|
||||||
|
日常发布可交给 [Gitea 自动部署](gitea-deployment.md)。本页用于首次迁移、数据备份与人工恢复。
|
||||||
|
|
||||||
|
## 部署组成
|
||||||
|
|
||||||
|
- Go 服务器:`webserver frontend`、`webserver backend`,或 `webserver start` 同时运行。
|
||||||
|
- 前端构建:Vue 应用的 `frontend/dist`,部署后无需 Node 服务。
|
||||||
|
- 运行数据:现有的三个 SQLite 数据库、主题文件目录、Casdoor 数据与配置。
|
||||||
|
- 入口:Nginx 继续反向代理 Go 前端;Go 将 `/api/*` 去掉 `/api` 前缀后代理到后端。
|
||||||
|
|
||||||
|
保留当前实际端口和域名。仓库内 Nginx 示例使用前端 3003、后端 8080,通用配置示例使用前端 3000。以部署机 `config.json` 为准。
|
||||||
|
|
||||||
|
## 1. 准备新版本
|
||||||
|
|
||||||
|
开发或构建机器需要 Go 1.25.6+ 和 Node.js 22.13+。推荐使用 Node 当前 LTS。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm --prefix frontend ci
|
||||||
|
npm --prefix frontend run build
|
||||||
|
go test ./...
|
||||||
|
go build -o webserver .
|
||||||
|
```
|
||||||
|
|
||||||
|
SQLite 驱动使用 CGO;建议在 Ubuntu 上安装 `build-essential` 并在目标平台构建 Go 可执行文件,不要把 Windows 可执行文件直接部署到 Linux。
|
||||||
|
|
||||||
|
为每次发布准备单独目录,例如 `/opt/autolibrary/releases/20261016`。目录应包含:
|
||||||
|
|
||||||
|
```text
|
||||||
|
webserver
|
||||||
|
frontend/dist/
|
||||||
|
backend/templates/
|
||||||
|
config.json # 部署机自己的配置,不用示例覆盖
|
||||||
|
```
|
||||||
|
|
||||||
|
保留 `frontend/dist` 中的完整资源目录,包括字体、截图与品牌图标。
|
||||||
|
|
||||||
|
## 2. 备份运行数据
|
||||||
|
|
||||||
|
停止旧 AutoLibrary 服务,保证 SQLite 备份与主题文件一致,再备份:
|
||||||
|
|
||||||
|
- 原部署程序和前端资源。
|
||||||
|
- `config.json`。
|
||||||
|
- `versions.db`、`bulletins.db`、`themes.db`,以及存在的 `-wal`、`-shm` 文件。
|
||||||
|
- 整个 `themes_files` 目录。
|
||||||
|
- Casdoor 数据库、证书和配置。
|
||||||
|
|
||||||
|
本次不重置数据库或 Casdoor。使用部署机既有账户;不要重新导入初始化数据覆盖账户。
|
||||||
|
|
||||||
|
## 3. 配置路径
|
||||||
|
|
||||||
|
推荐将持久数据放在 `/opt/autolibrary/shared/`,在 `config.json` 中使用绝对路径。例如:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"backend": {
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": "8080",
|
||||||
|
"database": {
|
||||||
|
"versions": { "path": "/opt/autolibrary/shared/versions.db" },
|
||||||
|
"bulletins": { "path": "/opt/autolibrary/shared/bulletins.db" },
|
||||||
|
"themes": { "path": "/opt/autolibrary/shared/themes.db" },
|
||||||
|
"themes_files": { "path": "/opt/autolibrary/shared/themes_files" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"frontend": {
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": "3003",
|
||||||
|
"static": { "path": "./frontend/dist" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
以上仅展示需调整的字段;合并至实际完整配置,保留原 API Key、Casdoor client ID、secret 和证书。完整无密钥模板见根目录 `config.example.json`。
|
||||||
|
|
||||||
|
旧配置若仍指向 `frontend/public`,服务器在其同级找到 `dist/index.html` 时会使用新构建;仍建议显式改为 `frontend/dist`。
|
||||||
|
|
||||||
|
启动会为版本表添加 `gitea_*_url` 和 `release_notes_url` 列,不删除旧字段。历史 GitHub 下载地址不会被盲目改写。通过原版本管理 API 填入真实 Gitea 下载地址;未填写时,新官网引导访问 Gitea 发行页,云盘仍按原数据提供。
|
||||||
|
|
||||||
|
## 4. systemd
|
||||||
|
|
||||||
|
将 `/opt/autolibrary/current` 指向当前发布目录,并配置:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=AutoLibrary Website
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/opt/autolibrary/current
|
||||||
|
ExecStart=/opt/autolibrary/current/webserver start --config /opt/autolibrary/shared/config.json
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
TimeoutStopSec=20
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Go 服务收到 SIGTERM 后先等待请求结束,再关闭数据库。Casdoor 继续使用原独立服务运行;若配置了服务依赖,沿用现有的 Casdoor unit 名称。
|
||||||
|
|
||||||
|
Nginx 通常无需更改。检查 `proxy_pass` 指向的端口与当前配置一致。`/changelog` 的永久跳转和页面路由由 Go 处理;不要增加将所有未知资源重写到首页的规则。
|
||||||
|
|
||||||
|
## 5. 上线验证
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -I http://127.0.0.1:3003/
|
||||||
|
curl -I http://127.0.0.1:3003/marketplace/profile
|
||||||
|
curl -I http://127.0.0.1:3003/changelog
|
||||||
|
curl -i http://127.0.0.1:3003/not-a-real-page
|
||||||
|
curl http://127.0.0.1:3003/api/download/latest
|
||||||
|
curl http://127.0.0.1:8080/health
|
||||||
|
```
|
||||||
|
|
||||||
|
应分别得到页面成功、深层页面成功、301 跳转 `/downloads`、404、真实稳定版或空数据、健康状态。检查浏览器中的深浅主题、下载入口、市场筛选、登录、主题上传与管理员审核。
|
||||||
|
|
||||||
|
网站不再实时请求 GitHub 或 Gitea API 获取版本。发行说明与源码入口统一使用 Gitea;公告服务仍保留。
|
||||||
|
|
||||||
|
## 回滚
|
||||||
|
|
||||||
|
如需回滚,停止新服务,将 `current` 指回旧发布目录并恢复原配置,再启动旧程序。新增版本列属于增量改动,旧程序可忽略;若要恢复上线前的数据状态,应在停服后恢复同一批备份的数据库和主题文件。不要只回滚其中一部分。
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# ============================================================
|
||||||
|
# AutoLibrary Nginx Config — autolibrary.kenanzhu.com
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# --- Root domain redirect to www ---
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name autolibrary.kenanzhu.com;
|
||||||
|
|
||||||
|
if ($host = autolibrary.kenanzhu.com) {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
} # managed by Certbot
|
||||||
|
|
||||||
|
return 404; # managed by Certbot
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl; # managed by Certbot
|
||||||
|
server_name autolibrary.kenanzhu.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/autolibrary.kenanzhu.com/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/autolibrary.kenanzhu.com/privkey.pem; # managed by Certbot
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
|
||||||
|
return 301 https://www.autolibrary.kenanzhu.com$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- www Frontend ---
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name www.autolibrary.kenanzhu.com;
|
||||||
|
|
||||||
|
if ($host = www.autolibrary.kenanzhu.com) {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
} # managed by Certbot
|
||||||
|
|
||||||
|
return 404; # managed by Certbot
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl; # managed by Certbot
|
||||||
|
server_name www.autolibrary.kenanzhu.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/www.autolibrary.kenanzhu.com/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/www.autolibrary.kenanzhu.com/privkey.pem; # managed by Certbot
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3003;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 404 /errors/404;
|
||||||
|
error_page 500 502 503 504 /errors/500;
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- api Backend ---
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name api.autolibrary.kenanzhu.com;
|
||||||
|
|
||||||
|
if ($host = api.autolibrary.kenanzhu.com) {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
} # managed by Certbot
|
||||||
|
|
||||||
|
return 404; # managed by Certbot
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl; # managed by Certbot
|
||||||
|
server_name api.autolibrary.kenanzhu.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/www.autolibrary.kenanzhu.com/fullchain.pem; # managed by Certbot
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/www.autolibrary.kenanzhu.com/privkey.pem; # managed by Certbot
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://127.0.0.1:8080/health;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- auth Casdoor ---
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name auth.autolibrary.kenanzhu.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name auth.autolibrary.kenanzhu.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/www.autolibrary.kenanzhu.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/www.autolibrary.kenanzhu.com/privkey.pem;
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8000/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
public/
|
||||||
|
package-lock.json
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": false,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"htmlWhitespaceSensitivity": "ignore"
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import ts from 'typescript-eslint'
|
||||||
|
import vue from 'eslint-plugin-vue'
|
||||||
|
import globals from 'globals'
|
||||||
|
|
||||||
|
export default ts.config(
|
||||||
|
{ ignores: ['dist/**', 'node_modules/**', 'obfuscate/**', 'public/**'] },
|
||||||
|
js.configs.recommended,
|
||||||
|
...ts.configs.recommended,
|
||||||
|
...vue.configs['flat/essential'],
|
||||||
|
{
|
||||||
|
files: ['**/*.vue'],
|
||||||
|
languageOptions: { parserOptions: { parser: ts.parser } },
|
||||||
|
rules: { 'vue/multi-word-component-names': 'off' },
|
||||||
|
},
|
||||||
|
{ languageOptions: { globals: { ...globals.browser, ...globals.node } } },
|
||||||
|
)
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package frontend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"autolibrary.top/webserver/config"
|
||||||
|
"autolibrary.top/webserver/frontend/handlers/errors_handler"
|
||||||
|
"autolibrary.top/webserver/utils"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var hashedAsset = regexp.MustCompile(`-[a-zA-Z0-9_-]{8,}\.[a-z0-9]+$`)
|
||||||
|
|
||||||
|
func createProxy(targetURL string) gin.HandlerFunc {
|
||||||
|
target, _ := url.Parse(targetURL)
|
||||||
|
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||||
|
proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) {
|
||||||
|
log.Printf("[W] backend proxy: %v", err)
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
_, _ = w.Write([]byte(`{"code":502,"msg":"Backend service is unavailable","data":null}`))
|
||||||
|
}
|
||||||
|
return func(c *gin.Context) { c.Request.URL.Path = c.Param("path"); proxy.ServeHTTP(c.Writer, c.Request) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveStaticPath also accepts the legacy public configuration if a sibling
|
||||||
|
// production build exists. The configured path can be absolute or relative.
|
||||||
|
func ResolveStaticPath(configured string) (string, error) {
|
||||||
|
path := filepath.Clean(configured)
|
||||||
|
if filepath.Base(path) == "public" {
|
||||||
|
sibling := filepath.Join(filepath.Dir(path), "dist")
|
||||||
|
if _, err := os.Stat(filepath.Join(sibling, "index.html")); err == nil {
|
||||||
|
path = sibling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(path, "index.html")); err != nil {
|
||||||
|
return "", fmt.Errorf("frontend build missing at %s: run npm --prefix frontend run build: %w", path, err)
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRouter(cfg *config.Config) (*gin.Engine, error) {
|
||||||
|
staticPath, err := ResolveStaticPath(cfg.Frontend.Static.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entry, err := os.ReadFile(filepath.Join(staticPath, "index.html"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(gin.Recovery())
|
||||||
|
pages := errors_handler.New(entry)
|
||||||
|
r.Use(pages.APIAccessMiddleware())
|
||||||
|
r.Use(func(c *gin.Context) {
|
||||||
|
c.Header("X-Content-Type-Options", "nosniff")
|
||||||
|
if strings.HasPrefix(c.Request.URL.Path, "/assets/") {
|
||||||
|
if hashedAsset.MatchString(c.Request.URL.Path) {
|
||||||
|
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
} else {
|
||||||
|
c.Header("Cache-Control", "public, max-age=3600")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
r.Static("/assets", filepath.Join(staticPath, "assets"))
|
||||||
|
backendHost := cfg.Backend.Host
|
||||||
|
if backendHost == "0.0.0.0" || backendHost == "::" || backendHost == "" {
|
||||||
|
backendHost = "127.0.0.1"
|
||||||
|
}
|
||||||
|
r.Any("/api/*path", createProxy("http://"+net.JoinHostPort(backendHost, cfg.Backend.Port)))
|
||||||
|
serve := func(c *gin.Context) { c.Data(http.StatusOK, "text/html; charset=utf-8", entry) }
|
||||||
|
for _, path := range []string{"/", "/downloads", "/marketplace", "/marketplace/upload", "/marketplace/profile", "/marketplace/admin"} {
|
||||||
|
r.GET(path, serve)
|
||||||
|
r.HEAD(path, serve)
|
||||||
|
}
|
||||||
|
for _, method := range []string{http.MethodGet, http.MethodHead} {
|
||||||
|
r.Handle(method, "/changelog", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/downloads") })
|
||||||
|
}
|
||||||
|
pages.RegisterGetErrorRoutes(r, []int{400, 401, 403, 404, 500, 502, 503})
|
||||||
|
r.NoRoute(func(c *gin.Context) {
|
||||||
|
if c.Request.URL.Path == "/manuals" || strings.HasPrefix(c.Request.URL.Path, "/manuals/") {
|
||||||
|
c.Redirect(http.StatusMovedPermanently, "https://manuals.autolibrary.kenanzhu.com")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "API route not found", "data": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pages.GetErrorsPage(c, http.StatusNotFound)
|
||||||
|
})
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartFrontend() error { return StartFrontendContext(context.Background()) }
|
||||||
|
func StartFrontendContext(ctx context.Context) error {
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
router, err := NewRouter(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
address := net.JoinHostPort(cfg.Frontend.Host, cfg.Frontend.Port)
|
||||||
|
log.Printf("[I] frontend server is running on: http://%s", address)
|
||||||
|
return utils.ServeHTTP(ctx, address, router)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "autolibrary.top/webserver/frontend/handlers/errors_handler"
|
||||||
|
)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package errors_handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler renders the same application shell with the actual HTTP error code.
|
||||||
|
// It owns its entry data rather than using process-global mutable template state.
|
||||||
|
type Handler struct{ entry []byte }
|
||||||
|
|
||||||
|
func New(entry []byte) *Handler { return &Handler{entry: entry} }
|
||||||
|
|
||||||
|
func (h *Handler) GetErrorsPage(c *gin.Context, status int) {
|
||||||
|
c.Header("Cache-Control", "no-store")
|
||||||
|
c.Data(status, "text/html; charset=utf-8", h.entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RegisterGetErrorRoutes(r *gin.Engine, statuses []int) {
|
||||||
|
for _, code := range statuses {
|
||||||
|
serve := func(c *gin.Context) { h.GetErrorsPage(c, code) }
|
||||||
|
path := fmt.Sprintf("/errors/%d", code)
|
||||||
|
r.GET(path, serve)
|
||||||
|
r.HEAD(path, serve)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) APIAccessMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
|
||||||
|
referer := c.GetHeader("Referer")
|
||||||
|
if referer != "" {
|
||||||
|
parsed, err := url.Parse(referer)
|
||||||
|
if err != nil || parsed.Host != c.Request.Host {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 403, "msg": "Cross-origin API request denied", "data": nil})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="AutoLibrary,北京建筑大学图书馆自动化工具。自动预约、多用户管理、定时任务与自动续约,让每一次专注更从容。"
|
||||||
|
/>
|
||||||
|
<meta name="theme-color" content="#101b29" />
|
||||||
|
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml" />
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
const preference = localStorage.getItem('autolibrary-theme') || 'system'
|
||||||
|
document.documentElement.dataset.theme =
|
||||||
|
preference === 'system'
|
||||||
|
? matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light'
|
||||||
|
: preference
|
||||||
|
} catch {
|
||||||
|
document.documentElement.dataset.theme = 'dark'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<title>AutoLibrary · 让专注,从容开始</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<noscript>
|
||||||
|
请启用 JavaScript 以使用 AutoLibrary 网站。
|
||||||
|
<a href="https://gitea.kenanzhu.com/KenanZhu/AutoLibrary/releases">前往 Gitea 下载</a>
|
||||||
|
</noscript>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+3043
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "autolibrary-web",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1",
|
||||||
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
"check": "npm run typecheck && eslint . && prettier --check .",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"build": "npm run check && vite build",
|
||||||
|
"preview": "vite preview --host 127.0.0.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
|
"pinia": "^3.0.3",
|
||||||
|
"vue": "^3.5.22",
|
||||||
|
"vue-router": "^4.6.3",
|
||||||
|
"@lucide/vue": "^1.57.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@types/node": "^22.18.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"eslint": "^10.12.0",
|
||||||
|
"eslint-plugin-vue": "^10.5.1",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.0",
|
||||||
|
"vite": "^7.1.9",
|
||||||
|
"vue-tsc": "^3.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="1024" height="1024">
|
||||||
|
<path d="M0 0 C0.655448 -0.00004028 1.310896 -0.00008057 1.98620605 -0.00012207 C4.14074434 0.00328322 6.29478159 0.02228345 8.44921875 0.04296875 C9.98201368 0.05190052 11.51480996 0.06060414 13.04760742 0.0690918 C17.74432298 0.10143744 22.44041321 0.16149493 27.13671875 0.23046875 C27.82733398 0.24013672 28.51794922 0.24980469 29.22949219 0.25976562 C118.09117273 1.5582802 207.43034187 13.10804566 346.13671875 79.23046875 C346.79462402 79.66617188 347.4525293 80.101875 348.13037109 80.55078125 C354.37144684 84.71477199 360.39866177 89.07648317 366.13671875 93.91796875 C368.51366301 95.47783842 370.31231291 95.97618141 373.13671875 96.23046875 C376.87002053 94.28265913 379.46793221 91.95308704 382.37109375 88.93359375 C385.85386981 85.42431533 389.5360307 82.41039758 393.51171875 79.48046875 C394.20668457 78.96661621 394.90165039 78.45276367 395.61767578 77.92333984 C441.40038223 44.43156873 498.0299631 22.04963001 552.88671875 8.73046875 C553.59503845 8.55825806 554.30335815 8.38604736 555.03314209 8.20861816 C569.62462826 4.68860577 569.62462826 4.68860577 576.13671875 4.23046875 C576.46671875 4.56046875 576.79671875 4.89046875 577.13671875 5.23046875 C577.59864876 4.58066732 578.06057877 3.9308659 578.53650665 3.26137352 C582.43043612 0.22000627 585.9277034 0.68237629 590.7401123 0.73599243 C592.21720964 0.72874919 592.21720964 0.72874919 593.72414732 0.72135961 C597.03316264 0.71006252 600.34140966 0.72742809 603.65039062 0.74462891 C606.01884674 0.7424837 608.38730183 0.73860698 610.75575256 0.73312378 C616.5265238 0.72442282 622.29704563 0.73432288 628.0677892 0.75282438 C634.78650525 0.77359097 641.50517173 0.77345204 648.22391385 0.77247922 C660.2110105 0.77147787 672.19800045 0.78980374 684.18505859 0.81884766 C695.81951847 0.84702271 707.45388444 0.8611892 719.08837891 0.85986328 C719.80493233 0.85978337 720.52148576 0.85970346 721.25975293 0.85962112 C721.97506139 0.85954041 722.69036986 0.8594597 723.42735434 0.85937655 C736.66840622 0.85799701 749.90941817 0.86977617 763.15045711 0.88768298 C767.83492009 0.89371721 772.51937573 0.89584229 777.20384216 0.89674759 C783.50750991 0.89875336 789.8110786 0.91093769 796.11471367 0.93107986 C798.43188813 0.93693271 800.749074 0.93938002 803.06625557 0.93822479 C806.22205153 0.93724666 809.37756808 0.94852241 812.5333252 0.96304321 C813.91833324 0.95839741 813.91833324 0.95839741 815.33132124 0.95365775 C816.17631697 0.96010664 817.0213127 0.96655553 817.89191437 0.97319984 C818.62032799 0.97460869 819.34874162 0.97601754 820.09922838 0.97746909 C823.54880059 1.40581006 825.6085894 2.61891975 827.86375046 5.23029041 C829.70895004 9.57912444 829.54392798 13.55480002 829.50421143 18.23332214 C829.50748027 19.24890412 829.51074911 20.2644861 829.514117 21.31084329 C829.52140706 24.71804105 829.50723139 28.12481083 829.49316406 31.53198242 C829.49395479 33.97279432 829.49605158 36.4136061 829.49935913 38.85441589 C829.50396444 44.79745806 829.49468273 50.74038328 829.47907117 56.68339773 C829.46153365 63.60125042 829.46011197 70.51907807 829.45945558 77.43694953 C829.45776784 89.77627226 829.44195529 102.11553615 829.41796875 114.45483398 C829.39469594 126.43563322 829.38054187 138.41639514 829.37695312 150.3972168 C829.37673036 151.13554728 829.37650759 151.87387776 829.37627807 152.63458189 C829.37583548 154.12190877 829.37539346 155.60923564 829.374952 157.09656252 C829.37462265 158.19702183 829.37462265 158.19702183 829.37428665 159.31971264 C829.37407067 160.05146946 829.37385468 160.78322628 829.37363215 161.53715755 C829.36993415 172.94684846 829.35857564 184.35653332 829.34615625 195.76621762 C829.34171752 199.90528332 829.3382807 204.04434846 829.33553413 208.18341561 C829.33069402 215.35834652 829.32059927 222.53324532 829.30470657 229.70816231 C829.30017194 232.09552117 829.29734217 234.48288394 829.29629898 236.87024689 C829.29456172 240.11638278 829.28670125 243.36244535 829.27716064 246.60856628 C829.27846037 248.04214463 829.27846037 248.04214463 829.27978635 249.50468415 C829.2759452 250.36946486 829.27210406 251.23424556 829.26814651 252.12523174 C829.26627677 253.25007474 829.26627677 253.25007474 829.26436925 254.39764184 C829.13671875 256.23046875 829.13671875 256.23046875 828.13671875 258.23046875 C825.07421875 259.29296875 825.07421875 259.29296875 821.13671875 259.23046875 C813.61517577 254.42733993 807.24506882 246.49057128 800.95483398 240.17895508 C799.078424 238.29718601 797.19672906 236.42082137 795.31445312 234.54492188 C784.3581778 223.59336228 784.3581778 223.59336228 779.51977539 217.77905273 C777.51841556 215.53816563 775.22671935 213.71501675 772.91015625 211.8125 C769.6633088 208.91608323 766.93956426 205.54796103 764.13671875 202.23046875 C763.80671875 201.90046875 763.47671875 201.57046875 763.13671875 201.23046875 C763.13612166 202.25096321 763.13552457 203.27145768 763.13490939 204.32287621 C763.10834106 249.28335668 763.07684134 294.24383133 763.02008057 339.20428467 C763.01922306 339.88463567 763.01836555 340.5649Line truncated
|
||||||
|
<path d="M0 0 C1.47709733 -0.00724325 1.47709733 -0.00724325 2.98403502 -0.01463282 C6.29305033 -0.02592991 9.60129736 -0.00856434 12.91027832 0.00863647 C15.27873443 0.00649127 17.64718952 0.00261455 20.01564026 -0.00286865 C25.7864115 -0.01156961 31.55693333 -0.00166955 37.32767689 0.01683195 C44.04639295 0.03759854 50.76505942 0.0374596 57.48380154 0.03648679 C69.4708982 0.03548544 81.45788814 0.05381131 93.44494629 0.08285522 C105.07940617 0.11103027 116.71377213 0.12519677 128.3482666 0.12387085 C129.06482003 0.12379094 129.78137346 0.12371102 130.51964062 0.12362869 C131.23494909 0.12354798 131.95025755 0.12346727 132.68724203 0.12338412 C145.92829392 0.12200458 159.16930586 0.13378374 172.41034481 0.15169055 C177.09480779 0.15772478 181.77926343 0.15984985 186.46372986 0.16075516 C192.7673976 0.16276093 199.07096629 0.17494526 205.37460136 0.19508743 C207.69177583 0.20094028 210.0089617 0.20338759 212.32614326 0.20223236 C215.48193922 0.20125423 218.63745577 0.21252998 221.79321289 0.22705078 C223.17822093 0.22240498 223.17822093 0.22240498 224.59120893 0.21766531 C225.43620467 0.22411421 226.2812004 0.2305631 227.15180206 0.23720741 C228.2444225 0.23932069 228.2444225 0.23932069 229.35911608 0.24147666 C232.80868828 0.66981763 234.8684771 1.88292732 237.12363815 4.49429798 C238.96883774 8.84313201 238.80381567 12.81880759 238.76409912 17.49732971 C238.76900238 19.02070268 238.76900238 19.02070268 238.7740047 20.57485086 C238.78129475 23.98204862 238.76711908 27.38881839 238.75305176 30.79598999 C238.75384248 33.23680189 238.75593928 35.67761367 238.75924683 38.11842346 C238.76385213 44.06146563 238.75457043 50.00439084 238.73895887 55.9474053 C238.72142135 62.86525799 238.71999967 69.78308564 238.71934327 76.7009571 C238.71765554 89.04027983 238.70184298 101.37954372 238.67785645 113.71884155 C238.65458363 125.69964079 238.64042957 137.68040271 238.63684082 149.66122437 C238.63661805 150.39955484 238.63639529 151.13788532 238.63616577 151.89858945 C238.63572318 153.38591633 238.63528115 154.87324321 238.6348397 156.36057009 C238.63462013 157.09420963 238.63440057 157.82784917 238.63417435 158.58372021 C238.63385037 159.68135544 238.63385037 159.68135544 238.63351984 160.80116512 C238.62982184 172.21085602 238.61846333 183.62054089 238.60604395 195.03022518 C238.60160522 199.16929089 238.5981684 203.30835603 238.59542182 207.44742318 C238.59058171 214.62235409 238.58048697 221.79725289 238.56459427 228.97216988 C238.56005964 231.35952874 238.55722987 233.74689151 238.55618668 236.13425446 C238.55444941 239.38039035 238.54658895 242.62645292 238.53704834 245.87257385 C238.53834806 247.3061522 238.53834806 247.3061522 238.53967404 248.76869172 C238.5358329 249.63347242 238.53199175 250.49825313 238.52803421 251.38923931 C238.52616446 252.51408231 238.52616446 252.51408231 238.52425694 253.66164941 C238.39660645 255.49447632 238.39660645 255.49447632 237.39660645 257.49447632 C234.33410645 258.55697632 234.33410645 258.55697632 230.39660645 258.49447632 C222.87506346 253.6913475 216.50495651 245.75457885 210.21472168 239.44296265 C208.33831169 237.56119358 206.45661675 235.68482894 204.57434082 233.80892944 C193.6180655 222.85736985 193.6180655 222.85736985 188.77966309 217.0430603 C186.76411794 214.78629015 184.44940067 212.95084499 182.1204834 211.02816772 C180.33908312 209.44329922 178.84990451 207.77848283 177.3145752 205.95541382 C172.84218121 200.78084095 168.04174804 195.95566871 163.20910645 191.11947632 C162.32029785 190.21842163 161.43148926 189.31736694 160.51574707 188.38900757 C159.65787598 187.52920288 158.80000488 186.66939819 157.9161377 185.78353882 C157.14374756 185.00703979 156.37135742 184.23054077 155.57556152 183.43051147 C152.92680919 181.07705422 150.82321074 179.5722911 147.24816895 179.37728882 C138.31952263 180.34847491 133.80115653 184.9318221 127.7052002 191.18197632 C124.95985847 193.93196329 122.07134449 196.46301537 119.1192627 198.98666382 C117.69251303 200.23547418 116.34481795 201.57394346 115.02160645 202.93197632 C113.39660645 204.49447632 113.39660645 204.49447632 111.39660645 204.49447632 C111.06660645 205.48447632 110.73660645 206.47447632 110.39660645 207.49447632 C108.82299197 208.88808324 107.1866492 210.2114972 105.52160645 211.49447632 C100.28145848 215.66756432 95.66134273 220.12546607 90.9708252 224.91635132 C88.1267064 227.76478593 85.1561707 230.39863984 82.09973145 233.01400757 C79.17641791 235.5551448 76.50319624 238.33545459 73.8302002 241.13510132 C72.38742511 242.62307177 70.89507438 244.06260696 69.39660645 245.49447632 C68.73660645 245.49447632 68.07660645 245.49447632 67.39660645 245.49447632 C67.39660645 246.15447632 67.39660645 246.81447632 67.39660645 247.49447632 C66.73660645 247.49447632 66.07660645 247.49447632 65.39660645 247.49447632 C65.1935791 248.0350769 64.99055176 248.57567749 64.78137207 249.13265991 C62.83428002 252.4535641 60.07958687 254.74241537 57.24035645 257.28353882 C50.36571614 263.57147256 43.70761687 270.06142528 37.11535Line truncated
|
||||||
|
<path d="M0 0 C1.485 0.99 1.485 0.99 3 2 C3.29764319 4.67755834 3.39653281 7.12454682 3.36914349 9.80490303 C3.37260502 10.62438358 3.37606655 11.44386413 3.37963298 12.28817743 C3.38764217 15.06397078 3.37405873 17.83925336 3.36076355 20.61502075 C3.36207045 22.61315149 3.36469473 24.61128173 3.36853105 26.60940921 C3.37527072 32.12331736 3.3631957 37.63705222 3.34819686 43.15093827 C3.33529071 49.08824568 3.33962211 55.02554262 3.34190369 60.96286011 C3.34347987 71.25735819 3.334139 81.55180346 3.31719017 91.84628677 C3.29945477 102.64614252 3.28789551 113.4459756 3.28463173 124.24584579 C3.28442551 124.91670016 3.28421929 125.58755452 3.28400682 126.2787378 C3.28299186 129.6855016 3.2820877 133.09226542 3.2812262 136.49902926 C3.27492063 160.64536438 3.25469649 184.79166986 3.22631836 208.93798828 C3.19877674 232.39957522 3.17752019 255.86115405 3.16479492 279.32275391 C3.16440034 280.04561392 3.16400576 280.76847392 3.16359923 281.51323878 C3.15964376 288.77232505 3.1558163 296.03141137 3.15208265 303.29049775 C3.14447348 318.07882796 3.13600973 332.86715763 3.12702942 347.65548706 C3.12661755 348.33546912 3.12620568 349.01545118 3.12578133 349.71603875 C3.09816729 395.14403835 3.05056801 440.57202078 3 486 C2.34 486 1.68 486 1 486 C0.67 325.62 0.34 165.24 0 0 Z " fill="#3C678D" transform="translate(876,340)"/>
|
||||||
|
<path d="M0 0 C0 6.37316138 -7.86215933 11.87781443 -12.24609375 16.265625 C-13.94023325 17.95287676 -15.63726143 19.63714248 -17.33618164 21.31958008 C-20.76326811 24.72454009 -24.03890265 28.19532211 -27.16796875 31.87890625 C-29.6978766 34.80798933 -32.31773765 37.65133041 -34.9375 40.5 C-35.68410889 41.31223022 -35.68410889 41.31223022 -36.44580078 42.14086914 C-37.6297988 43.42792268 -38.81479229 44.71406035 -40 46 C-40.66 45.67 -41.32 45.34 -42 45 C-41.11699219 44.05640625 -40.23398437 43.1128125 -39.32421875 42.140625 C-38.11183475 40.84388501 -36.89960655 39.54699934 -35.6875 38.25 C-35.05320068 37.57130859 -34.41890137 36.89261719 -33.76538086 36.19335938 C-29.83818806 31.98568802 -25.93807116 27.75520489 -22.0625 23.5 C-18.67033787 19.78501131 -15.21573058 16.17792807 -11.625 12.65625 C-9.15628854 10.14006332 -6.86971574 7.47396698 -4.578125 4.796875 C-3.11118731 3.12659942 -1.59043707 1.55211328 0 0 Z " fill="#F5F0E7" transform="translate(644,343)"/>
|
||||||
|
<path d="M0 0 C0 3.86422892 -1.54102637 4.89217929 -4.16644287 7.53198242 C-4.76636047 8.11955322 -5.36627808 8.70712402 -5.984375 9.3125 C-6.59637756 9.92262939 -7.20838013 10.53275879 -7.83892822 11.16137695 C-9.13685219 12.45257165 -10.43837219 13.74015999 -11.74316406 15.02441406 C-13.73495731 16.98519155 -15.71601386 18.95630604 -17.6953125 20.9296875 C-18.95771542 22.18291441 -20.22071715 23.43553848 -21.484375 24.6875 C-22.07619568 25.2750708 -22.66801636 25.8626416 -23.277771 26.46801758 C-26.05674862 29.19934188 -28.78765639 31.77046415 -32 34 C-32.66 33.67 -33.32 33.34 -34 33 C-21.39754098 20.22464139 -21.39754098 20.22464139 -15.1875 14.96484375 C-9.90238958 10.21768207 -4.98746242 5.05588797 0 0 Z " fill="#F3EFE8" transform="translate(693,295)"/>
|
||||||
|
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C1.43152344 1.556875 0.86304688 2.11375 0.27734375 2.6875 C-4.07597106 6.98994135 -8.24417481 11.35778727 -12.2265625 16.00390625 C-15.32528789 19.49167866 -18.66344637 22.74144373 -22 26 C-22.33 25.01 -22.66 24.02 -23 23 C-15.41 15.41 -7.82 7.82 0 0 Z " fill="#275885" transform="translate(733,431)"/>
|
||||||
|
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C-0.55000905 3.63738416 -3.11703788 6.25755816 -5.6875 8.875 C-6.7693457 9.99455078 -6.7693457 9.99455078 -7.87304688 11.13671875 C-8.57880859 11.85214844 -9.28457031 12.56757813 -10.01171875 13.3046875 C-10.65584717 13.96452637 -11.29997559 14.62436523 -11.96362305 15.30419922 C-14.19953832 17.16616631 -16.26046651 18.06394072 -19 19 C-18.2575 18.278125 -17.515 17.55625 -16.75 16.8125 C-14.04961342 14.16513453 -14.04961342 14.16513453 -12.1875 11.625 C-9.64721483 8.57665779 -6.82085859 6.11737212 -3.8046875 3.55078125 C-1.92288746 1.97318991 -1.92288746 1.97318991 0 0 Z " fill="#F4EFE7" transform="translate(713,276)"/>
|
||||||
|
<path d="M0 0 C0.33 0.66 0.66 1.32 1 2 C-2.72346836 6.42094803 -6.29521038 10.57833482 -11 14 C-11.66 13.67 -12.32 13.34 -13 13 C-11.625 11.5 -11.625 11.5 -10 10 C-9.34 10 -8.68 10 -8 10 C-7.79375 9.46375 -7.5875 8.9275 -7.375 8.375 C-5.42003056 4.9982346 -2.81776305 2.69525161 0 0 Z " fill="#F1EDE9" transform="translate(658,329)"/>
|
||||||
|
<path d="M0 0 C0.33 0.66 0.66 1.32 1 2 C-0.09346881 3.34212062 -1.2001575 4.67347975 -2.3125 6 C-3.23482422 7.11375 -3.23482422 7.11375 -4.17578125 8.25 C-6 10 -6 10 -9 10 C-6.03 6.7 -3.06 3.4 0 0 Z " fill="#F4EFE6" transform="translate(597,394)"/>
|
||||||
|
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C0.03 3.64 -2.94 6.28 -6 9 C-6.66 8.67 -7.32 8.34 -8 8 C-5.36 5.36 -2.72 2.72 0 0 Z " fill="#3D668E" transform="translate(795,370)"/>
|
||||||
|
<path d="M0 0 C0.33 0 0.66 0 1 0 C1.63934602 5.11476819 -0.59466453 8.67254378 -3 13 C-3.66 13 -4.32 13 -5 13 C-4.54554036 11.58208592 -4.08625056 10.16571954 -3.625 8.75 C-3.24214844 7.56664062 -3.24214844 7.56664062 -2.8515625 6.359375 C-2.04826333 4.13372042 -1.10288338 2.09068328 0 0 Z " fill="#F1F1EB" transform="translate(485,564)"/>
|
||||||
|
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C-1.465 3.97 -1.465 3.97 -5 7 C-5.66 6.67 -6.32 6.34 -7 6 C-4.69 4.02 -2.38 2.04 0 0 Z " fill="#35628D" transform="translate(813,354)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 193 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 164 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
ISC License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Lucide Icons and Contributors
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
purpose with or without fee is hereby granted, provided that the above
|
||||||
|
copyright notice and this permission notice appear in all copies.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||||
|
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||||
|
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||||
|
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||||
|
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||||
|
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||||
|
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The following Lucide icons are derived from the Feather project:
|
||||||
|
|
||||||
|
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
|
||||||
|
|
||||||
|
The MIT License (MIT) (for the icons listed above)
|
||||||
|
|
||||||
|
Copyright (c) 2013-present Cole Bemis
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { Menu, X, ArrowUpRight, LogOut, Mail } from '@lucide/vue'
|
||||||
|
import { useAuthStore } from './stores/auth'
|
||||||
|
import { site } from './site'
|
||||||
|
import AuthDialog from './components/AuthDialog.vue'
|
||||||
|
import ThemeToggle from './components/ThemeToggle.vue'
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const menuOpen = ref(false)
|
||||||
|
watch(
|
||||||
|
() => route.fullPath,
|
||||||
|
() => {
|
||||||
|
menuOpen.value = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
void auth.init()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<a class="skip-link" href="#main">跳转到主要内容</a>
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="container header-inner">
|
||||||
|
<RouterLink class="brand" to="/" aria-label="AutoLibrary 首页">
|
||||||
|
<img src="/assets/favicon.svg" alt="" width="34" height="34" />
|
||||||
|
<span>
|
||||||
|
AutoLibrary
|
||||||
|
<span class="brand-dot">.</span>
|
||||||
|
</span>
|
||||||
|
</RouterLink>
|
||||||
|
<button
|
||||||
|
class="icon-button mobile-menu"
|
||||||
|
:aria-expanded="menuOpen"
|
||||||
|
aria-controls="navigation"
|
||||||
|
:aria-label="menuOpen ? '收起导航' : '展开导航'"
|
||||||
|
@click="menuOpen = !menuOpen"
|
||||||
|
>
|
||||||
|
<X v-if="menuOpen" :size="22" />
|
||||||
|
<Menu v-else :size="22" />
|
||||||
|
</button>
|
||||||
|
<nav
|
||||||
|
id="navigation"
|
||||||
|
class="navigation"
|
||||||
|
:class="{ open: menuOpen }"
|
||||||
|
aria-label="主导航"
|
||||||
|
@keydown.esc="menuOpen = false"
|
||||||
|
>
|
||||||
|
<RouterLink to="/" exact-active-class="current" active-class="">首页</RouterLink>
|
||||||
|
<RouterLink to="/downloads" active-class="current">软件下载</RouterLink>
|
||||||
|
<RouterLink to="/marketplace" active-class="current">主题市场</RouterLink>
|
||||||
|
<a :href="site.manuals" target="_blank" rel="noopener">
|
||||||
|
用户手册
|
||||||
|
<ArrowUpRight :size="13" />
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div class="header-actions">
|
||||||
|
<ThemeToggle />
|
||||||
|
<template v-if="auth.isLoggedIn">
|
||||||
|
<RouterLink class="account-link" to="/marketplace/profile">
|
||||||
|
{{ auth.user?.display_name || auth.user?.user_name || '个人中心' }}
|
||||||
|
</RouterLink>
|
||||||
|
<button class="icon-button" aria-label="退出登录" @click="auth.logout">
|
||||||
|
<LogOut :size="17" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<button v-else class="button small header-login" @click="auth.open()">登录</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main id="main" tabindex="-1"><RouterView /></main>
|
||||||
|
<footer id="footer" class="site-footer">
|
||||||
|
<div class="container">
|
||||||
|
<div class="footer-grid">
|
||||||
|
<div class="footer-intro">
|
||||||
|
<RouterLink class="brand" to="/">
|
||||||
|
<img src="/assets/favicon.svg" alt="" width="30" height="30" />
|
||||||
|
<span>
|
||||||
|
AutoLibrary
|
||||||
|
<span class="brand-dot">.</span>
|
||||||
|
</span>
|
||||||
|
</RouterLink>
|
||||||
|
<p>
|
||||||
|
让重复的事自动完成,
|
||||||
|
<br />
|
||||||
|
把时间留给真正重要的事。
|
||||||
|
</p>
|
||||||
|
<span class="caption">北京建筑大学 · 图书馆自动化工具</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>探索</h3>
|
||||||
|
<RouterLink to="/downloads">软件下载</RouterLink>
|
||||||
|
<RouterLink to="/marketplace">主题市场</RouterLink>
|
||||||
|
<a :href="site.manuals" target="_blank" rel="noopener">用户手册</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>开源项目</h3>
|
||||||
|
<a :href="site.repository" target="_blank" rel="noopener">
|
||||||
|
源码仓库
|
||||||
|
<ArrowUpRight :size="13" />
|
||||||
|
</a>
|
||||||
|
<a :href="site.issues" target="_blank" rel="noopener">
|
||||||
|
问题反馈
|
||||||
|
<ArrowUpRight :size="13" />
|
||||||
|
</a>
|
||||||
|
<a :href="site.releases" target="_blank" rel="noopener">
|
||||||
|
发行版本
|
||||||
|
<ArrowUpRight :size="13" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>联系</h3>
|
||||||
|
<a class="footer-email" href="mailto:imnanoki@qq.com">
|
||||||
|
<Mail :size="14" aria-hidden="true" />
|
||||||
|
imnanoki@qq.com
|
||||||
|
</a>
|
||||||
|
<span class="caption">基于 MIT 协议开源</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-bottom">
|
||||||
|
<span>© 2025–{{ new Date().getFullYear() }} AutoLibrary</span>
|
||||||
|
<span>非官方、非盈利项目,仅用于学习和研究。</span>
|
||||||
|
<span class="footer-signature">Built for a little more focus.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
<AuthDialog v-if="auth.modal" />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public status: number,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const messages: Record<number, string> = {
|
||||||
|
401: '登录已过期,请重新登录',
|
||||||
|
403: '你没有执行此操作的权限',
|
||||||
|
409: '你已经上传过同名主题',
|
||||||
|
502: '认证服务暂时不可用',
|
||||||
|
503: '服务暂时不可用',
|
||||||
|
}
|
||||||
|
function headersFor(options: RequestInit, authenticated: boolean): Headers {
|
||||||
|
const headers = new Headers(options.headers)
|
||||||
|
headers.set('Accept', 'application/json')
|
||||||
|
if (authenticated) {
|
||||||
|
const token = localStorage.getItem('casdoor_token')
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
export async function request<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
authenticated = false,
|
||||||
|
): Promise<T> {
|
||||||
|
const timeout = AbortSignal.timeout(30_000)
|
||||||
|
const response = await fetch(`/api${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: headersFor(options, authenticated),
|
||||||
|
signal: options.signal ? AbortSignal.any([options.signal, timeout]) : timeout,
|
||||||
|
})
|
||||||
|
const body = await response.json().catch(() => null)
|
||||||
|
if (response.status === 401 && authenticated) {
|
||||||
|
localStorage.removeItem('casdoor_token')
|
||||||
|
localStorage.removeItem('casdoor_user')
|
||||||
|
window.dispatchEvent(new Event('session-expired'))
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!response.ok ||
|
||||||
|
!body ||
|
||||||
|
(typeof body.code === 'number' && body.code !== 1 && (body.code < 200 || body.code >= 300))
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
response.status === 401 && path === '/marketplace/auth/login'
|
||||||
|
? '账户或密码错误'
|
||||||
|
: messages[response.status] || body?.msg || '请求失败,请稍后重试',
|
||||||
|
response.status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (typeof body.code === 'number' && 'data' in body ? body.data : body) as T
|
||||||
|
}
|
||||||
|
export function json(method: string, data: unknown): RequestInit {
|
||||||
|
return { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }
|
||||||
|
}
|
||||||
|
export async function downloadFile(path: string, filename: string): Promise<void> {
|
||||||
|
const response = await fetch(`/api${path}`, {
|
||||||
|
headers: headersFor({}, true),
|
||||||
|
signal: AbortSignal.timeout(60_000),
|
||||||
|
})
|
||||||
|
if (!response.ok)
|
||||||
|
throw new ApiError(messages[response.status] || '下载失败,请稍后重试', response.status)
|
||||||
|
const url = URL.createObjectURL(await response.blob())
|
||||||
|
const link = Object.assign(document.createElement('a'), { href: url, download: filename })
|
||||||
|
link.click()
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onBeforeUnmount, nextTick, useId } from 'vue'
|
||||||
|
import { X } from '@lucide/vue'
|
||||||
|
defineProps<{ title: string; wide?: boolean }>()
|
||||||
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
const dialog = ref<HTMLDialogElement>()
|
||||||
|
const titleId = useId()
|
||||||
|
let previous: HTMLElement | null = null
|
||||||
|
function close() {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
function backdrop(event: MouseEvent) {
|
||||||
|
const rect = dialog.value?.getBoundingClientRect()
|
||||||
|
if (
|
||||||
|
rect &&
|
||||||
|
(event.clientX < rect.left ||
|
||||||
|
event.clientX > rect.right ||
|
||||||
|
event.clientY < rect.top ||
|
||||||
|
event.clientY > rect.bottom)
|
||||||
|
)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
onMounted(async () => {
|
||||||
|
previous = document.activeElement as HTMLElement
|
||||||
|
dialog.value?.showModal()
|
||||||
|
document.body.classList.add('dialog-open')
|
||||||
|
await nextTick()
|
||||||
|
;(
|
||||||
|
dialog.value?.querySelector<HTMLElement>('[autofocus]') ||
|
||||||
|
dialog.value?.querySelector<HTMLElement>('input, select, textarea, button')
|
||||||
|
)?.focus()
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
dialog.value?.close()
|
||||||
|
if (!document.querySelector('dialog[open]')) document.body.classList.remove('dialog-open')
|
||||||
|
previous?.focus()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<dialog
|
||||||
|
ref="dialog"
|
||||||
|
class="dialog"
|
||||||
|
:class="{ wide }"
|
||||||
|
:aria-labelledby="titleId"
|
||||||
|
@cancel.prevent="close"
|
||||||
|
@click="backdrop"
|
||||||
|
>
|
||||||
|
<header class="dialog-heading">
|
||||||
|
<h2 :id="titleId">{{ title }}</h2>
|
||||||
|
<button class="icon-button" aria-label="关闭对话框" @click="close"><X :size="20" /></button>
|
||||||
|
</header>
|
||||||
|
<div class="dialog-content"><slot /></div>
|
||||||
|
</dialog>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { LoaderCircle, CircleAlert, Inbox } from '@lucide/vue'
|
||||||
|
defineProps<{ loading?: boolean; error?: string; empty?: boolean; message?: string }>()
|
||||||
|
defineEmits<{ retry: [] }>()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div v-if="loading || error || empty" class="async-state" role="status" :aria-busy="loading">
|
||||||
|
<LoaderCircle v-if="loading" class="spin" :size="28" />
|
||||||
|
<CircleAlert v-else-if="error" :size="28" />
|
||||||
|
<Inbox v-else :size="28" />
|
||||||
|
<p>{{ loading ? '正在加载…' : error || message || '这里暂时还没有内容' }}</p>
|
||||||
|
<button v-if="error" class="button small" @click="$emit('retry')">重新加载</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, useId } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { request, json } from '../api/client'
|
||||||
|
import { errorMessage } from '../site'
|
||||||
|
import AppDialog from './AppDialog.vue'
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const captchaInputId = useId()
|
||||||
|
const captchaHintId = useId()
|
||||||
|
const username = ref(''),
|
||||||
|
password = ref(''),
|
||||||
|
name = ref(''),
|
||||||
|
email = ref(''),
|
||||||
|
captchaCode = ref('')
|
||||||
|
const captcha = ref({ captchaId: '', captchaImage: '' })
|
||||||
|
const error = ref(''),
|
||||||
|
busy = ref(false),
|
||||||
|
captchaBusy = ref(false)
|
||||||
|
async function loadCaptcha() {
|
||||||
|
captchaBusy.value = true
|
||||||
|
try {
|
||||||
|
captcha.value = await request('/marketplace/auth/captcha')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = errorMessage(e)
|
||||||
|
} finally {
|
||||||
|
captchaBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch(
|
||||||
|
() => auth.modal,
|
||||||
|
(value) => {
|
||||||
|
error.value = ''
|
||||||
|
password.value = ''
|
||||||
|
captchaCode.value = ''
|
||||||
|
if (value === 'signup') void loadCaptcha()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
async function submit() {
|
||||||
|
busy.value = true
|
||||||
|
error.value = ''
|
||||||
|
const wasSignup = auth.modal === 'signup'
|
||||||
|
try {
|
||||||
|
if (wasSignup) {
|
||||||
|
const result = await request<{ status: string; msg?: string }>(
|
||||||
|
'/marketplace/auth/signup',
|
||||||
|
json('POST', {
|
||||||
|
username: username.value.trim(),
|
||||||
|
password: password.value,
|
||||||
|
name: name.value.trim() || username.value.trim(),
|
||||||
|
email: email.value.trim(),
|
||||||
|
captchaId: captcha.value.captchaId,
|
||||||
|
captchaCode: captchaCode.value.trim(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (result.status !== 'ok') throw new Error(result.msg || '注册失败,请检查填写内容')
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await auth.login(username.value.trim(), password.value)
|
||||||
|
} catch (e) {
|
||||||
|
if (wasSignup) {
|
||||||
|
auth.modal = 'login'
|
||||||
|
throw new Error('注册成功,请登录你的新账户', { cause: e })
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
if (auth.returnTo) {
|
||||||
|
await router.push(auth.returnTo)
|
||||||
|
auth.returnTo = ''
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = errorMessage(e)
|
||||||
|
if (auth.modal === 'signup') {
|
||||||
|
captchaCode.value = ''
|
||||||
|
void loadCaptcha()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<AppDialog
|
||||||
|
:title="auth.modal === 'signup' ? '加入 AutoLibrary' : '欢迎回来'"
|
||||||
|
@close="auth.modal = null"
|
||||||
|
>
|
||||||
|
<p class="muted dialog-intro">
|
||||||
|
{{
|
||||||
|
auth.modal === 'signup'
|
||||||
|
? '创建账户,分享你的主题与灵感。'
|
||||||
|
: '登录后管理主题,打造自己的图书馆体验。'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<form class="form-stack" @submit.prevent="submit">
|
||||||
|
<label>
|
||||||
|
用户名
|
||||||
|
<input v-model="username" autofocus required autocomplete="username" maxlength="100" />
|
||||||
|
</label>
|
||||||
|
<template v-if="auth.modal === 'signup'">
|
||||||
|
<label>
|
||||||
|
显示名称
|
||||||
|
<span class="muted">(可选)</span>
|
||||||
|
<input v-model="name" autocomplete="nickname" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
邮箱
|
||||||
|
<span class="muted">(可选)</span>
|
||||||
|
<input v-model="email" type="email" autocomplete="email" />
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
<label>
|
||||||
|
密码
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
:minlength="auth.modal === 'signup' ? 6 : undefined"
|
||||||
|
:autocomplete="auth.modal === 'signup' ? 'new-password' : 'current-password'"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div v-if="auth.modal === 'signup'" class="captcha-field">
|
||||||
|
<label :for="captchaInputId">验证码</label>
|
||||||
|
<div class="captcha-row">
|
||||||
|
<input
|
||||||
|
:id="captchaInputId"
|
||||||
|
v-model="captchaCode"
|
||||||
|
required
|
||||||
|
inputmode="numeric"
|
||||||
|
autocomplete="off"
|
||||||
|
:aria-describedby="captchaHintId"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="captcha-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="captchaBusy"
|
||||||
|
:aria-busy="captchaBusy"
|
||||||
|
aria-label="更换验证码"
|
||||||
|
@click="loadCaptcha"
|
||||||
|
>
|
||||||
|
<img v-if="captcha.captchaImage" :src="captcha.captchaImage" alt="验证码" />
|
||||||
|
<span v-else>加载验证码</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p :id="captchaHintId" class="captcha-hint">看不清?点击图片更换</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="form-error" role="alert">{{ error }}</p>
|
||||||
|
<button class="button primary full-width" :disabled="busy">
|
||||||
|
{{ busy ? '请稍候…' : auth.modal === 'signup' ? '创建账户' : '登录' }}
|
||||||
|
</button>
|
||||||
|
<p class="form-switch">
|
||||||
|
{{ auth.modal === 'signup' ? '已有账户?' : '还没有账户?' }}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-button"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="auth.modal = auth.modal === 'signup' ? 'login' : 'signup'"
|
||||||
|
>
|
||||||
|
{{ auth.modal === 'signup' ? '前往登录' : '注册账户' }}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</AppDialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { ArrowUpRight, Check, ChevronDown, CloudDownload, Code2, Copy, Download } from '@lucide/vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
source: 'gitea' | 'cloud'
|
||||||
|
files: { name: string; type: string; url: string }[]
|
||||||
|
password?: string
|
||||||
|
fallbackUrl?: string
|
||||||
|
}>()
|
||||||
|
const element = ref<HTMLDetailsElement | null>(null)
|
||||||
|
const copied = ref(false)
|
||||||
|
const copyError = ref(false)
|
||||||
|
function hoverOpen() {
|
||||||
|
if (element.value && matchMedia('(hover: hover)').matches) element.value.open = true
|
||||||
|
}
|
||||||
|
function hoverClose() {
|
||||||
|
if (element.value && !element.value.contains(document.activeElement)) element.value.open = false
|
||||||
|
}
|
||||||
|
function focusOut(event: FocusEvent) {
|
||||||
|
const details = element.value
|
||||||
|
if (!details || details.matches(':hover')) return
|
||||||
|
if (!(event.relatedTarget instanceof Node) || !details.contains(event.relatedTarget))
|
||||||
|
details.open = false
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
if (!element.value) return
|
||||||
|
element.value.open = false
|
||||||
|
element.value.querySelector('summary')?.focus()
|
||||||
|
}
|
||||||
|
async function copyPassword(password: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(password)
|
||||||
|
copied.value = true
|
||||||
|
copyError.value = false
|
||||||
|
} catch {
|
||||||
|
copyError.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<details
|
||||||
|
ref="element"
|
||||||
|
class="download-source"
|
||||||
|
name="download-source"
|
||||||
|
@mouseenter="hoverOpen"
|
||||||
|
@mouseleave="hoverClose"
|
||||||
|
@focusout="focusOut"
|
||||||
|
@keydown.esc.stop.prevent="close"
|
||||||
|
>
|
||||||
|
<summary
|
||||||
|
class="button full-width download-source-trigger"
|
||||||
|
:class="{ primary: source === 'gitea' }"
|
||||||
|
>
|
||||||
|
<Code2 v-if="source === 'gitea'" :size="18" />
|
||||||
|
<CloudDownload v-else :size="18" />
|
||||||
|
{{ source === 'gitea' ? 'Gitea 下载' : '云盘下载' }}
|
||||||
|
<ChevronDown class="download-source-chevron" :size="16" />
|
||||||
|
</summary>
|
||||||
|
<div class="download-source-menu">
|
||||||
|
<div v-if="password && files.length" class="cloud-password">
|
||||||
|
<span>
|
||||||
|
云盘提取码
|
||||||
|
<code>{{ password }}</code>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="icon-button"
|
||||||
|
:aria-label="copied ? '已复制提取码' : '复制提取码'"
|
||||||
|
@click="copyPassword(password)"
|
||||||
|
>
|
||||||
|
<Check v-if="copied" :size="16" />
|
||||||
|
<Copy v-else :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="copyError" class="caption" role="status">无法复制,请手动复制提取码。</p>
|
||||||
|
<a
|
||||||
|
v-for="file in files"
|
||||||
|
:key="file.type"
|
||||||
|
class="download-link"
|
||||||
|
:href="file.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<Download :size="18" />
|
||||||
|
<span>{{ file.name }}</span>
|
||||||
|
<span class="caption">{{ file.type }}</span>
|
||||||
|
<ArrowUpRight :size="15" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
v-if="!files.length && fallbackUrl"
|
||||||
|
class="download-link"
|
||||||
|
:href="fallbackUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<Download :size="18" />
|
||||||
|
<span>查看发行版本</span>
|
||||||
|
<ArrowUpRight :size="15" />
|
||||||
|
</a>
|
||||||
|
<p v-else-if="!files.length" class="caption download-source-empty">
|
||||||
|
此版本暂无当前平台的云盘资源。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||||
|
defineProps<{ page: number; pages: number; busy?: boolean }>()
|
||||||
|
defineEmits<{ change: [page: number] }>()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<nav v-if="pages > 1" class="pagination" aria-label="分页">
|
||||||
|
<button
|
||||||
|
class="icon-button"
|
||||||
|
:disabled="page <= 1 || busy"
|
||||||
|
aria-label="上一页"
|
||||||
|
@click="$emit('change', page - 1)"
|
||||||
|
>
|
||||||
|
<ChevronLeft :size="18" />
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
第 {{ page }} 页
|
||||||
|
<span class="muted">/ {{ pages }}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="icon-button"
|
||||||
|
:disabled="page >= pages || busy"
|
||||||
|
aria-label="下一页"
|
||||||
|
@click="$emit('change', page + 1)"
|
||||||
|
>
|
||||||
|
<ChevronRight :size="18" />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{ platform: 'windows' | 'macos'; size?: number; decorative?: boolean }>(),
|
||||||
|
{
|
||||||
|
size: 18,
|
||||||
|
decorative: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
:width="size"
|
||||||
|
:height="size"
|
||||||
|
fill="currentColor"
|
||||||
|
:role="decorative ? undefined : 'img'"
|
||||||
|
:aria-hidden="decorative || undefined"
|
||||||
|
:aria-label="decorative ? undefined : platform === 'windows' ? 'Windows' : 'macOS'"
|
||||||
|
>
|
||||||
|
<title v-if="!decorative">{{ platform === 'windows' ? 'Windows' : 'macOS' }}</title>
|
||||||
|
<path v-if="platform === 'windows'" d="M2 2h9v9H2zm11 0h9v9h-9zM2 13h9v9H2zm11 0h9v9h-9z" />
|
||||||
|
<path
|
||||||
|
v-else
|
||||||
|
d="M17.05 12.536c.031 3.132 2.748 4.174 2.778 4.188-.023.073-.434 1.485-1.431 2.944-.862 1.261-1.757 2.518-3.166 2.544-1.384.026-1.829-.822-3.412-.822-1.582 0-2.076.796-3.387.848-1.36.052-2.395-1.363-3.265-2.619-1.778-2.568-3.137-7.257-1.313-10.422.906-1.572 2.525-2.568 4.282-2.594 1.335-.026 2.595.9 3.412.9.817 0 2.351-1.113 3.96-.95.674.028 2.568.272 3.785 2.052-.098.061-2.261 1.316-2.243 3.931M14.447 4.854c.723-.875 1.21-2.094 1.077-3.306-1.043.042-2.304.695-3.052 1.57-.67.775-1.256 2.016-1.098 3.204 1.163.09 2.35-.59 3.073-1.468"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, useId } from 'vue'
|
||||||
|
import { ChevronLeft, ChevronRight, Pause, Play } from '@lucide/vue'
|
||||||
|
import { useGallery } from '../composables/useGallery'
|
||||||
|
import { useThemeStore } from '../stores/theme'
|
||||||
|
|
||||||
|
const theme = useThemeStore()
|
||||||
|
const element = ref<HTMLElement | null>(null)
|
||||||
|
const panelId = useId()
|
||||||
|
const views = [
|
||||||
|
{
|
||||||
|
title: '直观的用户界面',
|
||||||
|
short: '工作台',
|
||||||
|
description: '清晰的任务状态与操作流程,一处掌握图书馆日常。',
|
||||||
|
image: 'AutoLibrary-Dark.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '座位可视布局',
|
||||||
|
short: '座位布局',
|
||||||
|
description: '把座位放回真实的空间里,直观选择你想坐的位置。',
|
||||||
|
image: 'AutoLibrary座位可视布局.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '多用户管理',
|
||||||
|
short: '用户管理',
|
||||||
|
description: '分组管理与批量操作,让多个用户的安排同样井然有序。',
|
||||||
|
image: 'AutoLibrary多用户管理.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '定时任务管理',
|
||||||
|
short: '定时任务',
|
||||||
|
description: '提前安排预约与签到,让日常流程在合适的时间自动运行。',
|
||||||
|
image: 'AutoLibrary定时任务.png',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const gallery = useGallery(element, views.length)
|
||||||
|
const view = computed(() => views[gallery.selected.value]!)
|
||||||
|
const imageName = computed(() =>
|
||||||
|
gallery.selected.value === 0 && theme.current === 'light'
|
||||||
|
? 'AutoLibrary-Light.png'
|
||||||
|
: view.value.image,
|
||||||
|
)
|
||||||
|
const number = (index: number) => String(index + 1).padStart(2, '0')
|
||||||
|
onMounted(() => {
|
||||||
|
for (const image of [...views.map((view) => view.image), 'AutoLibrary-Light.png']) {
|
||||||
|
const preload = new Image()
|
||||||
|
preload.src = `/assets/images/index/${image}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
ref="element"
|
||||||
|
class="gallery-section container"
|
||||||
|
aria-label="软件界面画廊"
|
||||||
|
aria-roledescription="轮播图"
|
||||||
|
@keydown.left.prevent="gallery.select(gallery.selected.value - 1)"
|
||||||
|
@keydown.right.prevent="gallery.select(gallery.selected.value + 1)"
|
||||||
|
>
|
||||||
|
<header class="gallery-masthead">
|
||||||
|
<h2 class="home-section-title">软件一览</h2>
|
||||||
|
<div class="gallery-controls">
|
||||||
|
<button
|
||||||
|
class="icon-button"
|
||||||
|
:aria-label="gallery.enabled.value ? '暂停画廊自动切换' : '播放画廊'"
|
||||||
|
:title="gallery.enabled.value ? '暂停自动切换' : '播放画廊'"
|
||||||
|
@click="gallery.toggle"
|
||||||
|
>
|
||||||
|
<Pause v-if="gallery.enabled.value" :size="14" />
|
||||||
|
<Play v-else :size="14" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="icon-button"
|
||||||
|
aria-label="上一张界面"
|
||||||
|
@click="gallery.select(gallery.selected.value - 1)"
|
||||||
|
>
|
||||||
|
<ChevronLeft :size="16" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="icon-button"
|
||||||
|
aria-label="下一张界面"
|
||||||
|
@click="gallery.select(gallery.selected.value + 1)"
|
||||||
|
>
|
||||||
|
<ChevronRight :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="gallery-chapters" role="group" aria-label="选择软件界面">
|
||||||
|
<button
|
||||||
|
v-for="(item, index) in views"
|
||||||
|
:key="item.title"
|
||||||
|
class="gallery-chapter"
|
||||||
|
:class="{ active: gallery.selected.value === index }"
|
||||||
|
:aria-pressed="gallery.selected.value === index"
|
||||||
|
:aria-controls="panelId"
|
||||||
|
@click="gallery.select(index)"
|
||||||
|
>
|
||||||
|
<span class="gallery-chapter-number">{{ number(index) }}</span>
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
<span
|
||||||
|
v-if="gallery.selected.value === index"
|
||||||
|
:key="gallery.progressKey.value"
|
||||||
|
class="gallery-progress"
|
||||||
|
:class="{ running: gallery.running.value }"
|
||||||
|
:style="{ animationDuration: `${gallery.delay}ms` }"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<figure :id="panelId" class="gallery-window" aria-live="off">
|
||||||
|
<div class="gallery-window-bar" aria-hidden="true">
|
||||||
|
<span class="window-dots">● ● ●</span>
|
||||||
|
<span>{{ view.title }}</span>
|
||||||
|
<span>
|
||||||
|
{{ number(gallery.selected.value) }} / {{ String(views.length).padStart(2, '0') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="gallery-visual">
|
||||||
|
<Transition name="gallery-fade">
|
||||||
|
<img
|
||||||
|
:key="imageName"
|
||||||
|
:src="`/assets/images/index/${imageName}`"
|
||||||
|
:alt="`${view.title}:${view.description}`"
|
||||||
|
width="2560"
|
||||||
|
height="1440"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
<figcaption>
|
||||||
|
<span>{{ view.short }}</span>
|
||||||
|
{{ view.description }}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { Download, Check, X } from '@lucide/vue'
|
||||||
|
import type { Theme } from '../types'
|
||||||
|
import { request, json, downloadFile } from '../api/client'
|
||||||
|
import { errorMessage, previewUrl, modeLabel } from '../site'
|
||||||
|
import AsyncState from './AsyncState.vue'
|
||||||
|
import AppDialog from './AppDialog.vue'
|
||||||
|
const themes = ref<Theme[]>([]),
|
||||||
|
loading = ref(true),
|
||||||
|
error = ref(''),
|
||||||
|
actionError = ref(''),
|
||||||
|
busy = ref(false)
|
||||||
|
const reviewing = ref<Theme | null>(null),
|
||||||
|
action = ref('approved'),
|
||||||
|
comment = ref('')
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
themes.value = await request<Theme[]>('/marketplace/admin/pending', {}, true)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = errorMessage(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function review(theme: Theme, next: string) {
|
||||||
|
reviewing.value = theme
|
||||||
|
action.value = next
|
||||||
|
comment.value = ''
|
||||||
|
actionError.value = ''
|
||||||
|
}
|
||||||
|
async function submit() {
|
||||||
|
if (!reviewing.value) return
|
||||||
|
busy.value = true
|
||||||
|
actionError.value = ''
|
||||||
|
try {
|
||||||
|
await request(
|
||||||
|
`/marketplace/admin/review/${reviewing.value.id}`,
|
||||||
|
json('PUT', { action: action.value, comment: comment.value.trim() }),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
reviewing.value = null
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
actionError.value = errorMessage(e)
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function download(theme: Theme) {
|
||||||
|
try {
|
||||||
|
await downloadFile(
|
||||||
|
`/marketplace/admin/download/${theme.id}`,
|
||||||
|
`${theme.name}_${theme.version}.altheme`,
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = errorMessage(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<AsyncState
|
||||||
|
:loading="loading"
|
||||||
|
:error="error"
|
||||||
|
:empty="!loading && !error && !themes.length"
|
||||||
|
message="审核队列已清空,所有主题都已处理。"
|
||||||
|
@retry="load"
|
||||||
|
/>
|
||||||
|
<div v-if="!loading && !error" class="review-list">
|
||||||
|
<article v-for="theme in themes" :key="theme.id" class="panel review-item">
|
||||||
|
<img
|
||||||
|
v-if="previewUrl(theme.previewUrl)"
|
||||||
|
class="review-preview"
|
||||||
|
:src="previewUrl(theme.previewUrl)"
|
||||||
|
:alt="theme.name"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div class="review-body">
|
||||||
|
<h3>
|
||||||
|
{{ theme.name }}
|
||||||
|
<span class="badge neutral">待审核</span>
|
||||||
|
</h3>
|
||||||
|
<p class="caption">
|
||||||
|
{{ theme.author }} · {{ theme.version }} · {{ modeLabel(theme.needTheme) }} ·
|
||||||
|
{{ theme.fileSize }}
|
||||||
|
</p>
|
||||||
|
<p>{{ theme.brief }}</p>
|
||||||
|
<p v-if="theme.description" class="detail-description muted">{{ theme.description }}</p>
|
||||||
|
<div class="tags">
|
||||||
|
<span v-for="tag in theme.tags" :key="tag">{{ tag }}</span>
|
||||||
|
<span>兼容 {{ theme.compatVersion }}+</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="button small" @click="download(theme)">
|
||||||
|
<Download :size="15" />
|
||||||
|
下载审核
|
||||||
|
</button>
|
||||||
|
<button class="button small primary" @click="review(theme, 'approved')">
|
||||||
|
<Check :size="15" />
|
||||||
|
通过
|
||||||
|
</button>
|
||||||
|
<button class="button small danger" @click="review(theme, 'rejected')">
|
||||||
|
<X :size="15" />
|
||||||
|
退回
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<AppDialog
|
||||||
|
v-if="reviewing"
|
||||||
|
:title="`${action === 'approved' ? '通过' : '退回'}主题:${reviewing.name}`"
|
||||||
|
@close="!busy && (reviewing = null)"
|
||||||
|
>
|
||||||
|
<form class="form-stack" @submit.prevent="submit">
|
||||||
|
<label>
|
||||||
|
审核说明
|
||||||
|
<span class="muted">(可选)</span>
|
||||||
|
<textarea v-model="comment" rows="4" maxlength="500" placeholder="向作者说明审核结果" />
|
||||||
|
</label>
|
||||||
|
<p v-if="actionError" class="form-error" role="alert">{{ actionError }}</p>
|
||||||
|
<button class="button primary" :disabled="busy">
|
||||||
|
{{ busy ? '提交中…' : '确认审核结果' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</AppDialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Download, BadgeCheck, ArrowUpRight } from '@lucide/vue'
|
||||||
|
import type { Theme } from '../types'
|
||||||
|
import { previewUrl, modeLabel } from '../site'
|
||||||
|
defineProps<{ theme: Theme }>()
|
||||||
|
defineEmits<{ open: [theme: Theme]; preview: [theme: Theme] }>()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<article class="theme-card">
|
||||||
|
<button
|
||||||
|
class="theme-swatch"
|
||||||
|
:class="`swatch-${theme.needTheme}`"
|
||||||
|
:aria-label="`查看 ${theme.name}${theme.previewUrl ? ' 的预览' : ' 的详情'}`"
|
||||||
|
@click="theme.previewUrl ? $emit('preview', theme) : $emit('open', theme)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="previewUrl(theme.previewUrl)"
|
||||||
|
:src="previewUrl(theme.previewUrl)"
|
||||||
|
:alt="`${theme.name} 预览`"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<span v-else class="swatch-window">
|
||||||
|
<span class="window-dots">● ● ●</span>
|
||||||
|
<span class="swatch-lines">
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
</span>
|
||||||
|
<span class="swatch-brand">
|
||||||
|
Aa
|
||||||
|
<span>AutoLibrary</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div class="theme-card-body">
|
||||||
|
<button class="theme-card-title" @click="$emit('open', theme)">
|
||||||
|
<span>{{ theme.name }}</span>
|
||||||
|
<BadgeCheck v-if="theme.verified" class="accent" :size="17" aria-label="认证作者" />
|
||||||
|
<ArrowUpRight class="theme-card-arrow" :size="17" />
|
||||||
|
</button>
|
||||||
|
<p class="caption">{{ theme.author }} · {{ theme.version }}</p>
|
||||||
|
<p class="theme-brief">{{ theme.brief }}</p>
|
||||||
|
<div class="tags">
|
||||||
|
<span v-for="tag in theme.tags" :key="tag">{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="theme-card-meta">
|
||||||
|
<span>{{ modeLabel(theme.needTheme) }}</span>
|
||||||
|
<span>
|
||||||
|
<Download :size="13" />
|
||||||
|
{{ theme.downloadCount }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import type { Theme } from '../types'
|
||||||
|
import { request } from '../api/client'
|
||||||
|
import { previewUrl, modeLabel, errorMessage } from '../site'
|
||||||
|
import AppDialog from './AppDialog.vue'
|
||||||
|
import AsyncState from './AsyncState.vue'
|
||||||
|
const props = defineProps<{ theme: Theme }>()
|
||||||
|
defineEmits<{ close: [] }>()
|
||||||
|
const detail = ref(props.theme),
|
||||||
|
loading = ref(true),
|
||||||
|
error = ref('')
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
detail.value = await request(`/marketplace/themes/${props.theme.id}`)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = errorMessage(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<AppDialog :title="theme.name" wide @close="$emit('close')">
|
||||||
|
<AsyncState :loading="loading" :error="error" @retry="load" />
|
||||||
|
<template v-if="!loading && !error">
|
||||||
|
<img
|
||||||
|
v-if="previewUrl(detail.previewUrl)"
|
||||||
|
class="detail-preview"
|
||||||
|
:src="previewUrl(detail.previewUrl)"
|
||||||
|
:alt="detail.name"
|
||||||
|
/>
|
||||||
|
<p class="caption">
|
||||||
|
{{ detail.author }} · {{ modeLabel(detail.needTheme) }} · {{ detail.version }}
|
||||||
|
</p>
|
||||||
|
<p class="detail-description">{{ detail.description || detail.brief }}</p>
|
||||||
|
<div class="tags">
|
||||||
|
<span v-for="tag in detail.tags" :key="tag">{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
<dl class="detail-meta">
|
||||||
|
<div>
|
||||||
|
<dt>兼容版本</dt>
|
||||||
|
<dd>AutoLibrary {{ detail.compatVersion || '1.0.0' }}+</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>文件大小</dt>
|
||||||
|
<dd>{{ detail.fileSize }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>下载次数</dt>
|
||||||
|
<dd>{{ detail.downloadCount }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<a class="button primary full-width" :href="`/api/marketplace/themes/${detail.id}/download`">
|
||||||
|
下载主题 · .altheme
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</AppDialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import type { ThemeFields } from '../types'
|
||||||
|
const model = defineModel<ThemeFields>({ required: true })
|
||||||
|
defineProps<{ requireFile?: boolean; preview?: string }>()
|
||||||
|
const emit = defineEmits<{ file: [file: File | null]; preview: [file: File | null] }>()
|
||||||
|
const tags = ref(model.value.tags.join(', '))
|
||||||
|
watch(tags, (value) => {
|
||||||
|
model.value.tags = [
|
||||||
|
...new Set(
|
||||||
|
value
|
||||||
|
.split(/[,,]/)
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
function selected(event: Event, kind: 'file' | 'preview') {
|
||||||
|
const file = (event.target as HTMLInputElement).files?.[0] || null
|
||||||
|
if (kind === 'file') emit('file', file)
|
||||||
|
else emit('preview', file)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="form-stack">
|
||||||
|
<div class="form-columns">
|
||||||
|
<label>
|
||||||
|
主题名称
|
||||||
|
<input v-model="model.name" required maxlength="80" placeholder="为你的主题起个名字" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
外观模式
|
||||||
|
<select v-model="model.need_theme" required>
|
||||||
|
<option value="both">深浅通用</option>
|
||||||
|
<option value="dark">深色</option>
|
||||||
|
<option value="light">浅色</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
一句话介绍
|
||||||
|
<span class="caption">{{ model.brief.length }}/50</span>
|
||||||
|
<input v-model="model.brief" required maxlength="50" placeholder="这个主题有什么特别之处?" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
详细说明
|
||||||
|
<span class="caption">{{ model.description.length }}/100</span>
|
||||||
|
<textarea
|
||||||
|
v-model="model.description"
|
||||||
|
rows="4"
|
||||||
|
maxlength="100"
|
||||||
|
placeholder="介绍你的设计,也可以说明使用方法。"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="form-columns">
|
||||||
|
<label>
|
||||||
|
主题版本
|
||||||
|
<input v-model="model.version" required maxlength="40" placeholder="1.0.0" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
最低兼容版本
|
||||||
|
<input v-model="model.compat_version" required maxlength="40" placeholder="1.0.0" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
标签
|
||||||
|
<span class="muted">(用逗号分隔)</span>
|
||||||
|
<input v-model="tags" maxlength="200" placeholder="简约, 护眼, 绿色" />
|
||||||
|
</label>
|
||||||
|
<div class="form-columns">
|
||||||
|
<label>
|
||||||
|
主题文件
|
||||||
|
<span class="caption">.qss</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".qss"
|
||||||
|
:required="requireFile"
|
||||||
|
@change="selected($event, 'file')"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
预览图片
|
||||||
|
<span class="caption">PNG / JPG,可选</span>
|
||||||
|
<input type="file" accept="image/png,image/jpeg" @change="selected($event, 'preview')" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<img v-if="preview" class="editor-preview" :src="preview" alt="当前预览图片" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Moon, Sun } from '@lucide/vue'
|
||||||
|
import { useThemeStore } from '../stores/theme'
|
||||||
|
|
||||||
|
const theme = useThemeStore()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="theme-toggle"
|
||||||
|
:class="{ 'is-dark': theme.current === 'dark' }"
|
||||||
|
:aria-label="theme.current === 'dark' ? '切换浅色主题' : '切换深色主题'"
|
||||||
|
:title="theme.current === 'dark' ? '切换浅色主题' : '切换深色主题'"
|
||||||
|
@click="theme.toggle"
|
||||||
|
>
|
||||||
|
<span class="theme-symbol" aria-hidden="true">
|
||||||
|
<Sun class="theme-sun" :size="18" :stroke-width="1.7" />
|
||||||
|
<Moon class="theme-moon" :size="18" :stroke-width="1.7" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
|
||||||
|
|
||||||
|
// Only a visible gallery advances. Manual selection restarts a full
|
||||||
|
// reading interval, and all observers/listeners are released on navigation.
|
||||||
|
export function useGallery(element: Ref<HTMLElement | null>, count: number, delay = 6000) {
|
||||||
|
const selected = ref(0)
|
||||||
|
const enabled = ref(true)
|
||||||
|
const visible = ref(false)
|
||||||
|
const pageVisible = ref(!document.hidden)
|
||||||
|
const progressKey = ref(0)
|
||||||
|
const selectionKey = ref(0)
|
||||||
|
const running = computed(() => enabled.value && visible.value && pageVisible.value)
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let observer: IntersectionObserver | undefined
|
||||||
|
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)')
|
||||||
|
|
||||||
|
function select(index: number) {
|
||||||
|
selected.value = (index + count) % count
|
||||||
|
selectionKey.value++
|
||||||
|
}
|
||||||
|
function toggle() {
|
||||||
|
enabled.value = !enabled.value
|
||||||
|
}
|
||||||
|
function updateVisibility() {
|
||||||
|
pageVisible.value = !document.hidden
|
||||||
|
}
|
||||||
|
function updateMotion() {
|
||||||
|
if (reducedMotion.matches) enabled.value = false
|
||||||
|
}
|
||||||
|
watch([running, selected, selectionKey], () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
progressKey.value++
|
||||||
|
if (running.value) timer = setTimeout(() => select(selected.value + 1), delay)
|
||||||
|
})
|
||||||
|
onMounted(() => {
|
||||||
|
enabled.value = !reducedMotion.matches
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const entry = entries[0]
|
||||||
|
visible.value = !!entry && entry.isIntersecting && entry.intersectionRatio >= 0.2
|
||||||
|
},
|
||||||
|
{ threshold: 0.2 },
|
||||||
|
)
|
||||||
|
if (element.value) observer.observe(element.value)
|
||||||
|
document.addEventListener('visibilitychange', updateVisibility)
|
||||||
|
reducedMotion.addEventListener('change', updateMotion)
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
observer?.disconnect()
|
||||||
|
document.removeEventListener('visibilitychange', updateVisibility)
|
||||||
|
reducedMotion.removeEventListener('change', updateMotion)
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
selected,
|
||||||
|
enabled,
|
||||||
|
running,
|
||||||
|
progressKey,
|
||||||
|
delay,
|
||||||
|
select,
|
||||||
|
toggle,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import { router } from './router'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
createApp(App).use(createPinia()).use(router).mount('#app')
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { ShieldCheck } from '@lucide/vue'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import AsyncState from '../components/AsyncState.vue'
|
||||||
|
import ReviewQueue from '../components/ReviewQueue.vue'
|
||||||
|
const auth = useAuthStore()
|
||||||
|
onMounted(() => auth.init())
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="container page-container">
|
||||||
|
<header class="page-heading">
|
||||||
|
<p class="eyebrow">COMMUNITY CARE</p>
|
||||||
|
<h1>主题审核</h1>
|
||||||
|
<p>让每一份创意,都安心与社区见面。</p>
|
||||||
|
</header>
|
||||||
|
<AsyncState v-if="!auth.ready" loading />
|
||||||
|
<section v-else-if="!auth.isLoggedIn" class="panel login-prompt">
|
||||||
|
<ShieldCheck :size="30" />
|
||||||
|
<h2>请使用管理员账户登录</h2>
|
||||||
|
<p>审核功能使用统一的账户权限。</p>
|
||||||
|
<button class="button primary" @click="auth.open('login', '/marketplace/admin')">
|
||||||
|
管理员登录
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
<section v-else-if="!auth.user?.is_admin" class="panel login-prompt">
|
||||||
|
<ShieldCheck :size="30" />
|
||||||
|
<h2>需要管理员权限</h2>
|
||||||
|
<p>当前账户无法访问审核队列。</p>
|
||||||
|
<RouterLink class="button" to="/marketplace/profile">返回个人中心</RouterLink>
|
||||||
|
</section>
|
||||||
|
<ReviewQueue v-else />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ArrowUpRight, ArrowRight } from '@lucide/vue'
|
||||||
|
import type { Version, PageMeta } from '../types'
|
||||||
|
import { request } from '../api/client'
|
||||||
|
import { site, safeUrl, formatDate, errorMessage } from '../site'
|
||||||
|
import AsyncState from '../components/AsyncState.vue'
|
||||||
|
import Pagination from '../components/Pagination.vue'
|
||||||
|
import PlatformLogo from '../components/PlatformLogo.vue'
|
||||||
|
import DownloadSource from '../components/DownloadSource.vue'
|
||||||
|
const route = useRoute(),
|
||||||
|
router = useRouter()
|
||||||
|
const versions = ref<Version[]>([]),
|
||||||
|
selected = ref<Version | null>(null)
|
||||||
|
const meta = ref<PageMeta>({ page: 1, per_page: 9, total: 0, total_pages: 0, has_next: false })
|
||||||
|
const loading = ref(true),
|
||||||
|
error = ref(''),
|
||||||
|
notice = ref('')
|
||||||
|
const platform = ref<'windows' | 'macos'>(
|
||||||
|
/Mac|iPhone|iPad/i.test(navigator.userAgent) ? 'macos' : 'windows',
|
||||||
|
)
|
||||||
|
const cache = new Map<number, { downloads: Version[]; meta: PageMeta }>()
|
||||||
|
let generation = 0
|
||||||
|
const downloadSources = computed(() => {
|
||||||
|
const version = selected.value
|
||||||
|
const files = (installer?: string, archive?: string, dmg?: string) =>
|
||||||
|
(platform.value === 'macos'
|
||||||
|
? [{ name: 'macOS 安装包', type: '.dmg', url: dmg }]
|
||||||
|
: [
|
||||||
|
{ name: 'Windows 安装程序', type: '.exe', url: installer },
|
||||||
|
{ name: '便携压缩包', type: '.zip', url: archive },
|
||||||
|
]
|
||||||
|
)
|
||||||
|
.map((file) => ({ ...file, url: safeUrl(file.url) }))
|
||||||
|
.filter((file) => file.url)
|
||||||
|
return {
|
||||||
|
gitea: files(version?.giteaInstallerUrl, version?.giteaZipUrl, version?.giteaDmgUrl),
|
||||||
|
cloud: files(version?.cloudInstallerUrl, version?.cloudZipUrl, version?.cloudDmgUrl),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
async function pageData(page: number) {
|
||||||
|
if (!cache.has(page)) cache.set(page, await request(`/download/versions?page=${page}&per_page=9`))
|
||||||
|
return cache.get(page)!
|
||||||
|
}
|
||||||
|
async function load(page = meta.value.page, resolveHash = false) {
|
||||||
|
const current = ++generation
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
notice.value = ''
|
||||||
|
try {
|
||||||
|
let data = await pageData(page)
|
||||||
|
let match: Version | undefined
|
||||||
|
let hash = route.hash.slice(1)
|
||||||
|
try {
|
||||||
|
hash = decodeURIComponent(hash)
|
||||||
|
} catch {
|
||||||
|
/* use the literal fragment */
|
||||||
|
}
|
||||||
|
if (resolveHash && hash) {
|
||||||
|
match = data.downloads.find((v) => v.version === hash)
|
||||||
|
for (let p = 1; !match && p <= data.meta.total_pages; p++) {
|
||||||
|
const candidate = await pageData(p)
|
||||||
|
match = candidate.downloads.find((v) => v.version === hash)
|
||||||
|
if (match) data = candidate
|
||||||
|
}
|
||||||
|
if (!match) notice.value = '未找到链接中的版本,已展示当前可用版本。'
|
||||||
|
}
|
||||||
|
if (current !== generation) return
|
||||||
|
versions.value = data.downloads
|
||||||
|
meta.value = data.meta
|
||||||
|
selected.value = match || data.downloads[0] || null
|
||||||
|
if (!resolveHash && selected.value) void router.replace({ hash: `#${selected.value.version}` })
|
||||||
|
} catch (e) {
|
||||||
|
if (current === generation) error.value = errorMessage(e)
|
||||||
|
} finally {
|
||||||
|
if (current === generation) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function select(version: Version) {
|
||||||
|
selected.value = version
|
||||||
|
notice.value = ''
|
||||||
|
void router.replace({ hash: `#${version.version}` })
|
||||||
|
}
|
||||||
|
watch(
|
||||||
|
() => route.hash,
|
||||||
|
(hash) => {
|
||||||
|
if (hash.slice(1) !== selected.value?.version) void load(1, true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
onMounted(() => load(1, true))
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="container page-container">
|
||||||
|
<header class="page-heading">
|
||||||
|
<p class="eyebrow">GET AUTOLIBRARY</p>
|
||||||
|
<h1>让自动化,即刻开始。</h1>
|
||||||
|
<p>选择你的平台,下载适合的版本。</p>
|
||||||
|
</header>
|
||||||
|
<AsyncState
|
||||||
|
:loading="loading"
|
||||||
|
:error="error"
|
||||||
|
:empty="!loading && !error && !selected"
|
||||||
|
message="暂无可用的下载版本"
|
||||||
|
@retry="load()"
|
||||||
|
/>
|
||||||
|
<p v-if="notice" class="notice" role="status">{{ notice }}</p>
|
||||||
|
<div v-if="selected && !loading && !error" class="download-layout">
|
||||||
|
<div class="mobile-version-control">
|
||||||
|
<label>
|
||||||
|
选择发行版本
|
||||||
|
<select
|
||||||
|
:value="selected.id"
|
||||||
|
aria-label="选择发行版本"
|
||||||
|
@change="
|
||||||
|
select(
|
||||||
|
versions.find((v) => v.id === Number(($event.target as HTMLSelectElement).value))!,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<option v-for="version in versions" :key="version.id" :value="version.id">
|
||||||
|
{{ version.version }} · {{ formatDate(version.releaseDate) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<Pagination :page="meta.page" :pages="meta.total_pages" @change="load($event)" />
|
||||||
|
</div>
|
||||||
|
<section class="download-panel panel">
|
||||||
|
<div class="platform-switch" role="group" aria-label="选择操作系统">
|
||||||
|
<button
|
||||||
|
:class="{ active: platform === 'windows' }"
|
||||||
|
:aria-pressed="platform === 'windows'"
|
||||||
|
@click="platform = 'windows'"
|
||||||
|
>
|
||||||
|
<PlatformLogo platform="windows" decorative />
|
||||||
|
Windows
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="{ active: platform === 'macos' }"
|
||||||
|
:aria-pressed="platform === 'macos'"
|
||||||
|
@click="platform = 'macos'"
|
||||||
|
>
|
||||||
|
<PlatformLogo platform="macos" decorative />
|
||||||
|
macOS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="download-title">
|
||||||
|
<img src="/assets/favicon.svg" alt="" width="54" height="54" />
|
||||||
|
<div>
|
||||||
|
<span class="caption">
|
||||||
|
AutoLibrary for {{ platform === 'windows' ? 'Windows' : 'macOS' }}
|
||||||
|
</span>
|
||||||
|
<h2>
|
||||||
|
{{ selected.version }}
|
||||||
|
<span class="badge" :class="{ neutral: selected.type !== 'stable' }">
|
||||||
|
{{
|
||||||
|
selected.type === 'stable'
|
||||||
|
? '稳定版'
|
||||||
|
: selected.type === 'beta'
|
||||||
|
? '测试版'
|
||||||
|
: '预览版'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="download-facts">
|
||||||
|
<span>{{ formatDate(selected.releaseDate) }}</span>
|
||||||
|
<span v-if="selected.fileSize">{{ selected.fileSize }}</span>
|
||||||
|
<span v-if="platform === 'windows' && selected.supportOS">{{ selected.supportOS }}</span>
|
||||||
|
<span
|
||||||
|
v-else-if="
|
||||||
|
platform === 'macos' && (downloadSources.gitea.length || downloadSources.cloud.length)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
macOS
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="download-links">
|
||||||
|
<DownloadSource
|
||||||
|
:key="`gitea-${selected.id}-${platform}`"
|
||||||
|
source="gitea"
|
||||||
|
:files="downloadSources.gitea"
|
||||||
|
:fallback-url="site.releases"
|
||||||
|
/>
|
||||||
|
<DownloadSource
|
||||||
|
:key="`cloud-${selected.id}-${platform}`"
|
||||||
|
source="cloud"
|
||||||
|
:files="downloadSources.cloud"
|
||||||
|
:password="selected.cloudPassword"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="selected.features" class="version-description">
|
||||||
|
<h3>版本说明</h3>
|
||||||
|
<ul>
|
||||||
|
<li
|
||||||
|
v-for="(feature, index) in selected.features
|
||||||
|
.split(/[,,\n]/)
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean)"
|
||||||
|
:key="index"
|
||||||
|
>
|
||||||
|
{{ feature }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
class="text-link"
|
||||||
|
:href="safeUrl(selected.releaseNotesUrl) || site.releases"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
阅读发行说明
|
||||||
|
<ArrowUpRight :size="16" />
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
<aside class="version-sidebar">
|
||||||
|
<div class="sidebar-heading">
|
||||||
|
<h2>发行版本</h2>
|
||||||
|
<span class="caption">{{ meta.total }} 个版本</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="version in versions"
|
||||||
|
:key="version.id"
|
||||||
|
class="version-item"
|
||||||
|
:class="{ selected: version.id === selected.id }"
|
||||||
|
:aria-pressed="version.id === selected.id"
|
||||||
|
@click="select(version)"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<strong>{{ version.version }}</strong>
|
||||||
|
<span class="caption">{{ formatDate(version.releaseDate) }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="version-type">{{ version.type === 'stable' ? '稳定' : version.type }}</span>
|
||||||
|
<ArrowRight :size="16" />
|
||||||
|
</button>
|
||||||
|
<Pagination :page="meta.page" :pages="meta.total_pages" @change="load($event)" />
|
||||||
|
<a :href="site.manuals" class="sidebar-help" target="_blank" rel="noopener">
|
||||||
|
第一次使用?
|
||||||
|
<br />
|
||||||
|
<strong>
|
||||||
|
查看安装与配置指南
|
||||||
|
<ArrowUpRight :size="14" />
|
||||||
|
</strong>
|
||||||
|
</a>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { ArrowLeft, ArrowUpRight } from '@lucide/vue'
|
||||||
|
import { site } from '../site'
|
||||||
|
const route = useRoute()
|
||||||
|
const code = computed(() => String(route.params.code || '404'))
|
||||||
|
const messages: Record<string, [string, string]> = {
|
||||||
|
'400': ['请求似乎有些问题。', '请检查请求内容,或返回首页重新开始。'],
|
||||||
|
'401': ['请先登录。', '登录后再访问这个页面。'],
|
||||||
|
'403': ['暂时无法访问这里。', '当前账户没有访问此内容的权限。'],
|
||||||
|
'404': ['这一页,暂时迷路了。', '页面可能已移动,或者链接不太准确。'],
|
||||||
|
'500': ['服务正在休息一下。', '服务器遇到问题,请稍后再试。'],
|
||||||
|
'502': ['连接暂时中断。', '上游服务暂时不可用,请稍后再试。'],
|
||||||
|
'503': ['我们很快回来。', '服务暂时不可用,请稍后再试。'],
|
||||||
|
}
|
||||||
|
const message = computed(() => messages[code.value] || messages['404']!)
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section class="container error-page">
|
||||||
|
<p class="error-number">{{ code }}</p>
|
||||||
|
<h1>{{ message[0] }}</h1>
|
||||||
|
<p>{{ message[1] }}</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<RouterLink to="/" class="button primary">
|
||||||
|
<ArrowLeft :size="17" />
|
||||||
|
返回首页
|
||||||
|
</RouterLink>
|
||||||
|
<a :href="site.issues" class="text-link" target="_blank" rel="noopener">
|
||||||
|
反馈问题
|
||||||
|
<ArrowUpRight :size="17" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import {
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowRight,
|
||||||
|
Download,
|
||||||
|
CalendarCheck2,
|
||||||
|
Users,
|
||||||
|
Clock3,
|
||||||
|
Repeat2,
|
||||||
|
BookOpen,
|
||||||
|
Palette,
|
||||||
|
Code2,
|
||||||
|
} from '@lucide/vue'
|
||||||
|
import { request } from '../api/client'
|
||||||
|
import type { Version } from '../types'
|
||||||
|
import { site, safeUrl, formatDate } from '../site'
|
||||||
|
import ProductGallery from '../components/ProductGallery.vue'
|
||||||
|
import PlatformLogo from '../components/PlatformLogo.vue'
|
||||||
|
const latest = ref<Version | null>(null)
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
latest.value = await request<Version | null>('/download/latest')
|
||||||
|
} catch {
|
||||||
|
/* download and repository remain available */
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: CalendarCheck2,
|
||||||
|
title: '自动预约',
|
||||||
|
text: '设定时间与座位偏好,自动完成预约流程。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Users,
|
||||||
|
title: '多用户管理',
|
||||||
|
text: '分组管理多个用户,批量操作更得心应手。',
|
||||||
|
},
|
||||||
|
{ icon: Clock3, title: '定时任务', text: '安排好预约与签到,让任务按时自动执行。' },
|
||||||
|
{ icon: Repeat2, title: '自动续约', text: '减少重复操作,让每一段专注自然延续。' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section class="home-hero container">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<p class="eyebrow">
|
||||||
|
<span class="status-dot" />
|
||||||
|
为北京建筑大学而构建
|
||||||
|
</p>
|
||||||
|
<h1>
|
||||||
|
让专注,
|
||||||
|
<br />
|
||||||
|
<span>从容开始。</span>
|
||||||
|
</h1>
|
||||||
|
<p class="hero-description">
|
||||||
|
图书馆里的每一次专注,都值得少一点繁琐。
|
||||||
|
<br class="desktop-break" />
|
||||||
|
让 AutoLibrary 帮你预约、签到与续约。
|
||||||
|
</p>
|
||||||
|
<div class="hero-bottom">
|
||||||
|
<a :href="site.manuals" class="text-link" target="_blank" rel="noopener">
|
||||||
|
从用户手册开始
|
||||||
|
<ArrowUpRight :size="17" />
|
||||||
|
</a>
|
||||||
|
<span class="hero-note">免费使用 · MIT 开源</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<aside class="release-card" aria-label="下载 AutoLibrary">
|
||||||
|
<div class="release-heading">
|
||||||
|
<img src="/assets/favicon.svg" alt="AutoLibrary" width="70" height="70" />
|
||||||
|
<div>
|
||||||
|
<p class="caption">{{ latest ? '最新稳定版本' : 'AutoLibrary 桌面端' }}</p>
|
||||||
|
<strong class="release-version">{{ latest?.version || '即刻开始' }}</strong>
|
||||||
|
<div v-if="latest" class="release-notes">
|
||||||
|
<a
|
||||||
|
:href="safeUrl(latest.releaseNotesUrl) || site.releases"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
发行说明
|
||||||
|
<ArrowUpRight :size="12" />
|
||||||
|
</a>
|
||||||
|
<span>{{ formatDate(latest.releaseDate) }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-else class="caption">让日常任务,自动完成。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="release-actions">
|
||||||
|
<RouterLink class="button primary full-width" to="/downloads">
|
||||||
|
<Download :size="18" />
|
||||||
|
下载 AutoLibrary
|
||||||
|
<ArrowRight :size="17" />
|
||||||
|
</RouterLink>
|
||||||
|
<a class="button full-width" :href="site.repository" target="_blank" rel="noopener">
|
||||||
|
<Code2 :size="18" />
|
||||||
|
源码仓库
|
||||||
|
<ArrowUpRight :size="16" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="release-platforms">
|
||||||
|
<PlatformLogo platform="windows" :size="17" />
|
||||||
|
<PlatformLogo platform="macos" :size="19" />
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
<section class="capabilities-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="capabilities-intro">
|
||||||
|
<h2 class="home-section-title">把时间,留给阅读。</h2>
|
||||||
|
</div>
|
||||||
|
<div class="capabilities-grid">
|
||||||
|
<article v-for="feature in features" :key="feature.title" class="capability">
|
||||||
|
<component :is="feature.icon" :size="23" :stroke-width="1.5" />
|
||||||
|
<div>
|
||||||
|
<h3>{{ feature.title }}</h3>
|
||||||
|
<p>{{ feature.text }}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<ProductGallery />
|
||||||
|
<section class="home-directory container">
|
||||||
|
<div class="directory-intro">
|
||||||
|
<BookOpen :size="34" :stroke-width="1.2" aria-hidden="true" />
|
||||||
|
<h2>接下来,去哪里?</h2>
|
||||||
|
<p>
|
||||||
|
一本手册,一种新风格,
|
||||||
|
<br />
|
||||||
|
或是一份让它变得更好的灵感。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<nav class="directory-links" aria-label="探索 AutoLibrary">
|
||||||
|
<a :href="site.manuals" target="_blank" rel="noopener" class="directory-link">
|
||||||
|
<span class="directory-index">01</span>
|
||||||
|
<span>
|
||||||
|
<strong>阅读用户手册</strong>
|
||||||
|
<span>安装、配置与进阶使用,从这里开始。</span>
|
||||||
|
</span>
|
||||||
|
<ArrowUpRight :size="19" />
|
||||||
|
</a>
|
||||||
|
<RouterLink to="/marketplace" class="directory-link">
|
||||||
|
<span class="directory-index">02</span>
|
||||||
|
<span>
|
||||||
|
<strong>找到你的风格</strong>
|
||||||
|
<span>探索社区主题,或分享自己的作品。</span>
|
||||||
|
</span>
|
||||||
|
<Palette :size="19" />
|
||||||
|
</RouterLink>
|
||||||
|
<a :href="site.repository" target="_blank" rel="noopener" class="directory-link">
|
||||||
|
<span class="directory-index">03</span>
|
||||||
|
<span>
|
||||||
|
<strong>一起,让它更好</strong>
|
||||||
|
<span>在 Gitea 浏览源码、提出建议,参与项目。</span>
|
||||||
|
</span>
|
||||||
|
<ArrowUpRight :size="19" />
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<style src="../styles/home.css" />
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { Search, Plus, ArrowUpRight } from '@lucide/vue'
|
||||||
|
import type { Theme, PageMeta } from '../types'
|
||||||
|
import { request } from '../api/client'
|
||||||
|
import { errorMessage, previewUrl } from '../site'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import ThemeCard from '../components/ThemeCard.vue'
|
||||||
|
import ThemeDetail from '../components/ThemeDetail.vue'
|
||||||
|
import AppDialog from '../components/AppDialog.vue'
|
||||||
|
import AsyncState from '../components/AsyncState.vue'
|
||||||
|
import Pagination from '../components/Pagination.vue'
|
||||||
|
const auth = useAuthStore(),
|
||||||
|
route = useRoute(),
|
||||||
|
router = useRouter()
|
||||||
|
const themes = ref<Theme[]>([]),
|
||||||
|
featured = ref<Theme[]>([]),
|
||||||
|
detail = ref<Theme | null>(null),
|
||||||
|
preview = ref<Theme | null>(null)
|
||||||
|
const search = ref(String(route.query.search || '')),
|
||||||
|
mode = ref(String(route.query.need_theme || '')),
|
||||||
|
tags = ref(String(route.query.tags || '')),
|
||||||
|
sort = ref(String(route.query.sort || 'popular'))
|
||||||
|
const page = ref(Math.max(1, Number(route.query.page) || 1)),
|
||||||
|
pages = ref(0),
|
||||||
|
total = ref(0),
|
||||||
|
loading = ref(true),
|
||||||
|
error = ref('')
|
||||||
|
let controller: AbortController | null = null
|
||||||
|
async function load(newPage = page.value) {
|
||||||
|
page.value = newPage
|
||||||
|
controller?.abort()
|
||||||
|
const current = new AbortController()
|
||||||
|
controller = current
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(page.value),
|
||||||
|
per_page: '9',
|
||||||
|
search: search.value.trim(),
|
||||||
|
need_theme: mode.value,
|
||||||
|
tags: tags.value.trim(),
|
||||||
|
sort: sort.value,
|
||||||
|
})
|
||||||
|
void router.replace({
|
||||||
|
query: Object.fromEntries([...params].filter(([key, value]) => value && key !== 'per_page')),
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const data = await request<{ themes: Theme[]; meta: PageMeta }>(
|
||||||
|
`/marketplace/themes?${params}`,
|
||||||
|
{ signal: current.signal },
|
||||||
|
)
|
||||||
|
themes.value = data.themes
|
||||||
|
pages.value = data.meta.total_pages
|
||||||
|
total.value = data.meta.total
|
||||||
|
} catch (e) {
|
||||||
|
if (!current.signal.aborted) error.value = errorMessage(e)
|
||||||
|
} finally {
|
||||||
|
if (!current.signal.aborted) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function viewPreviewDetails() {
|
||||||
|
detail.value = preview.value
|
||||||
|
preview.value = null
|
||||||
|
}
|
||||||
|
function upload() {
|
||||||
|
if (auth.isLoggedIn) void router.push('/marketplace/upload')
|
||||||
|
else auth.open('login', '/marketplace/upload')
|
||||||
|
}
|
||||||
|
watch(
|
||||||
|
() => route.query,
|
||||||
|
(query) => {
|
||||||
|
const next = {
|
||||||
|
search: String(query.search || ''),
|
||||||
|
mode: String(query.need_theme || ''),
|
||||||
|
tags: String(query.tags || ''),
|
||||||
|
sort: String(query.sort || 'popular'),
|
||||||
|
page: Math.max(1, Number(query.page) || 1),
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
next.search === search.value &&
|
||||||
|
next.mode === mode.value &&
|
||||||
|
next.tags === tags.value &&
|
||||||
|
next.sort === sort.value &&
|
||||||
|
next.page === page.value
|
||||||
|
)
|
||||||
|
return
|
||||||
|
search.value = next.search
|
||||||
|
mode.value = next.mode
|
||||||
|
tags.value = next.tags
|
||||||
|
sort.value = next.sort
|
||||||
|
page.value = next.page
|
||||||
|
void load()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
onMounted(async () => {
|
||||||
|
void load()
|
||||||
|
try {
|
||||||
|
featured.value = await request<Theme[]>('/marketplace/themes/featured?limit=3')
|
||||||
|
} catch {
|
||||||
|
/* featured is optional */
|
||||||
|
}
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => controller?.abort())
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="container page-container">
|
||||||
|
<header class="page-heading with-action">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">MAKE IT YOURS</p>
|
||||||
|
<h1>你的界面,你的风格。</h1>
|
||||||
|
<p>从社区的灵感中,找到属于你的 AutoLibrary。</p>
|
||||||
|
</div>
|
||||||
|
<button class="button" @click="upload">
|
||||||
|
<Plus :size="17" />
|
||||||
|
分享主题
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<section v-if="featured.length && !search && !mode && !tags" class="featured-section">
|
||||||
|
<div class="section-label">
|
||||||
|
<h2>社区精选</h2>
|
||||||
|
<span class="caption">
|
||||||
|
从这些受欢迎的主题开始
|
||||||
|
<ArrowUpRight :size="14" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="theme-grid">
|
||||||
|
<ThemeCard
|
||||||
|
v-for="theme in featured"
|
||||||
|
:key="theme.id"
|
||||||
|
:theme="theme"
|
||||||
|
@open="detail = $event"
|
||||||
|
@preview="preview = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<div class="section-label">
|
||||||
|
<h2>探索全部主题</h2>
|
||||||
|
<span class="caption">{{ total }} 个主题</span>
|
||||||
|
</div>
|
||||||
|
<form class="marketplace-filters" @submit.prevent="load(1)">
|
||||||
|
<label class="search-field">
|
||||||
|
<Search :size="18" />
|
||||||
|
<span class="sr-only">搜索主题</span>
|
||||||
|
<input v-model="search" type="search" placeholder="搜索名称、作者或关键词…" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span class="sr-only">外观模式</span>
|
||||||
|
<select v-model="mode" @change="load(1)">
|
||||||
|
<option value="">全部外观</option>
|
||||||
|
<option value="dark">深色</option>
|
||||||
|
<option value="light">浅色</option>
|
||||||
|
<option value="both">深浅通用</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="tag-filter">
|
||||||
|
<span class="sr-only">标签</span>
|
||||||
|
<input v-model="tags" placeholder="标签,逗号分隔" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span class="sr-only">排序</span>
|
||||||
|
<select v-model="sort" @change="load(1)">
|
||||||
|
<option value="popular">最受欢迎</option>
|
||||||
|
<option value="newest">最新发布</option>
|
||||||
|
<option value="name">名称排序</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="button small" type="submit">搜索</button>
|
||||||
|
</form>
|
||||||
|
<AsyncState
|
||||||
|
:loading="loading"
|
||||||
|
:error="error"
|
||||||
|
:empty="!loading && !error && themes.length === 0"
|
||||||
|
message="没有找到匹配的主题,试试其他关键词吧。"
|
||||||
|
@retry="load()"
|
||||||
|
/>
|
||||||
|
<div v-if="!loading && !error" class="theme-grid">
|
||||||
|
<ThemeCard
|
||||||
|
v-for="theme in themes"
|
||||||
|
:key="theme.id"
|
||||||
|
:theme="theme"
|
||||||
|
@open="detail = $event"
|
||||||
|
@preview="preview = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Pagination :page="page" :pages="pages" :busy="loading" @change="load($event)" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<ThemeDetail v-if="detail" :theme="detail" @close="detail = null" />
|
||||||
|
<AppDialog v-if="preview" :title="preview.name" wide @close="preview = null">
|
||||||
|
<img class="lightbox-image" :src="previewUrl(preview.previewUrl)" :alt="preview.name" />
|
||||||
|
<button class="button full-width" @click="viewPreviewDetails">查看主题详情</button>
|
||||||
|
</AppDialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { Plus, Pencil, Trash2, BadgeCheck, ArrowRight } from '@lucide/vue'
|
||||||
|
import type { Theme, ThemeFields } from '../types'
|
||||||
|
import { request, json } from '../api/client'
|
||||||
|
import { errorMessage, previewUrl } from '../site'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import AsyncState from '../components/AsyncState.vue'
|
||||||
|
import ThemeCard from '../components/ThemeCard.vue'
|
||||||
|
import ThemeEditor from '../components/ThemeEditor.vue'
|
||||||
|
import ThemeDetail from '../components/ThemeDetail.vue'
|
||||||
|
import AppDialog from '../components/AppDialog.vue'
|
||||||
|
const auth = useAuthStore(),
|
||||||
|
route = useRoute(),
|
||||||
|
router = useRouter()
|
||||||
|
const themes = ref<Theme[]>([]),
|
||||||
|
loading = ref(false),
|
||||||
|
error = ref(''),
|
||||||
|
notice = ref(route.query.submitted ? '主题已提交,审核通过后会出现在主题市场。' : '')
|
||||||
|
const editing = ref<Theme | null>(null),
|
||||||
|
removing = ref<Theme | null>(null),
|
||||||
|
detail = ref<Theme | null>(null)
|
||||||
|
const fields = ref<ThemeFields>({
|
||||||
|
name: '',
|
||||||
|
brief: '',
|
||||||
|
description: '',
|
||||||
|
need_theme: 'both',
|
||||||
|
tags: [],
|
||||||
|
version: '',
|
||||||
|
compat_version: '',
|
||||||
|
})
|
||||||
|
const file = ref<File | null>(null),
|
||||||
|
preview = ref<File | null>(null),
|
||||||
|
busy = ref(false),
|
||||||
|
actionError = ref('')
|
||||||
|
let generation = 0
|
||||||
|
async function load() {
|
||||||
|
const current = ++generation
|
||||||
|
if (!auth.isLoggedIn) {
|
||||||
|
themes.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const data = await request<Theme[]>('/marketplace/user/themes', {}, true)
|
||||||
|
if (current === generation && auth.isLoggedIn) themes.value = data
|
||||||
|
} catch (e) {
|
||||||
|
if (current === generation) error.value = errorMessage(e)
|
||||||
|
} finally {
|
||||||
|
if (current === generation) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function edit(theme: Theme) {
|
||||||
|
editing.value = theme
|
||||||
|
file.value = null
|
||||||
|
preview.value = null
|
||||||
|
actionError.value = ''
|
||||||
|
fields.value = {
|
||||||
|
name: theme.name,
|
||||||
|
brief: theme.brief,
|
||||||
|
description: theme.description || '',
|
||||||
|
need_theme: theme.needTheme,
|
||||||
|
tags: [...(theme.tags || [])],
|
||||||
|
version: theme.version,
|
||||||
|
compat_version: theme.compatVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function save() {
|
||||||
|
if (!editing.value) return
|
||||||
|
busy.value = true
|
||||||
|
actionError.value = ''
|
||||||
|
let changed = false
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
file.value &&
|
||||||
|
(!file.value.name.toLowerCase().endsWith('.qss') || file.value.size > 8 * 1024 * 1024)
|
||||||
|
)
|
||||||
|
throw new Error('主题文件需为 .qss,且不超过 8 MB')
|
||||||
|
if (
|
||||||
|
preview.value &&
|
||||||
|
(!['image/png', 'image/jpeg'].includes(preview.value.type) ||
|
||||||
|
preview.value.size > 4 * 1024 * 1024)
|
||||||
|
)
|
||||||
|
throw new Error('预览图需为 PNG / JPG,且不超过 4 MB')
|
||||||
|
const id = editing.value.id
|
||||||
|
if (file.value) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file.value)
|
||||||
|
await request(`/marketplace/themes/${id}/file`, { method: 'POST', body: form }, true)
|
||||||
|
file.value = null
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (preview.value) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('preview', preview.value)
|
||||||
|
await request(`/marketplace/themes/${id}/preview`, { method: 'POST', body: form }, true)
|
||||||
|
preview.value = null
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
await request(`/marketplace/themes/${id}`, json('PUT', fields.value), true)
|
||||||
|
editing.value = null
|
||||||
|
notice.value = '修改已保存,主题已重新进入审核队列。'
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
actionError.value = `${changed ? '部分文件已保存,主题需重新审核。其余修改未完成:' : ''}${errorMessage(e)}`
|
||||||
|
if (changed) void load()
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function confirmRemove(theme: Theme) {
|
||||||
|
removing.value = theme
|
||||||
|
actionError.value = ''
|
||||||
|
}
|
||||||
|
async function remove() {
|
||||||
|
if (!removing.value) return
|
||||||
|
busy.value = true
|
||||||
|
actionError.value = ''
|
||||||
|
try {
|
||||||
|
await request(`/marketplace/themes/${removing.value.id}`, { method: 'DELETE' }, true)
|
||||||
|
removing.value = null
|
||||||
|
notice.value = '主题已删除。'
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
actionError.value = errorMessage(e)
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch(
|
||||||
|
() => auth.isLoggedIn,
|
||||||
|
() => {
|
||||||
|
generation++
|
||||||
|
editing.value = null
|
||||||
|
removing.value = null
|
||||||
|
detail.value = null
|
||||||
|
void load()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
onMounted(async () => {
|
||||||
|
await auth.init()
|
||||||
|
if (route.query.tab === 'review' && auth.user?.is_admin) {
|
||||||
|
await router.replace('/marketplace/admin')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await load()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="container page-container">
|
||||||
|
<header class="page-heading with-action">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">YOUR CREATIVE SPACE</p>
|
||||||
|
<h1>
|
||||||
|
{{ auth.user?.display_name || auth.user?.user_name || '个人中心' }}
|
||||||
|
<BadgeCheck v-if="auth.user?.is_verified" class="accent" :size="28" />
|
||||||
|
</h1>
|
||||||
|
<p>你的主题与灵感,都在这里。</p>
|
||||||
|
</div>
|
||||||
|
<RouterLink v-if="auth.isLoggedIn" class="button" to="/marketplace/upload">
|
||||||
|
<Plus :size="17" />
|
||||||
|
上传新主题
|
||||||
|
</RouterLink>
|
||||||
|
</header>
|
||||||
|
<section v-if="!auth.isLoggedIn" class="panel login-prompt">
|
||||||
|
<h2>登录,回到你的创作空间。</h2>
|
||||||
|
<p>查看已上传主题,管理版本与审核状态。</p>
|
||||||
|
<button class="button primary" @click="auth.open('login', '/marketplace/profile')">
|
||||||
|
登录 / 注册
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
<template v-else>
|
||||||
|
<RouterLink v-if="auth.user?.is_admin" class="admin-entry" to="/marketplace/admin">
|
||||||
|
<span>
|
||||||
|
管理员工作区
|
||||||
|
<strong>查看主题审核队列</strong>
|
||||||
|
</span>
|
||||||
|
<ArrowRight :size="20" />
|
||||||
|
</RouterLink>
|
||||||
|
<p v-if="notice" class="notice" role="status">{{ notice }}</p>
|
||||||
|
<div class="section-label">
|
||||||
|
<h2>我的主题</h2>
|
||||||
|
<span class="caption">{{ themes.length }} 个主题</span>
|
||||||
|
</div>
|
||||||
|
<AsyncState
|
||||||
|
:loading="loading"
|
||||||
|
:error="error"
|
||||||
|
:empty="!loading && !error && !themes.length"
|
||||||
|
message="还没有上传过主题,分享你的第一个作品吧。"
|
||||||
|
@retry="load"
|
||||||
|
/>
|
||||||
|
<div v-if="!loading && !error" class="theme-grid">
|
||||||
|
<ThemeCard
|
||||||
|
v-for="theme in themes"
|
||||||
|
:key="theme.id"
|
||||||
|
:theme="theme"
|
||||||
|
@open="theme.status === 'approved' ? (detail = $event) : edit(theme)"
|
||||||
|
@preview="edit(theme)"
|
||||||
|
>
|
||||||
|
<div class="my-theme-status">
|
||||||
|
<span class="badge" :class="{ neutral: theme.status !== 'approved' }">
|
||||||
|
{{ { approved: '已发布', pending: '待审核', rejected: '已退回' }[theme.status] }}
|
||||||
|
</span>
|
||||||
|
<span v-if="theme.reviewComment" class="caption">{{ theme.reviewComment }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="button small" @click="edit(theme)">
|
||||||
|
<Pencil :size="14" />
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button class="button small danger" @click="confirmRemove(theme)">
|
||||||
|
<Trash2 :size="14" />
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ThemeCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<AppDialog
|
||||||
|
v-if="editing && auth.isLoggedIn"
|
||||||
|
title="编辑主题"
|
||||||
|
wide
|
||||||
|
@close="!busy && (editing = null)"
|
||||||
|
>
|
||||||
|
<form class="form-stack" @submit.prevent="save">
|
||||||
|
<p class="notice">保存任何修改后,主题会重新进入审核队列。</p>
|
||||||
|
<ThemeEditor
|
||||||
|
v-model="fields"
|
||||||
|
:preview="previewUrl(editing.previewUrl)"
|
||||||
|
@file="file = $event"
|
||||||
|
@preview="preview = $event"
|
||||||
|
/>
|
||||||
|
<p v-if="actionError" class="form-error" role="alert">{{ actionError }}</p>
|
||||||
|
<button class="button primary" :disabled="busy">
|
||||||
|
{{ busy ? '保存中…' : '保存修改并提交审核' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</AppDialog>
|
||||||
|
<AppDialog
|
||||||
|
v-if="removing && auth.isLoggedIn"
|
||||||
|
title="删除主题"
|
||||||
|
@close="!busy && (removing = null)"
|
||||||
|
>
|
||||||
|
<p>确定删除「{{ removing.name }}」吗?主题文件和预览将一同删除,此操作无法撤销。</p>
|
||||||
|
<p v-if="actionError" class="form-error" role="alert">{{ actionError }}</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button" :disabled="busy" @click="removing = null">取消</button>
|
||||||
|
<button class="button danger" :disabled="busy" @click="remove">
|
||||||
|
{{ busy ? '删除中…' : '确认删除' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</AppDialog>
|
||||||
|
<ThemeDetail v-if="detail" :theme="detail" @close="detail = null" />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onBeforeUnmount } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Upload, Check, ArrowLeft, ArrowRight, Save } from '@lucide/vue'
|
||||||
|
import type { ThemeFields } from '../types'
|
||||||
|
import { request } from '../api/client'
|
||||||
|
import { errorMessage, modeLabel } from '../site'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import ThemeEditor from '../components/ThemeEditor.vue'
|
||||||
|
const auth = useAuthStore(),
|
||||||
|
router = useRouter()
|
||||||
|
const fields = ref<ThemeFields>({
|
||||||
|
name: '',
|
||||||
|
brief: '',
|
||||||
|
description: '',
|
||||||
|
need_theme: 'both',
|
||||||
|
tags: [],
|
||||||
|
version: '1.0.0',
|
||||||
|
compat_version: '1.0.0',
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem('theme_draft') || 'null')
|
||||||
|
if (saved && typeof saved === 'object')
|
||||||
|
fields.value = {
|
||||||
|
name: String(saved.name || ''),
|
||||||
|
brief: String(saved.brief || ''),
|
||||||
|
description: String(saved.description || saved.desc || ''),
|
||||||
|
need_theme: ['dark', 'light', 'both'].includes(saved.need_theme || saved.mode)
|
||||||
|
? saved.need_theme || saved.mode
|
||||||
|
: 'both',
|
||||||
|
tags: Array.isArray(saved.tags) ? saved.tags.map(String) : [],
|
||||||
|
version: String(saved.version || '1.0.0'),
|
||||||
|
compat_version: String(saved.compat_version || saved.compat || '1.0.0'),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed drafts */
|
||||||
|
}
|
||||||
|
const step = ref(1),
|
||||||
|
file = ref<File | null>(null),
|
||||||
|
preview = ref<File | null>(null),
|
||||||
|
previewObjectUrl = ref('')
|
||||||
|
const agreed = ref(false),
|
||||||
|
busy = ref(false),
|
||||||
|
error = ref(''),
|
||||||
|
notice = ref('')
|
||||||
|
watch(preview, (value) => {
|
||||||
|
if (previewObjectUrl.value) URL.revokeObjectURL(previewObjectUrl.value)
|
||||||
|
previewObjectUrl.value = value ? URL.createObjectURL(value) : ''
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (previewObjectUrl.value) URL.revokeObjectURL(previewObjectUrl.value)
|
||||||
|
})
|
||||||
|
function chooseFile(event: Event) {
|
||||||
|
file.value = (event.target as HTMLInputElement).files?.[0] || null
|
||||||
|
error.value = ''
|
||||||
|
}
|
||||||
|
function drop(event: DragEvent) {
|
||||||
|
file.value = event.dataTransfer?.files[0] || null
|
||||||
|
error.value = ''
|
||||||
|
}
|
||||||
|
function validateFiles() {
|
||||||
|
if (!file.value || !file.value.name.toLowerCase().endsWith('.qss'))
|
||||||
|
throw new Error('请选择 .qss 格式的主题文件')
|
||||||
|
if (file.value.size > 8 * 1024 * 1024) throw new Error('主题文件不能超过 8 MB')
|
||||||
|
if (
|
||||||
|
preview.value &&
|
||||||
|
(!['image/png', 'image/jpeg'].includes(preview.value.type) ||
|
||||||
|
preview.value.size > 4 * 1024 * 1024)
|
||||||
|
)
|
||||||
|
throw new Error('预览图需为 PNG / JPG,且不超过 4 MB')
|
||||||
|
}
|
||||||
|
function back() {
|
||||||
|
step.value = Math.max(1, step.value - 1)
|
||||||
|
error.value = ''
|
||||||
|
}
|
||||||
|
async function next() {
|
||||||
|
error.value = ''
|
||||||
|
notice.value = ''
|
||||||
|
try {
|
||||||
|
validateFiles()
|
||||||
|
if (step.value === 1) {
|
||||||
|
step.value = 2
|
||||||
|
return
|
||||||
|
}
|
||||||
|
busy.value = true
|
||||||
|
const exists = await request<boolean>(
|
||||||
|
`/marketplace/user/check-theme-name?name=${encodeURIComponent(fields.value.name.trim())}`,
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
if (exists) throw new Error('你已经上传过同名主题,请更换名称')
|
||||||
|
step.value = 3
|
||||||
|
} catch (e) {
|
||||||
|
error.value = errorMessage(e)
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function saveDraft() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('theme_draft', JSON.stringify(fields.value))
|
||||||
|
notice.value = '文字草稿已保存至当前浏览器。主题文件与预览图需重新选择。'
|
||||||
|
} catch {
|
||||||
|
error.value = '草稿保存失败,浏览器存储不可用'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function submit() {
|
||||||
|
busy.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
validateFiles()
|
||||||
|
const form = new FormData()
|
||||||
|
form.append(
|
||||||
|
'metadata',
|
||||||
|
JSON.stringify({
|
||||||
|
...fields.value,
|
||||||
|
name: fields.value.name.trim(),
|
||||||
|
brief: fields.value.brief.trim(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
form.append('file', file.value!)
|
||||||
|
if (preview.value) form.append('preview', preview.value)
|
||||||
|
await request('/marketplace/themes', { method: 'POST', body: form }, true)
|
||||||
|
localStorage.removeItem('theme_draft')
|
||||||
|
await router.push('/marketplace/profile?submitted=1')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = errorMessage(e)
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="container page-container narrow-page">
|
||||||
|
<header class="page-heading">
|
||||||
|
<p class="eyebrow">SHARE YOUR CREATIVITY</p>
|
||||||
|
<h1>把你的风格,分享出去。</h1>
|
||||||
|
<p>上传主题文件,经过审核后与社区见面。</p>
|
||||||
|
</header>
|
||||||
|
<section v-if="!auth.isLoggedIn" class="panel login-prompt">
|
||||||
|
<Upload :size="30" />
|
||||||
|
<h2>登录后分享你的主题</h2>
|
||||||
|
<p>已有草稿会保存在这个浏览器中。</p>
|
||||||
|
<button class="button primary" @click="auth.open('login', '/marketplace/upload')">
|
||||||
|
登录 / 注册
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
<template v-else>
|
||||||
|
<ol class="steps" aria-label="上传进度">
|
||||||
|
<li
|
||||||
|
v-for="(title, index) in ['选择文件', '填写信息', '确认提交']"
|
||||||
|
:key="title"
|
||||||
|
:class="{ active: step === index + 1, done: step > index + 1 }"
|
||||||
|
:aria-current="step === index + 1 ? 'step' : undefined"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Check v-if="step > index + 1" :size="15" />
|
||||||
|
<template v-else>{{ index + 1 }}</template>
|
||||||
|
</span>
|
||||||
|
{{ title }}
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<section class="panel upload-panel">
|
||||||
|
<form v-if="step < 3" @submit.prevent="next">
|
||||||
|
<div v-if="step === 1">
|
||||||
|
<label class="drop-zone" @dragover.prevent @drop.prevent="drop">
|
||||||
|
<Upload :size="32" :stroke-width="1.5" />
|
||||||
|
<strong>{{ file?.name || '选择或拖放主题文件' }}</strong>
|
||||||
|
<span>支持 .qss 格式,最大 8 MB</span>
|
||||||
|
<input type="file" accept=".qss" aria-label="选择主题文件" @change="chooseFile" />
|
||||||
|
</label>
|
||||||
|
<p class="caption form-hint">主题将自动打包为 AutoLibrary 可识别的 .altheme 文件。</p>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<ThemeEditor
|
||||||
|
v-model="fields"
|
||||||
|
:preview="previewObjectUrl"
|
||||||
|
@file="file = $event || file"
|
||||||
|
@preview="preview = $event"
|
||||||
|
/>
|
||||||
|
<p class="caption form-hint">已选主题文件:{{ file?.name }}。可在上方替换文件。</p>
|
||||||
|
</template>
|
||||||
|
<p v-if="error" class="form-error" role="alert">{{ error }}</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button v-if="step > 1" type="button" class="button" :disabled="busy" @click="back">
|
||||||
|
<ArrowLeft :size="16" />
|
||||||
|
上一步
|
||||||
|
</button>
|
||||||
|
<button type="button" class="text-button" @click="saveDraft">
|
||||||
|
<Save :size="16" />
|
||||||
|
保存草稿
|
||||||
|
</button>
|
||||||
|
<button class="button primary" :disabled="busy">
|
||||||
|
{{ busy ? '检查中…' : '下一步' }}
|
||||||
|
<ArrowRight :size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form v-else class="form-stack" @submit.prevent="submit">
|
||||||
|
<p class="eyebrow">准备就绪</p>
|
||||||
|
<img
|
||||||
|
v-if="previewObjectUrl"
|
||||||
|
:src="previewObjectUrl"
|
||||||
|
class="detail-preview"
|
||||||
|
alt="主题预览"
|
||||||
|
/>
|
||||||
|
<h2>{{ fields.name }}</h2>
|
||||||
|
<p>{{ fields.brief }}</p>
|
||||||
|
<p class="detail-description muted">{{ fields.description }}</p>
|
||||||
|
<div class="tags">
|
||||||
|
<span>{{ modeLabel(fields.need_theme) }}</span>
|
||||||
|
<span>{{ fields.version }}</span>
|
||||||
|
<span v-for="tag in fields.tags" :key="tag">{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="caption">{{ file?.name }} · 兼容 AutoLibrary {{ fields.compat_version }}+</p>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input v-model="agreed" type="checkbox" required />
|
||||||
|
我确认拥有主题内容的发布权利,并同意提交审核。
|
||||||
|
</label>
|
||||||
|
<p v-if="error" class="form-error" role="alert">{{ error }}</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="button" :disabled="busy" @click="back">
|
||||||
|
<ArrowLeft :size="16" />
|
||||||
|
返回编辑
|
||||||
|
</button>
|
||||||
|
<button class="button primary" :disabled="busy || !agreed">
|
||||||
|
{{ busy ? '正在提交…' : '提交审核' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p v-if="notice" class="notice" role="status">{{ notice }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: () => import('./pages/HomePage.vue'),
|
||||||
|
meta: { title: '让专注,从容开始' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/downloads',
|
||||||
|
component: () => import('./pages/DownloadsPage.vue'),
|
||||||
|
meta: { title: '软件下载' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/marketplace',
|
||||||
|
component: () => import('./pages/MarketplacePage.vue'),
|
||||||
|
meta: { title: '主题市场' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/marketplace/upload',
|
||||||
|
component: () => import('./pages/UploadPage.vue'),
|
||||||
|
meta: { title: '分享主题' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/marketplace/profile',
|
||||||
|
component: () => import('./pages/ProfilePage.vue'),
|
||||||
|
meta: { title: '个人中心' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/marketplace/admin',
|
||||||
|
component: () => import('./pages/AdminPage.vue'),
|
||||||
|
meta: { title: '主题审核' },
|
||||||
|
},
|
||||||
|
{ path: '/changelog', redirect: '/downloads' },
|
||||||
|
{
|
||||||
|
path: '/errors/:code(400|401|403|404|500|502|503)',
|
||||||
|
component: () => import('./pages/ErrorPage.vue'),
|
||||||
|
meta: { title: '请求错误' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
component: () => import('./pages/ErrorPage.vue'),
|
||||||
|
meta: { title: '页面未找到' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scrollBehavior(to, from, saved) {
|
||||||
|
if (saved) return saved
|
||||||
|
if (to.hash && to.path === '/') return { el: to.hash, top: 100, behavior: 'smooth' }
|
||||||
|
if (to.path === from.path) return false
|
||||||
|
return { top: 0 }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
router.afterEach((to) => {
|
||||||
|
document.title = `AutoLibrary · ${String(to.meta.title || '图书馆自动化')}`
|
||||||
|
})
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export const site = {
|
||||||
|
repository: 'https://gitea.kenanzhu.com/KenanZhu/AutoLibrary',
|
||||||
|
releases: 'https://gitea.kenanzhu.com/KenanZhu/AutoLibrary/releases',
|
||||||
|
issues: 'https://gitea.kenanzhu.com/KenanZhu/AutoLibrary/issues',
|
||||||
|
manuals: 'https://manuals.autolibrary.kenanzhu.com',
|
||||||
|
}
|
||||||
|
export function safeUrl(value?: string): string {
|
||||||
|
if (!value) return ''
|
||||||
|
try {
|
||||||
|
const url = new URL(value, location.origin)
|
||||||
|
return ['http:', 'https:'].includes(url.protocol) ? url.href : ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function previewUrl(value?: string): string {
|
||||||
|
if (!value) return ''
|
||||||
|
return safeUrl(value.startsWith('/marketplace/') ? `/api${value}` : value)
|
||||||
|
}
|
||||||
|
export function formatDate(value: string): string {
|
||||||
|
const date = new Date(value)
|
||||||
|
return Number.isNaN(date.getTime())
|
||||||
|
? '日期待补充'
|
||||||
|
: new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
})
|
||||||
|
.format(date)
|
||||||
|
.replaceAll('/', '-')
|
||||||
|
}
|
||||||
|
export function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : '操作失败,请稍后重试'
|
||||||
|
}
|
||||||
|
export function modeLabel(mode: string): string {
|
||||||
|
return { dark: '深色', light: '浅色', both: '深浅通用' }[mode] || mode
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { request, json } from '../api/client'
|
||||||
|
import type { UserRole, AuthConfig } from '../types'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const token = ref(localStorage.getItem('casdoor_token') || '')
|
||||||
|
const user = ref<UserRole | null>(null)
|
||||||
|
const modal = ref<'login' | 'signup' | null>(null)
|
||||||
|
const returnTo = ref('')
|
||||||
|
const ready = ref(false)
|
||||||
|
const isLoggedIn = computed(() => Boolean(token.value))
|
||||||
|
let pending: Promise<void> | null = null
|
||||||
|
function logout() {
|
||||||
|
token.value = ''
|
||||||
|
user.value = null
|
||||||
|
localStorage.removeItem('casdoor_token')
|
||||||
|
localStorage.removeItem('casdoor_user')
|
||||||
|
}
|
||||||
|
window.addEventListener('session-expired', () => {
|
||||||
|
logout()
|
||||||
|
modal.value = 'login'
|
||||||
|
})
|
||||||
|
window.addEventListener('storage', (event) => {
|
||||||
|
if (event.key === 'casdoor_token') {
|
||||||
|
token.value = event.newValue || ''
|
||||||
|
ready.value = false
|
||||||
|
user.value = null
|
||||||
|
void init()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
async function init() {
|
||||||
|
if (ready.value) return
|
||||||
|
if (pending) return pending
|
||||||
|
pending = (async () => {
|
||||||
|
if (token.value) {
|
||||||
|
try {
|
||||||
|
user.value = await request<UserRole>('/marketplace/user/role', {}, true)
|
||||||
|
} catch {
|
||||||
|
user.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ready.value = true
|
||||||
|
})().finally(() => {
|
||||||
|
pending = null
|
||||||
|
})
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
async function login(username: string, password: string) {
|
||||||
|
const config = await request<AuthConfig>('/marketplace/auth/config')
|
||||||
|
if (!config.casdoor_configured) throw new Error('当前服务器尚未启用账户服务')
|
||||||
|
const result = await request<{ access_token?: string; error_description?: string }>(
|
||||||
|
'/marketplace/auth/login',
|
||||||
|
json('POST', { username, password }),
|
||||||
|
)
|
||||||
|
if (!result.access_token) throw new Error(result.error_description || '账户或密码错误')
|
||||||
|
token.value = result.access_token
|
||||||
|
localStorage.setItem('casdoor_token', token.value)
|
||||||
|
localStorage.setItem('casdoor_user', JSON.stringify({ name: username }))
|
||||||
|
ready.value = false
|
||||||
|
await init()
|
||||||
|
modal.value = null
|
||||||
|
}
|
||||||
|
function open(mode: 'login' | 'signup' = 'login', redirect = '') {
|
||||||
|
returnTo.value = redirect
|
||||||
|
modal.value = mode
|
||||||
|
}
|
||||||
|
return { user, ready, modal, returnTo, isLoggedIn, init, login, logout, open }
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export const useThemeStore = defineStore('theme', () => {
|
||||||
|
let stored = 'system'
|
||||||
|
try {
|
||||||
|
stored = localStorage.getItem('autolibrary-theme') || 'system'
|
||||||
|
} catch {
|
||||||
|
/* use system */
|
||||||
|
}
|
||||||
|
const preference = ref(['light', 'dark', 'system'].includes(stored) ? stored : 'system')
|
||||||
|
const current = ref(document.documentElement.dataset.theme || 'light')
|
||||||
|
const media = matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
function apply() {
|
||||||
|
const theme =
|
||||||
|
preference.value === 'system' ? (media.matches ? 'dark' : 'light') : preference.value
|
||||||
|
document.documentElement.dataset.theme = theme
|
||||||
|
current.value = theme
|
||||||
|
document
|
||||||
|
.querySelector('meta[name="theme-color"]')
|
||||||
|
?.setAttribute('content', theme === 'dark' ? '#101b29' : '#f8f9f6')
|
||||||
|
}
|
||||||
|
media.addEventListener('change', apply)
|
||||||
|
watch(
|
||||||
|
preference,
|
||||||
|
() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('autolibrary-theme', preference.value)
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable */
|
||||||
|
}
|
||||||
|
apply()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
function toggle() {
|
||||||
|
preference.value = current.value === 'dark' ? 'light' : 'dark'
|
||||||
|
}
|
||||||
|
return { preference, current, toggle }
|
||||||
|
})
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,356 @@
|
|||||||
|
/* The home page borrows its structure from a library catalogue: a short
|
||||||
|
introduction, a visual index, and a reading list, each with its own rhythm. */
|
||||||
|
.capabilities-section {
|
||||||
|
border-block: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
padding-block: 38px 42px;
|
||||||
|
}
|
||||||
|
.capabilities-intro,
|
||||||
|
.gallery-masthead {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
min-height: 32px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.home-section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.capabilities-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
.capability {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.capability > svg {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.capability h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.capability p {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.9;
|
||||||
|
}
|
||||||
|
.gallery-section {
|
||||||
|
padding-block: 64px 68px;
|
||||||
|
}
|
||||||
|
.gallery-window {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 9px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.gallery-window-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 15px;
|
||||||
|
gap: 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.gallery-window-bar > span:last-child {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.gallery-visual {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
.gallery-visual img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.gallery-fade-enter-active,
|
||||||
|
.gallery-fade-leave-active {
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
}
|
||||||
|
.gallery-fade-enter-from,
|
||||||
|
.gallery-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.gallery-window figcaption {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 12px 15px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.gallery-window figcaption > span {
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.gallery-chapters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
margin-bottom: 26px;
|
||||||
|
}
|
||||||
|
.gallery-chapter {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
padding: 17px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
color 0.2s;
|
||||||
|
}
|
||||||
|
.gallery-chapter:hover {
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.gallery-chapter.active {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.gallery-chapter-number {
|
||||||
|
font-size: 10px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.gallery-chapter.active .gallery-chapter-number {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.gallery-progress {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: -1px;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--accent);
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
|
}
|
||||||
|
.gallery-progress.running {
|
||||||
|
animation: gallery-reading linear forwards;
|
||||||
|
}
|
||||||
|
@keyframes gallery-reading {
|
||||||
|
to {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.gallery-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.gallery-controls .icon-button {
|
||||||
|
width: 29px;
|
||||||
|
height: 29px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.home-directory {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.6fr;
|
||||||
|
gap: 80px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-block: 57px 77px;
|
||||||
|
}
|
||||||
|
.directory-intro {
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
.directory-intro > svg {
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 17px;
|
||||||
|
}
|
||||||
|
.directory-intro h2 {
|
||||||
|
font-size: 26px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
.directory-intro p {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
.directory-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 23px;
|
||||||
|
padding: 22px 5px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.directory-link:first-child {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.directory-index {
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: -0.07em;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.directory-link > span:nth-child(2) {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.directory-link strong {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 550;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.directory-link > span:nth-child(2) > span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.directory-link > svg {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--muted);
|
||||||
|
transition:
|
||||||
|
transform 0.2s,
|
||||||
|
color 0.2s;
|
||||||
|
}
|
||||||
|
.directory-link:hover .directory-index,
|
||||||
|
.directory-link:hover strong,
|
||||||
|
.directory-link:hover > svg {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.directory-link:hover > svg {
|
||||||
|
transform: translate(2px, -2px);
|
||||||
|
}
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.capabilities-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 25px 35px;
|
||||||
|
}
|
||||||
|
.gallery-chapter {
|
||||||
|
gap: 12px;
|
||||||
|
padding-block: 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.home-directory {
|
||||||
|
gap: 40px;
|
||||||
|
grid-template-columns: 1fr 1.5fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.capabilities-section {
|
||||||
|
padding-block: 29px 32px;
|
||||||
|
}
|
||||||
|
.capabilities-intro,
|
||||||
|
.gallery-masthead {
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.home-section-title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.capabilities-grid {
|
||||||
|
gap: 23px 20px;
|
||||||
|
}
|
||||||
|
.capability {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.capability > svg {
|
||||||
|
width: 21px;
|
||||||
|
height: 21px;
|
||||||
|
margin: 0 0 11px;
|
||||||
|
}
|
||||||
|
.capability h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.capability p {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.gallery-section {
|
||||||
|
padding-block: 36px 40px;
|
||||||
|
}
|
||||||
|
.gallery-window-bar {
|
||||||
|
padding: 9px 12px;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
.gallery-window figcaption {
|
||||||
|
display: block;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.8;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
.gallery-window figcaption > span {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
.gallery-chapters {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.gallery-chapter {
|
||||||
|
font-size: 10px;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 0;
|
||||||
|
}
|
||||||
|
.gallery-controls .icon-button {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
.home-directory {
|
||||||
|
display: block;
|
||||||
|
padding-block: 35px 45px;
|
||||||
|
}
|
||||||
|
.directory-intro {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
.directory-intro > svg {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.directory-intro h2 {
|
||||||
|
font-size: 23px;
|
||||||
|
margin-bottom: 9px;
|
||||||
|
}
|
||||||
|
.directory-intro p {
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
.directory-intro br {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.directory-link {
|
||||||
|
padding-block: 19px;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.directory-link strong {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.directory-link > span:nth-child(2) > span {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.gallery-fade-enter-active,
|
||||||
|
.gallery-fade-leave-active {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
.gallery-progress.running {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export interface PageMeta {
|
||||||
|
page: number
|
||||||
|
per_page: number
|
||||||
|
total: number
|
||||||
|
total_pages: number
|
||||||
|
has_next: boolean
|
||||||
|
}
|
||||||
|
export interface Version {
|
||||||
|
id: number
|
||||||
|
version: string
|
||||||
|
type: string
|
||||||
|
releaseDate: string
|
||||||
|
fileSize: string
|
||||||
|
supportOS: string
|
||||||
|
features: string
|
||||||
|
giteaZipUrl: string
|
||||||
|
giteaInstallerUrl: string
|
||||||
|
giteaDmgUrl: string
|
||||||
|
releaseNotesUrl: string
|
||||||
|
cloudZipUrl: string
|
||||||
|
cloudInstallerUrl: string
|
||||||
|
cloudDmgUrl: string
|
||||||
|
cloudPassword: string
|
||||||
|
}
|
||||||
|
export interface Theme {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
author: string
|
||||||
|
userId: string
|
||||||
|
needTheme: 'dark' | 'light' | 'both'
|
||||||
|
brief: string
|
||||||
|
description: string
|
||||||
|
tags: string[]
|
||||||
|
status: 'pending' | 'approved' | 'rejected'
|
||||||
|
fileSize: string
|
||||||
|
previewUrl: string
|
||||||
|
downloadCount: number
|
||||||
|
version: string
|
||||||
|
compatVersion: string
|
||||||
|
reviewedBy: string
|
||||||
|
reviewComment: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
verified: boolean
|
||||||
|
}
|
||||||
|
export interface UserRole {
|
||||||
|
user_id: string
|
||||||
|
user_name: string
|
||||||
|
display_name: string
|
||||||
|
is_admin: boolean
|
||||||
|
is_verified: boolean
|
||||||
|
}
|
||||||
|
export interface AuthConfig {
|
||||||
|
casdoor_configured: boolean
|
||||||
|
}
|
||||||
|
export interface ThemeFields {
|
||||||
|
name: string
|
||||||
|
brief: string
|
||||||
|
description: string
|
||||||
|
need_theme: string
|
||||||
|
tags: string[]
|
||||||
|
version: string
|
||||||
|
compat_version: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client", "node"],
|
||||||
|
"noEmit": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
|
return {
|
||||||
|
plugins: [vue()],
|
||||||
|
publicDir: 'public',
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: env.API_PROXY_TARGET || 'http://127.0.0.1:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path: string) => path.replace(/^\/api/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: { outDir: 'dist', emptyOutDir: true },
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
module autolibrary.top/webserver
|
||||||
|
|
||||||
|
go 1.25.6
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/casdoor/casdoor-go-sdk v1.46.0
|
||||||
|
github.com/dchest/captcha v1.1.0
|
||||||
|
github.com/gin-gonic/gin v1.9.1
|
||||||
|
github.com/urfave/cli/v2 v2.25.7
|
||||||
|
gorm.io/driver/sqlite v1.5.4
|
||||||
|
gorm.io/gorm v1.25.5
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/sonic v1.9.1 // indirect
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||||
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
|
||||||
|
github.com/golang/protobuf v1.5.3 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||||
|
github.com/leodido/go-urn v1.2.4 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.17 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||||
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
|
golang.org/x/crypto v0.14.0 // indirect
|
||||||
|
golang.org/x/net v0.16.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.13.0 // indirect
|
||||||
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
golang.org/x/text v0.13.0 // indirect
|
||||||
|
google.golang.org/appengine v1.6.7 // indirect
|
||||||
|
google.golang.org/protobuf v1.31.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||||
|
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||||
|
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||||
|
github.com/casdoor/casdoor-go-sdk v1.46.0 h1:a2LAon5daFoJes4E5FEQinaLC/QaH6XBtOEdrvJlVc4=
|
||||||
|
github.com/casdoor/casdoor-go-sdk v1.46.0/go.mod h1:7aXOpfl6s5M5/yjHgIGuPBwdHeSCV/jha1iYsnE1MNc=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dchest/captcha v1.1.0 h1:2kt47EoYUUkaISobUdTbqwx55xvKOJxyScVfw25xzhQ=
|
||||||
|
github.com/dchest/captcha v1.1.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||||
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
|
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||||
|
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||||
|
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||||
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||||
|
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||||
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||||
|
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||||
|
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||||
|
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/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
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/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||||
|
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
|
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
|
||||||
|
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
||||||
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||||
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||||
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
|
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||||
|
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
|
||||||
|
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||||
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
|
golang.org/x/net v0.16.0 h1:7eBu7KsSvFDtSXUIDbh3aqlK4DPsZ1rByC8PFfBThos=
|
||||||
|
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||||
|
golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY=
|
||||||
|
golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
|
||||||
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||||
|
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||||
|
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=
|
||||||
|
gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
|
||||||
|
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
||||||
|
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
LogFile *os.File
|
||||||
|
)
|
||||||
|
|
||||||
|
func InitLogger() error {
|
||||||
|
logDir := filepath.Join(GetRootPath(), "logs")
|
||||||
|
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
logPath := filepath.Join(logDir, "webserver.log")
|
||||||
|
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
multiWriter := io.MultiWriter(os.Stdout, file)
|
||||||
|
log.SetOutput(multiWriter)
|
||||||
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||||
|
|
||||||
|
LogFile = file
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CloseLogger() {
|
||||||
|
if LogFile != nil {
|
||||||
|
LogFile.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServeHTTP drains requests before its caller closes databases and files.
|
||||||
|
func ServeHTTP(ctx context.Context, address string, handler http.Handler) error {
|
||||||
|
server := &http.Server{Addr: address, Handler: handler, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second}
|
||||||
|
finished := make(chan struct{})
|
||||||
|
defer close(finished)
|
||||||
|
shutdownDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(shutdownDone)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timeout, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := server.Shutdown(timeout); err != nil {
|
||||||
|
_ = server.Close()
|
||||||
|
}
|
||||||
|
case <-finished:
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
err := server.ListenAndServe()
|
||||||
|
if errors.Is(err, http.ErrServerClosed) {
|
||||||
|
<-shutdownDone
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ShanghaiLocation *time.Location
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
var err error
|
||||||
|
ShanghaiLocation, err = time.LoadLocation("Asia/Shanghai")
|
||||||
|
if err != nil {
|
||||||
|
ShanghaiLocation = time.FixedZone("CST", 8*60*60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NowInShanghai() time.Time {
|
||||||
|
return time.Now().In(ShanghaiLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseInShanghai(layout, value string) (time.Time, error) {
|
||||||
|
return time.ParseInLocation(layout, value, ShanghaiLocation)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetRootPath() string {
|
||||||
|
// Production binaries resolve files beside the executable; development
|
||||||
|
// commands resolve the current project, including go run's temp binary.
|
||||||
|
working, _ := os.Getwd()
|
||||||
|
executable, _ := os.Executable()
|
||||||
|
for _, directory := range []string{filepath.Dir(executable), working} {
|
||||||
|
if directory == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, marker := range []string{"config.json", "frontend", "go.mod"} {
|
||||||
|
if _, err := os.Stat(filepath.Join(directory, marker)); err == nil {
|
||||||
|
return directory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if working != "" {
|
||||||
|
return working
|
||||||
|
}
|
||||||
|
return filepath.Dir(executable)
|
||||||
|
}
|
||||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user