Initialize AutoLibrary website with Gitea deployment

This commit is contained in:
KenanZhu committed 2026-11-04 09:59:06 +08:00
commit 18216953e7
101 files changed
+12581

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
dist/
node_modules/
public/
package-lock.json
+7
View File
@@ -0,0 +1,7 @@
{
"singleQuote": true,
"semi": false,
"printWidth": 100,
"trailingComma": "all",
"htmlWhitespaceSensitivity": "ignore"
}
+17
View File
@@ -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 } } },
)
+117
View File
@@ -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)
}
+5
View File
@@ -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()
}
}
+35
View File
@@ -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>
+3043
View File
File diff suppressed because it is too large. Load diff
+34
View File
@@ -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"
}
}
+15
View File
@@ -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.
+127
View File
@@ -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>
+70
View File
@@ -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)
}
+56
View File
@@ -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>
+14
View File
@@ -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>
+165
View File
@@ -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>
+106
View File
@@ -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>
+29
View File
@@ -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>
+28
View File
@@ -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>
+136
View File
@@ -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>
+130
View File
@@ -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>
+56
View File
@@ -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>
+62
View File
@@ -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>
+89
View File
@@ -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>
+21
View File
@@ -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>
+63
View File
@@ -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,
}
}
+7
View File
@@ -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')
+34
View File
@@ -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>
+249
View File
@@ -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>
+35
View File
@@ -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>
+161
View File
@@ -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" />
+196
View File
@@ -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>
+257
View File
@@ -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>
+235
View File
@@ -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>
+57
View File
@@ -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 || '图书馆自动化')}`
})
+38
View File
@@ -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
}
+69
View File
@@ -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 }
})
+40
View File
@@ -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
+356
View File
@@ -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;
}
}
+64
View File
@@ -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
}
+19
View File
@@ -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"]
}
+21
View File
@@ -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 },
}
})