refactor: update web UI asset serving and embedding mechanism.

This commit is contained in:
CJACK
2026-02-16 19:58:33 +08:00
parent 190881f13a
commit 888a0e6bff
17 changed files with 150 additions and 749 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- SEO Meta Tags -->
<title>DS2API - 管理面板 / Admin Console</title>
<meta name="description" content="DS2API 管理面板:管理 DeepSeek 账号、测试 API、同步 Vercel 配置 / Admin console for accounts, API tests, and Vercel sync." />
<meta name="keywords" content="DeepSeek, API, OpenAI, 管理面板, admin console, DS2API" />
<meta name="author" content="CJackHwang" />
<meta name="robots" content="noindex, nofollow" />
<!-- Open Graph / Social -->
<meta property="og:type" content="website" />
<meta property="og:title" content="DS2API - 管理面板 / Admin Console" />
<meta property="og:description" content="Manage DeepSeek accounts, test the API, and sync Vercel configuration." />
<meta property="og:site_name" content="DS2API" />
<!-- PWA / Mobile -->
<meta name="theme-color" content="#f59e0b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="DS2API" />
<!-- Favicon - using data URI for orange-yellow gradient icon -->
<link rel="icon" type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%23f59e0b'/%3E%3Cstop offset='100%25' stop-color='%23ef4444'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect rx='20' width='100' height='100' fill='url(%23g)'/%3E%3Ctext x='50' y='68' font-family='Arial,sans-serif' font-size='48' font-weight='bold' fill='white' text-anchor='middle'%3EDS%3C/text%3E%3C/svg%3E" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/admin/assets/index-BZnWnZUg.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-Dd4LAu0y.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

103
internal/webui/build.go Normal file
View File

@@ -0,0 +1,103 @@
package webui
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"ds2api/internal/config"
)
const (
defaultBuildTimeout = 5 * time.Minute
)
func EnsureBuiltOnStartup() {
if !shouldAutoBuild() {
return
}
staticDir := resolveStaticAdminDir(config.StaticAdminDir())
if hasBuiltUI(staticDir) {
return
}
if err := buildWebUI(staticDir); err != nil {
config.Logger.Warn("[webui] auto build failed", "error", err)
return
}
if hasBuiltUI(staticDir) {
config.Logger.Info("[webui] auto build completed", "dir", staticDir)
return
}
config.Logger.Warn("[webui] auto build finished but output missing", "dir", staticDir)
}
func shouldAutoBuild() bool {
raw := strings.TrimSpace(os.Getenv("DS2API_AUTO_BUILD_WEBUI"))
if raw == "" {
return !config.IsVercel()
}
switch strings.ToLower(raw) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return !config.IsVercel()
}
}
func hasBuiltUI(staticDir string) bool {
if strings.TrimSpace(staticDir) == "" {
return false
}
indexPath := filepath.Join(staticDir, "index.html")
st, err := os.Stat(indexPath)
return err == nil && !st.IsDir()
}
func buildWebUI(staticDir string) error {
if _, err := exec.LookPath("npm"); err != nil {
return fmt.Errorf("npm not found in PATH: %w", err)
}
if strings.TrimSpace(staticDir) == "" {
return errors.New("static admin dir is empty")
}
config.Logger.Info("[webui] static files missing, running npm build")
ctx, cancel := context.WithTimeout(context.Background(), defaultBuildTimeout)
defer cancel()
if _, err := os.Stat(filepath.Join("webui", "node_modules")); err != nil {
if !os.IsNotExist(err) {
return err
}
installCmd := exec.CommandContext(ctx, "npm", "ci", "--prefix", "webui")
installCmd.Stdout = os.Stdout
installCmd.Stderr = os.Stderr
if err := installCmd.Run(); err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("webui npm ci timed out after %s", defaultBuildTimeout)
}
return err
}
}
if err := os.MkdirAll(staticDir, 0o755); err != nil {
return err
}
cmd := exec.CommandContext(ctx, "npm", "run", "build", "--prefix", "webui", "--", "--outDir", staticDir, "--emptyOutDir")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("webui build timed out after %s", defaultBuildTimeout)
}
return err
}
return nil
}

View File

@@ -1,9 +0,0 @@
package webui
import "embed"
// embeddedAdminFS bundles the built admin webui so serverless runtime does not
// depend on function includeFiles behavior.
//
//go:embed assets/admin
var embeddedAdminFS embed.FS

View File

@@ -1,10 +1,8 @@
package webui
import (
"io/fs"
"net/http"
"os"
"path"
"path/filepath"
"strings"
@@ -19,18 +17,11 @@ const welcomeHTML = `<!DOCTYPE html>
</head><body><main><h1>DS2API</h1><p>DeepSeek to OpenAI & Claude Compatible API</p><div class="links"><a href="/admin">管理面板</a><a href="/v1/models">API 状态</a><a href="https://github.com/CJackHwang/ds2api" target="_blank">GitHub</a></div></main></body></html>`
type Handler struct {
StaticDir string
embeddedAdmin fs.FS
hasEmbeddedUI bool
StaticDir string
}
func NewHandler() *Handler {
h := &Handler{StaticDir: resolveStaticAdminDir(config.StaticAdminDir())}
if sub, err := fs.Sub(embeddedAdminFS, "assets/admin"); err == nil {
h.embeddedAdmin = sub
h.hasEmbeddedUI = true
}
return h
return &Handler{StaticDir: resolveStaticAdminDir(config.StaticAdminDir())}
}
func RegisterRoutes(r chi.Router, h *Handler) {
@@ -61,11 +52,6 @@ func (h *Handler) admin(w http.ResponseWriter, r *http.Request) {
h.serveFromDisk(w, r, staticDir)
return
}
if h.hasEmbeddedUI {
if h.serveFromFS(w, r, h.embeddedAdmin) {
return
}
}
http.Error(w, "WebUI not built. Run `cd webui && npm run build` first.", http.StatusNotFound)
}
@@ -100,6 +86,9 @@ func (h *Handler) serveFromDisk(w http.ResponseWriter, r *http.Request, staticDi
}
func resolveStaticAdminDir(preferred string) string {
if strings.TrimSpace(os.Getenv("DS2API_STATIC_ADMIN_DIR")) != "" {
return filepath.Clean(preferred)
}
candidates := []string{preferred}
if wd, err := os.Getwd(); err == nil {
candidates = append(candidates, filepath.Join(wd, "static/admin"))
@@ -130,34 +119,3 @@ func resolveStaticAdminDir(preferred string) string {
}
return filepath.Clean(preferred)
}
func (h *Handler) serveFromFS(w http.ResponseWriter, r *http.Request, rootFS fs.FS) bool {
rel := strings.TrimPrefix(r.URL.Path, "/admin")
rel = strings.TrimPrefix(rel, "/")
safe := strings.TrimPrefix(path.Clean("/"+rel), "/")
if strings.HasPrefix(safe, "../") {
http.NotFound(w, r)
return true
}
if safe != "" && strings.Contains(safe, ".") {
if _, err := fs.Stat(rootFS, safe); err != nil {
http.NotFound(w, r)
return true
}
if strings.HasPrefix(safe, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-store, must-revalidate")
}
http.ServeFileFS(w, r, rootFS, safe)
return true
}
if _, err := fs.Stat(rootFS, "index.html"); err != nil {
return false
}
w.Header().Set("Cache-Control", "no-store, must-revalidate")
http.ServeFileFS(w, r, rootFS, "index.html")
return true
}