Browse Source

feat: initialize frontend with Svelte and Vite

- Add package.json for frontend dependencies and scripts
- Create AdminPanel component for managing short links
- Implement App component for shortening URLs and admin access
- Add API utility functions for token management and link operations
- Set up main entry point for the Svelte application
- Configure Vite with proxy settings for API requests
- Create systemd service file for the short link server

Co-authored-by: Copilot <copilot@github.com>
main
npmrun 3 months ago
commit
0f0f211ac2
  1. 31
      .drone.yml
  2. 4
      .gitignore
  3. 16
      Caddyfile
  4. 184
      README.md
  5. 109
      backend/admin.go
  6. 3
      backend/go.mod
  7. 166
      backend/handler.go
  8. 239
      backend/handler_test.go
  9. 70
      backend/main.go
  10. 20
      backend/shortlink.db
  11. 149
      backend/store.go
  12. 121
      backend/store_test.go
  13. 13
      frontend/index.html
  14. 1299
      frontend/package-lock.json
  15. 16
      frontend/package.json
  16. 576
      frontend/src/AdminPanel.svelte
  17. 301
      frontend/src/App.svelte
  18. 72
      frontend/src/api.js
  19. 7
      frontend/src/main.js
  20. 12
      frontend/vite.config.js
  21. 18
      short-link.service

31
.drone.yml

@ -0,0 +1,31 @@
kind: pipeline
type: exec
name: deploy
platform:
os: linux
arch: amd64
steps:
- name: build-backend
commands:
- cd backend && CGO_ENABLED=0 go build -ldflags="-s -w" -o short-link-server .
- name: build-frontend
commands:
- cd frontend && npm ci && npm run build
- name: deploy
commands:
- cp backend/short-link-server /opt/short-link/short-link-server.new
- cp -r frontend/dist/* /opt/short-link/frontend/
- mv /opt/short-link/short-link-server.new /opt/short-link/short-link-server
- systemctl restart short-link
- systemctl reload caddy
depends_on:
- build-backend
- build-frontend
trigger:
branch:
- main

4
.gitignore

@ -0,0 +1,4 @@
node_modules
dist
backend/.go
backend/file::memory:?cache=shared

16
Caddyfile

@ -0,0 +1,16 @@
xieyaxin.top:8899 {
@api path /api/*
handle @api {
reverse_proxy localhost:8080
}
@shortcode path_regexp shortcode ^/[a-zA-Z0-9]{7}$
handle @shortcode {
reverse_proxy localhost:8080
}
handle {
root * /opt/short-link/frontend
file_server
}
}

184
README.md

@ -0,0 +1,184 @@
# 短链接生成器
粘贴长链接,一键变短。极简工具网站。
## 架构
```
用户请求 → Caddy
├── /api/* → Go 后端 :8080
├── /[a-zA-Z0-9]{7} → Go 后端 :8080 (短码重定向)
└── 其他 → 静态文件 /opt/short-link/frontend
```
```
后端: Go 1.22 + net/http(标准库路由) + JSON 文件持久化
前端: Svelte 4 + Vite 5
网关: Caddy(自动 HTTPS)
部署: Drone CI → systemd
```
## 本地开发
### 后端
```bash
cd backend
# 安装依赖
go mod tidy
# 运行 (默认 :8080)
ADDR=:8080 DOMAIN=http://localhost:8080 DB_PATH=./data.db ADMIN_TOKEN=dev go run .
# 运行测试
go test ./...
```
### 前端
```bash
cd frontend
# 安装依赖
npm install
# 开发模式 (默认 :5173, API 请求会由 Vite 转发配置决定)
npm run dev
# 构建
npm run build
```
## 部署
### 环境要求
- Go 1.22+
- Node.js 20+
- Caddy 2.x
- systemd (Linux)
### 手动部署
```bash
# 1. 构建
cd backend && CGO_ENABLED=0 go build -ldflags="-s -w" -o short-link-server .
cd ../frontend && npm install && npm run build
# 2. 部署文件
mkdir -p /opt/short-link/data /opt/short-link/frontend
cp backend/short-link-server /opt/short-link/
cp -r frontend/dist/* /opt/short-link/frontend/
# 3. 生成管理员 Token
openssl rand -hex 32 # 填入 short-link.service 的 ADMIN_TOKEN
# 4. 注册服务
cp short-link.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now short-link
# 5. Caddy
cp Caddyfile /etc/caddy/
systemctl reload caddy
```
### Drone CI 自动部署
推送到 `main` 分支自动触发。需在 Drone 配置 `ssh_key` secret(部署机 SSH 私钥)。
`.drone.yml` 完成了三步流水线:
1. `golang:1.22` — 编译 Go 二进制
2. `node:20-alpine` — 构建前端
3. `alpine:3` — 上传文件 + `systemctl restart short-link` + `systemctl reload caddy`
## 配置
| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `ADDR` | `:8080` | 监听地址 |
| `DOMAIN` | `http://localhost:8080` | 短链接域名,用于拼接 short_url |
| `DB_PATH` | `./data.db` | JSON 数据文件路径 |
| `ADMIN_TOKEN` | (空) | 管理员 Bearer Token,空值时所有管理接口返回 401 |
## API
### POST /api/shorten
```
请求: { "url": "https://example.com/very/long/path" }
响应: { "code": "abc1234", "short_url": "https://xieyaxin.top:8899/abc1234", "original_url": "https://example.com/very/long/path" }
```
- 自动补全 `https://` 前缀
- 仅支持 http/https 协议
- 返回 400(URL 不合法)、500(服务端错误)
### GET /:code
```
GET /abc1234 → 302 Found → Location: https://example.com/...
```
- 存在:302 临时重定向
- 不存在:404
### 管理接口
以下接口需在请求头中携带 `Authorization: Bearer <ADMIN_TOKEN>`
#### GET /api/admin/links
列出所有短链接,按创建时间倒序。
```
→ 200 [{ "code": "abc1234", "original_url": "https://...", "visit_count": 42, "created_at": "2026-05-06T14:30:00Z" }]
→ 401 未授权
```
#### PUT /api/admin/links/{code}
更新短链接指向的原始 URL。
```
请求: { "url": "https://new.example.com" }
→ 200 { "code": "abc1234", "original_url": "https://new.example.com", ... }
→ 404 短码不存在
```
#### DELETE /api/admin/links/{code}
删除短链接。
```
→ 200 { "ok": true }
→ 404 短码不存在
```
#### GET /api/admin/export
导出全部数据。
```
GET /api/admin/export → JSON 文件下载 (application/json)
GET /api/admin/export?format=csv → CSV 文件下载 (text/csv)
```
## 短码设计
- 7 位 base62 字符集 (a-z, A-Z, 0-9)
- `crypto/rand` 密码学安全随机数
- 62^7 ≈ 3.5 万亿组合
- 插入前检查唯一约束,碰撞重试最多 3 次
## 管理后台
访问 `/#admin` 进入管理界面:
1. 输入 `ADMIN_TOKEN` 登录(浏览器 sessionStorage 保存,关闭标签页自动清除)
2. 表格展示所有短链接:短码、原始链接、访问次数、创建时间
3. 行内编辑:修改原始 URL
4. 行内删除:确认后删除
5. 导出数据:支持 JSON / CSV 格式下载

109
backend/admin.go

@ -0,0 +1,109 @@
package main
import (
"encoding/csv"
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
)
// HandleListLinks handles GET /api/admin/links — returns all records.
func (h *Handler) HandleListLinks(w http.ResponseWriter, r *http.Request) {
records, err := h.store.ListAll()
if err != nil {
log.Printf("list links error: %v", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(records)
}
// HandleUpdateLink handles PUT /api/admin/links/{code} — updates a link's URL.
func (h *Handler) HandleUpdateLink(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
if code == "" {
jsonError(w, "missing short code", http.StatusBadRequest)
return
}
var req struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
req.URL = strings.TrimSpace(req.URL)
if req.URL == "" {
jsonError(w, "url is required", http.StatusBadRequest)
return
}
if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") {
jsonError(w, "url must start with http:// or https://", http.StatusBadRequest)
return
}
if err := h.store.Update(code, req.URL); err != nil {
log.Printf("update link error: %v", err)
jsonError(w, "short link not found", http.StatusNotFound)
return
}
rec, _ := h.store.FindByCode(code)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(rec)
}
// HandleDeleteLink handles DELETE /api/admin/links/{code} — deletes a link.
func (h *Handler) HandleDeleteLink(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
if code == "" {
jsonError(w, "missing short code", http.StatusBadRequest)
return
}
if err := h.store.Delete(code); err != nil {
log.Printf("delete link error: %v", err)
jsonError(w, "short link not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
}
// HandleExport handles GET /api/admin/export?format=json|csv — exports all data.
func (h *Handler) HandleExport(w http.ResponseWriter, r *http.Request) {
records, err := h.store.ListAll()
if err != nil {
log.Printf("export error: %v", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return
}
format := r.URL.Query().Get("format")
if format == "csv" {
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", "attachment; filename=\"shortlinks.csv\"")
wr := csv.NewWriter(w)
wr.Write([]string{"code", "original_url", "visit_count", "created_at"})
for _, rec := range records {
wr.Write([]string{
rec.Code,
rec.OriginalURL,
strconv.FormatInt(rec.VisitCount, 10),
rec.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
})
}
wr.Flush()
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", "attachment; filename=\"shortlinks.json\"")
json.NewEncoder(w).Encode(records)
}

3
backend/go.mod

@ -0,0 +1,3 @@
module shortlink
go 1.22

166
backend/handler.go

@ -0,0 +1,166 @@
package main
import (
"crypto/rand"
"encoding/json"
"log"
"math/big"
"net/http"
"strings"
)
const (
base62Chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
codeLength = 7
maxRetries = 3
)
// Handler holds references for HTTP handlers.
type Handler struct {
store *Store
baseURL string // optional; falls back to request Host
adminToken string
}
// NewHandler creates a Handler.
func NewHandler(store *Store, baseURL, adminToken string) *Handler {
return &Handler{store: store, baseURL: baseURL, adminToken: adminToken}
}
// generateCode produces a 7-character base62 code using crypto/rand.
func generateCode() (string, error) {
buf := make([]byte, codeLength)
for i := range buf {
n, err := rand.Int(rand.Reader, big.NewInt(62))
if err != nil {
return "", err
}
buf[i] = base62Chars[n.Int64()]
}
return string(buf), nil
}
// requireAdmin wraps a handler with Bearer token authentication.
// If adminToken is empty, all requests are rejected (fail-secure).
func (h *Handler) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if h.adminToken == "" || token != h.adminToken {
jsonError(w, "unauthorized", http.StatusUnauthorized)
return
}
next(w, r)
}
}
// --- helpers ---
func jsonError(w http.ResponseWriter, msg string, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
// --- handlers ---
// HandleCreate handles POST /api/shorten.
func (h *Handler) HandleCreate(w http.ResponseWriter, r *http.Request) {
var req struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
req.URL = strings.TrimSpace(req.URL)
if req.URL == "" {
jsonError(w, "url is required", http.StatusBadRequest)
return
}
if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") {
jsonError(w, "url must start with http:// or https://", http.StatusBadRequest)
return
}
var code string
var err error
for attempt := 0; attempt < maxRetries; attempt++ {
code, err = generateCode()
if err != nil {
log.Printf("generate code error: %v", err)
jsonError(w, "failed to generate short code", http.StatusInternalServerError)
return
}
if err = h.store.Create(code, req.URL); err == nil {
break // success
}
log.Printf("collision on code %q (attempt %d): %v", code, attempt+1, err)
}
if err != nil {
log.Printf("store create error after %d retries: %v", maxRetries, err)
jsonError(w, "failed to create short link", http.StatusInternalServerError)
return
}
shortURL := h.resolveBaseURL(r) + "/" + code
resp := map[string]string{
"code": code,
"short_url": shortURL,
"original_url": req.URL,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// HandleRedirect handles GET /{code}, redirecting to the original URL.
func (h *Handler) HandleRedirect(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
if code == "" {
jsonError(w, "missing short code", http.StatusBadRequest)
return
}
// Skip API-like paths that snuck through the mux
if strings.HasPrefix(code, "api/") {
jsonError(w, "not found", http.StatusNotFound)
return
}
url, err := h.store.FindByCode(code)
if err != nil {
log.Printf("store find error: %v", err)
jsonError(w, "internal error", http.StatusInternalServerError)
return
}
if url == nil {
jsonError(w, "short link not found", http.StatusNotFound)
return
}
// Fire-and-forget visit counter
go func() {
if err := h.store.IncrementVisit(url.Code); err != nil {
log.Printf("increment visit error: %v", err)
}
}()
// 302 — browsers don't cache it, user can update the link later
http.Redirect(w, r, url.OriginalURL, http.StatusFound)
}
// resolveBaseURL returns the configured base URL or constructs one from the
// incoming request's Host header.
func (h *Handler) resolveBaseURL(r *http.Request) string {
if h.baseURL != "" {
return strings.TrimRight(h.baseURL, "/")
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
return scheme + "://" + r.Host
}

239
backend/handler_test.go

@ -0,0 +1,239 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
)
func newTestHandler(t *testing.T) *Handler {
t.Helper()
store, err := NewStore(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("NewStore() failed: %v", err)
}
t.Cleanup(func() { store.Close() })
return NewHandler(store, "http://xieyaxin.top:8899", "test-token")
}
func decodeBody(t *testing.T, body string) map[string]string {
t.Helper()
var resp map[string]string
if err := json.NewDecoder(strings.NewReader(body)).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
return resp
}
func TestHandleCreate_Valid(t *testing.T) {
h := newTestHandler(t)
body := `{"url":"https://example.com"}`
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
h.HandleCreate(w, r)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
resp := decodeBody(t, w.Body.String())
if len(resp["code"]) != 7 {
t.Fatalf("expected code length 7, got %d: %q", len(resp["code"]), resp["code"])
}
if resp["short_url"] != "http://xieyaxin.top:8899/"+resp["code"] {
t.Fatalf("expected short_url %q, got %q", "http://xieyaxin.top:8899/"+resp["code"], resp["short_url"])
}
if resp["original_url"] != "https://example.com" {
t.Fatalf("expected original_url %q, got %q", "https://example.com", resp["original_url"])
}
}
func TestHandleCreate_Consecutive(t *testing.T) {
h := newTestHandler(t)
body1 := `{"url":"https://example.com/first"}`
w1 := httptest.NewRecorder()
r1 := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(body1))
r1.Header.Set("Content-Type", "application/json")
h.HandleCreate(w1, r1)
body2 := `{"url":"https://example.com/second"}`
w2 := httptest.NewRecorder()
r2 := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(body2))
r2.Header.Set("Content-Type", "application/json")
h.HandleCreate(w2, r2)
resp1 := decodeBody(t, w1.Body.String())
resp2 := decodeBody(t, w2.Body.String())
if len(resp1["code"]) != 7 {
t.Fatalf("expected code length 7, got %d: %q", len(resp1["code"]), resp1["code"])
}
if len(resp2["code"]) != 7 {
t.Fatalf("expected code length 7, got %d: %q", len(resp2["code"]), resp2["code"])
}
if resp1["code"] == resp2["code"] {
t.Fatal("consecutive short links should have different codes")
}
}
func TestHandleCreate_MissingURL(t *testing.T) {
h := newTestHandler(t)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(`{}`))
r.Header.Set("Content-Type", "application/json")
h.HandleCreate(w, r)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d: %s", w.Code, w.Body.String())
}
resp := decodeBody(t, w.Body.String())
if resp["error"] == "" {
t.Fatal("expected error message in response")
}
}
func TestHandleCreate_EmptyURL(t *testing.T) {
h := newTestHandler(t)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(`{"url":""}`))
r.Header.Set("Content-Type", "application/json")
h.HandleCreate(w, r)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
}
func TestHandleCreate_InvalidScheme(t *testing.T) {
h := newTestHandler(t)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(`{"url":"ftp://example.com"}`))
r.Header.Set("Content-Type", "application/json")
h.HandleCreate(w, r)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400 for unsupported scheme, got %d", w.Code)
}
}
func TestHandleCreate_WrongMethod(t *testing.T) {
h := newTestHandler(t)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/api/shorten", nil)
h.HandleCreate(w, r)
// HandleCreate itself does not check method (mux handles routing)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400 for GET request (no body), got %d", w.Code)
}
}
func TestHandleCreate_InvalidBody(t *testing.T) {
h := newTestHandler(t)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(`not json`))
r.Header.Set("Content-Type", "application/json")
h.HandleCreate(w, r)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400 for invalid JSON, got %d", w.Code)
}
}
func TestHandleRedirect_Valid(t *testing.T) {
h := newTestHandler(t)
// Create a short link first
body := `{"url":"https://example.com"}`
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
h.HandleCreate(w, r)
resp := decodeBody(t, w.Body.String())
code := resp["code"]
// Test redirect
w = httptest.NewRecorder()
r = httptest.NewRequest(http.MethodGet, "/"+code, nil)
r.SetPathValue("code", code)
h.HandleRedirect(w, r)
if w.Code != http.StatusFound {
t.Fatalf("expected status 302 for valid code, got %d", w.Code)
}
if loc := w.Header().Get("Location"); loc != "https://example.com" {
t.Fatalf("expected Location header %q, got %q", "https://example.com", loc)
}
}
func TestHandleRedirect_NotFound(t *testing.T) {
h := newTestHandler(t)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/nonexist", nil)
r.SetPathValue("code", "nonexist")
h.HandleRedirect(w, r)
if w.Code != http.StatusNotFound {
t.Fatalf("expected status 404 for non-existent code, got %d", w.Code)
}
}
func TestHandleRedirect_EmptyCode(t *testing.T) {
h := newTestHandler(t)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.SetPathValue("code", "")
h.HandleRedirect(w, r)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400 for empty code, got %d", w.Code)
}
}
func TestStore_IncrementVisit(t *testing.T) {
h := newTestHandler(t)
// Create a short link
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/api/shorten", strings.NewReader(`{"url":"https://example.com"}`))
r.Header.Set("Content-Type", "application/json")
h.HandleCreate(w, r)
resp := decodeBody(t, w.Body.String())
code := resp["code"]
rec, err := h.store.FindByCode(code)
if err != nil || rec == nil {
t.Fatal("record not found after create")
}
if err := h.store.IncrementVisit(code); err != nil {
t.Fatalf("IncrementVisit() failed: %v", err)
}
if err := h.store.IncrementVisit(code); err != nil {
t.Fatalf("IncrementVisit() failed: %v", err)
}
rec, err = h.store.FindByCode(code)
if err != nil || rec == nil {
t.Fatal("record not found after increment")
}
if rec.VisitCount != 2 {
t.Fatalf("expected VisitCount 2, got %d", rec.VisitCount)
}
}

70
backend/main.go

@ -0,0 +1,70 @@
package main
import (
"context"
"flag"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
func main() {
port := flag.String("port", "8080", "listen port")
dbPath := flag.String("db", "shortlink.db", "sqlite database path")
baseURL := flag.String("base-url", "", "base url for short links (e.g. https://xieyaxin.top:8899)")
flag.Parse()
store, err := NewStore(*dbPath)
if err != nil {
log.Fatalf("failed to open database: %v", err)
}
defer store.Close()
adminToken := strings.TrimSpace(os.Getenv("ADMIN_TOKEN"))
h := NewHandler(store, *baseURL, adminToken)
mux := http.NewServeMux()
mux.HandleFunc("POST /api/shorten", h.HandleCreate)
mux.HandleFunc("GET /{code}", h.HandleRedirect)
// Admin routes
mux.HandleFunc("GET /api/admin/links", h.requireAdmin(h.HandleListLinks))
mux.HandleFunc("PUT /api/admin/links/{code}", h.requireAdmin(h.HandleUpdateLink))
mux.HandleFunc("DELETE /api/admin/links/{code}", h.requireAdmin(h.HandleDeleteLink))
mux.HandleFunc("GET /api/admin/export", h.requireAdmin(h.HandleExport))
srv := &http.Server{
Addr: ":" + *port,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Graceful shutdown — wait for SIGINT/SIGTERM, then drain connections
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
log.Printf("listening on :%s", *port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}()
<-quit
log.Println("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("forced shutdown: %v", err)
}
log.Println("bye")
}

20
backend/shortlink.db

@ -0,0 +1,20 @@
[
{
"code": "BRmGN8s",
"original_url": "https://platform.deepseek.com/usage",
"visit_count": 2,
"created_at": "2026-05-06T22:50:16.706444479+08:00"
},
{
"code": "YLHlgN0",
"original_url": "https://platform.deepseek.com/usage",
"visit_count": 2,
"created_at": "2026-05-06T23:09:34.035144208+08:00"
},
{
"code": "DSqH5EQ",
"original_url": "https://platform.deepseek.com/usage",
"visit_count": 4,
"created_at": "2026-05-06T23:12:26.936674827+08:00"
}
]

149
backend/store.go

@ -0,0 +1,149 @@
package main
import (
"encoding/json"
"fmt"
"os"
"sort"
"sync"
"sync/atomic"
"time"
)
// URLRecord represents a stored link.
type URLRecord struct {
Code string `json:"code"`
OriginalURL string `json:"original_url"`
VisitCount int64 `json:"visit_count"`
CreatedAt time.Time `json:"created_at"`
}
// Store is a simple JSON-file-backed key-value store.
type Store struct {
mu sync.RWMutex
file string
links map[string]*URLRecord // code -> record
}
// NewStore opens (or creates) the JSON-backed store.
func NewStore(dbPath string) (*Store, error) {
s := &Store{
file: dbPath,
links: make(map[string]*URLRecord),
}
data, err := os.ReadFile(dbPath)
if err == nil {
var records []*URLRecord
if err := json.Unmarshal(data, &records); err == nil {
for _, rec := range records {
s.links[rec.Code] = rec
}
}
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("open store: %w", err)
}
return s, nil
}
// Create inserts a new URL with the given code. Returns an error if the code
// already exists.
func (s *Store) Create(code, originalURL string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.links[code]; exists {
return fmt.Errorf("code %q already exists", code)
}
s.links[code] = &URLRecord{
Code: code,
OriginalURL: originalURL,
CreatedAt: time.Now(),
}
return s.persist()
}
// FindByCode looks up a URL record by its short code.
func (s *Store) FindByCode(code string) (*URLRecord, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rec, ok := s.links[code]
if !ok {
return nil, nil
}
return rec, nil
}
// IncrementVisit atomically bumps the visit counter for the given code.
func (s *Store) IncrementVisit(code string) error {
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.links[code]
if !ok {
return fmt.Errorf("code %q not found", code)
}
atomic.AddInt64(&rec.VisitCount, 1)
return s.persist()
}
// Close is a no-op for the JSON store.
func (s *Store) Close() error {
return nil
}
// ListAll returns all records sorted by CreatedAt descending (newest first).
func (s *Store) ListAll() ([]*URLRecord, error) {
s.mu.RLock()
defer s.mu.RUnlock()
records := make([]*URLRecord, 0, len(s.links))
for _, rec := range s.links {
records = append(records, rec)
}
sort.Slice(records, func(i, j int) bool {
return records[i].CreatedAt.After(records[j].CreatedAt)
})
return records, nil
}
// Update changes the OriginalURL of the record identified by code.
func (s *Store) Update(code, newURL string) error {
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.links[code]
if !ok {
return fmt.Errorf("code %q not found", code)
}
rec.OriginalURL = newURL
return s.persist()
}
// Delete removes the record identified by code.
func (s *Store) Delete(code string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.links[code]; !ok {
return fmt.Errorf("code %q not found", code)
}
delete(s.links, code)
return s.persist()
}
func (s *Store) persist() error {
records := make([]*URLRecord, 0, len(s.links))
for _, rec := range s.links {
records = append(records, rec)
}
data, err := json.MarshalIndent(records, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.file, data, 0644)
}

121
backend/store_test.go

@ -0,0 +1,121 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
s, err := NewStore(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("NewStore() failed: %v", err)
}
t.Cleanup(func() { s.Close() })
return s
}
func TestStore_CreateAndFindByCode(t *testing.T) {
s := newTestStore(t)
originalURL := "https://example.com/create-and-find"
if err := s.Create("testabc", originalURL); err != nil {
t.Fatalf("Create() failed: %v", err)
}
rec, err := s.FindByCode("testabc")
if err != nil {
t.Fatalf("FindByCode() failed: %v", err)
}
if rec == nil {
t.Fatal("FindByCode() returned nil, expected a record")
}
if rec.OriginalURL != originalURL {
t.Fatalf("expected OriginalURL %q, got %q", originalURL, rec.OriginalURL)
}
if rec.Code != "testabc" {
t.Fatalf("expected Code %q, got %q", "testabc", rec.Code)
}
}
func TestStore_FindByCode_NonExistent(t *testing.T) {
s := newTestStore(t)
rec, err := s.FindByCode("nonexist")
if err != nil {
t.Fatalf("FindByCode() for non-existent code failed: %v", err)
}
if rec != nil {
t.Fatal("expected nil for non-existent code, got a record")
}
}
func TestStore_Create_Duplicate(t *testing.T) {
s := newTestStore(t)
url := "https://example.com/dup"
if err := s.Create("dupcode", url); err != nil {
t.Fatalf("first Create() failed: %v", err)
}
if err := s.Create("dupcode", url); err == nil {
t.Fatal("second Create() with same code should fail")
}
}
func TestStore_Persistence(t *testing.T) {
tmpFile, err := os.CreateTemp("", "shortlink-*.json")
if err != nil {
t.Fatal(err)
}
tmpPath := tmpFile.Name()
tmpFile.Close()
defer os.Remove(tmpPath)
s1, err := NewStore(tmpPath)
if err != nil {
t.Fatalf("NewStore() failed: %v", err)
}
s1.Create("persist", "https://example.com/persist")
s1.Close()
s2, err := NewStore(tmpPath)
if err != nil {
t.Fatalf("reopen store failed: %v", err)
}
defer s2.Close()
rec, err := s2.FindByCode("persist")
if err != nil {
t.Fatalf("FindByCode() after reopen failed: %v", err)
}
if rec == nil {
t.Fatal("expected record after reopening store")
}
if rec.OriginalURL != "https://example.com/persist" {
t.Fatalf("unexpected OriginalURL: %q", rec.OriginalURL)
}
}
func TestGenerateCode(t *testing.T) {
seen := make(map[string]bool)
for i := 0; i < 100; i++ {
code, err := generateCode()
if err != nil {
t.Fatalf("generateCode() failed: %v", err)
}
if len(code) != codeLength {
t.Fatalf("expected code length %d, got %d: %q", codeLength, len(code), code)
}
for _, c := range code {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) {
t.Fatalf("invalid character %c in code %q", c, code)
}
}
if seen[code] {
t.Fatal("unexpected collision: duplicate code generated")
}
seen[code] = true
}
}

13
frontend/index.html

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>短链接工具</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔗</text></svg>" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1299
frontend/package-lock.json

File diff suppressed because it is too large

16
frontend/package.json

@ -0,0 +1,16 @@
{
"name": "short-link-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.1.0",
"svelte": "^4.2.18",
"vite": "^5.4.0"
}
}

576
frontend/src/AdminPanel.svelte

@ -0,0 +1,576 @@
<script>
import { onMount } from 'svelte';
import { getToken, setToken, clearToken, fetchLinks, updateLink, deleteLink, exportData, downloadBlob } from './api.js';
let tokenInput = '';
let authenticated = false;
let links = [];
let loading = false;
let error = '';
// Edit state
let editing = null; // code being edited, or null
let editUrl = '';
let editSaving = false;
// Delete state
let deleteConfirm = null; // code awaiting confirmation, or null
// Export state
let exporting = false;
onMount(async () => {
if (getToken()) {
await loadLinks();
}
});
async function handleLogin() {
const t = tokenInput.trim();
if (!t) return;
setToken(t);
await loadLinks();
}
async function handleLogout() {
clearToken();
authenticated = false;
links = [];
tokenInput = '';
}
async function loadLinks() {
loading = true;
error = '';
try {
links = await fetchLinks();
authenticated = true;
} catch (e) {
error = e.message;
authenticated = false;
} finally {
loading = false;
}
}
function startEdit(rec) {
editing = rec.code;
editUrl = rec.original_url;
editSaving = false;
error = '';
}
function cancelEdit() {
editing = null;
editUrl = '';
}
async function saveEdit(code) {
const url = editUrl.trim();
if (!url) return;
editSaving = true;
error = '';
try {
const updated = await updateLink(code, url);
links = links.map(l => l.code === code ? updated : l);
editing = null;
editUrl = '';
} catch (e) {
error = e.message;
} finally {
editSaving = false;
}
}
function confirmDelete(code) {
deleteConfirm = code;
error = '';
}
function cancelDelete() {
deleteConfirm = null;
}
async function executeDelete(code) {
error = '';
try {
await deleteLink(code);
links = links.filter(l => l.code !== code);
deleteConfirm = null;
} catch (e) {
error = e.message;
deleteConfirm = null;
}
}
async function handleExport(format) {
exporting = true;
error = '';
try {
const blob = await exportData(format);
const ext = format === 'csv' ? 'csv' : 'json';
downloadBlob(blob, `shortlinks.${ext}`);
} catch (e) {
error = e.message;
} finally {
exporting = false;
}
}
function formatTime(ts) {
const d = new Date(ts);
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
</script>
<div class="admin-container">
<header class="admin-header">
<div class="header-left">
<h1>管理后台</h1>
<button class="back-link" on:click={() => window.location.hash = ''}> 返回</button>
</div>
{#if authenticated}
<button class="btn-logout" on:click={handleLogout}>退出</button>
{/if}
</header>
{#if !authenticated}
<div class="card token-card">
<p class="token-prompt">请输入管理 Token</p>
<div class="token-row">
<input
type="password"
class="token-input"
placeholder="输入 ADMIN_TOKEN..."
bind:value={tokenInput}
on:keydown={(e) => e.key === 'Enter' && handleLogin()}
/>
<button class="btn" on:click={handleLogin} disabled={!tokenInput.trim()}>
进入管理
</button>
</div>
{#if error}
<div class="error">{error}</div>
{/if}
</div>
{:else}
{#if loading}
<p class="loading-text">加载中...</p>
{:else}
<div class="toolbar">
<span class="count">{links.length} 条记录</span>
<div class="export-btns">
<button class="btn-small" on:click={() => handleExport('json')} disabled={exporting}>
导出 JSON
</button>
<button class="btn-small" on:click={() => handleExport('csv')} disabled={exporting}>
导出 CSV
</button>
</div>
</div>
{#if error}
<div class="error">{error}</div>
{/if}
{#if links.length === 0}
<div class="card">
<p class="empty-text">暂无数据</p>
</div>
{:else}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>短码</th>
<th>原始链接</th>
<th class="col-num">访问</th>
<th class="col-time">创建时间</th>
<th class="col-action">操作</th>
</tr>
</thead>
<tbody>
{#each links as rec (rec.code)}
<tr>
<td class="col-code">
<a href="/{rec.code}">{rec.code}</a>
</td>
<td class="col-url">
{#if editing === rec.code}
<div class="edit-row">
<input
type="text"
class="edit-input"
bind:value={editUrl}
on:keydown={(e) => e.key === 'Enter' && saveEdit(rec.code)}
disabled={editSaving}
/>
<button
class="btn-small btn-save"
on:click={() => saveEdit(rec.code)}
disabled={editSaving || !editUrl.trim()}
>保存</button>
<button class="btn-small" on:click={cancelEdit} disabled={editSaving}>取消</button>
</div>
{:else}
<span class="url-text" title={rec.original_url}>{rec.original_url}</span>
{/if}
</td>
<td class="col-num">{rec.visit_count}</td>
<td class="col-time">{formatTime(rec.created_at)}</td>
<td class="col-action">
{#if editing === rec.code}
<!-- buttons shown inline above -->
{:else if deleteConfirm === rec.code}
<span class="confirm-text">确认删除?</span>
<button class="btn-small btn-yes" on:click={() => executeDelete(rec.code)}>是</button>
<button class="btn-small" on:click={cancelDelete}>否</button>
{:else}
<button class="btn-small" on:click={() => startEdit(rec)}>编辑</button>
<button class="btn-small btn-del" on:click={() => confirmDelete(rec.code)}>删除</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
{/if}
</div>
<style>
.admin-container {
max-width: 960px;
margin: 0 auto;
padding: 40px 16px;
}
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.header-left {
display: flex;
align-items: baseline;
gap: 16px;
}
.admin-header h1 {
font-size: 24px;
font-weight: 700;
}
.back-link {
font-size: 14px;
color: #4a6cf7;
background: none;
border: none;
cursor: pointer;
padding: 0;
text-decoration: none;
}
.back-link:hover {
text-decoration: underline;
}
.btn-logout {
padding: 8px 16px;
background: #fff;
color: #d32f2f;
border: 1px solid #d32f2f;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
}
.btn-logout:hover {
background: #fff0f0;
}
.card {
background: #fff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.token-card {
max-width: 420px;
margin: 40px auto;
}
.token-prompt {
font-size: 15px;
color: #444;
margin-bottom: 12px;
text-align: center;
}
.token-row {
display: flex;
gap: 8px;
}
.token-input {
flex: 1;
padding: 10px 14px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 15px;
outline: none;
transition: border-color 0.2s;
}
.token-input:focus {
border-color: #4a6cf7;
}
.btn {
padding: 10px 20px;
background: #4a6cf7;
color: #fff;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: background 0.2s;
}
.btn:hover:not(:disabled) {
background: #3b5de7;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-small {
padding: 5px 12px;
background: #f0f2f5;
color: #444;
border: none;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
white-space: nowrap;
transition: background 0.2s;
}
.btn-small:hover:not(:disabled) {
background: #e0e0e0;
}
.btn-small:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-del {
color: #d32f2f;
}
.btn-del:hover:not(:disabled) {
background: #fff0f0;
}
.btn-save {
background: #4a6cf7;
color: #fff;
}
.btn-save:hover:not(:disabled) {
background: #3b5de7;
}
.btn-yes {
background: #d32f2f;
color: #fff;
}
.btn-yes:hover:not(:disabled) {
background: #b71c1c;
}
.error {
margin-top: 12px;
padding: 10px 14px;
background: #fff0f0;
color: #d32f2f;
border-radius: 8px;
font-size: 14px;
}
.loading-text {
text-align: center;
color: #888;
padding: 40px;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.count {
font-size: 14px;
color: #666;
}
.export-btns {
display: flex;
gap: 8px;
}
.empty-text {
text-align: center;
color: #999;
padding: 32px;
}
.table-wrap {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
thead {
background: #f9fafb;
}
th {
padding: 12px 16px;
text-align: left;
font-size: 13px;
font-weight: 600;
color: #666;
border-bottom: 1px solid #eee;
}
td {
padding: 12px 16px;
font-size: 14px;
border-bottom: 1px solid #f5f5f5;
vertical-align: middle;
}
tr:last-child td {
border-bottom: none;
}
.col-code {
font-weight: 600;
white-space: nowrap;
}
.col-code a {
color: #4a6cf7;
text-decoration: none;
}
.col-code a:hover {
text-decoration: underline;
}
.col-url {
max-width: 320px;
}
.url-text {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 320px;
}
.col-num {
width: 60px;
text-align: right;
}
.col-time {
width: 140px;
white-space: nowrap;
font-size: 13px;
color: #888;
}
.col-action {
width: 140px;
white-space: nowrap;
}
.col-action .btn-small {
margin-left: 4px;
}
.col-action .btn-small:first-child {
margin-left: 0;
}
.edit-row {
display: flex;
gap: 4px;
align-items: center;
}
.edit-input {
flex: 1;
padding: 6px 10px;
border: 2px solid #4a6cf7;
border-radius: 6px;
font-size: 14px;
outline: none;
min-width: 200px;
}
.confirm-text {
font-size: 13px;
color: #d32f2f;
margin-right: 4px;
}
@media (max-width: 768px) {
.admin-container {
padding: 20px 8px;
}
.table-wrap {
overflow-x: auto;
}
table {
min-width: 700px;
}
.header-left {
flex-direction: column;
gap: 4px;
}
.toolbar {
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
}
</style>

301
frontend/src/App.svelte

@ -0,0 +1,301 @@
<script>
import AdminPanel from './AdminPanel.svelte';
let url = '';
let loading = false;
let result = null;
let error = '';
let copied = false;
let isAdmin = false;
function syncHash() {
isAdmin = window.location.hash === '#admin';
}
syncHash();
window.addEventListener('hashchange', syncHash);
async function shorten() {
const trimmed = url.trim();
if (!trimmed) return;
loading = true;
error = '';
result = null;
try {
const res = await fetch('/api/shorten', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: trimmed })
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `请求失败 (${res.status})`);
}
const data = await res.json();
result = data;
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
function handleKeydown(e) {
if (e.key === 'Enter') shorten();
}
async function copy(text) {
try {
await navigator.clipboard.writeText(text);
copied = true;
setTimeout(() => { copied = false; }, 2000);
} catch {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
copied = true;
setTimeout(() => { copied = false; }, 2000);
}
}
</script>
{#if isAdmin}
<AdminPanel />
{:else}
<div class="container">
<header class="header">
<h1>短链接</h1>
<p class="subtitle">粘贴长链接,一键变短</p>
<a href="#admin" class="admin-link">管理</a>
</header>
<div class="card">
<div class="input-row">
<input
type="text"
class="url-input"
placeholder="粘贴长链接,比如 https://example.com/very-long-url..."
bind:value={url}
on:keydown={handleKeydown}
disabled={loading}
/>
<button
class="btn"
on:click={shorten}
disabled={loading || !url.trim()}
>
{loading ? '生成中...' : '缩短'}
</button>
</div>
{#if error}
<div class="error">{error}</div>
{/if}
{#if result}
<div class="result">
<div class="result-row">
<input
type="text"
class="result-input"
value={result.short_url || `${location.origin}/${result.code}`}
readonly
/>
<button class="btn btn-copy" on:click={() => copy(result.short_url || `${location.origin}/${result.code}`)}>
{copied ? '已复制' : '复制'}
</button>
</div>
<div class="result-meta">
<span class="meta-item">原始链接:{result.original_url}</span>
</div>
</div>
{/if}
</div>
</div>
{/if}
<style>
:global(*) {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:global(body) {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f0f2f5;
color: #1a1a2e;
min-height: 100vh;
}
.container {
max-width: 640px;
margin: 0 auto;
padding: 60px 16px 40px;
}
.header {
text-align: center;
margin-bottom: 32px;
}
.header h1 {
font-size: 28px;
font-weight: 700;
margin-bottom: 8px;
}
.subtitle {
color: #666;
font-size: 15px;
}
.admin-link {
display: inline-block;
margin-top: 8px;
font-size: 13px;
color: #999;
text-decoration: none;
}
.admin-link:hover {
color: #4a6cf7;
}
.card {
background: #fff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.input-row {
display: flex;
gap: 8px;
}
.url-input {
flex: 1;
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 15px;
outline: none;
transition: border-color 0.2s;
}
.url-input:focus {
border-color: #4a6cf7;
}
.url-input:disabled {
background: #f5f5f5;
}
.btn {
padding: 12px 24px;
background: #4a6cf7;
color: #fff;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: background 0.2s;
}
.btn:hover:not(:disabled) {
background: #3b5de7;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error {
margin-top: 12px;
padding: 10px 14px;
background: #fff0f0;
color: #d32f2f;
border-radius: 8px;
font-size: 14px;
}
.result {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.result-row {
display: flex;
gap: 8px;
}
.result-input {
flex: 1;
padding: 12px 16px;
border: 2px solid #e8f5e9;
border-radius: 8px;
font-size: 15px;
background: #f1f8f2;
color: #2e7d32;
outline: none;
font-weight: 500;
}
.btn-copy {
background: #2e7d32;
}
.btn-copy:hover:not(:disabled) {
background: #1b5e20;
}
.result-meta {
margin-top: 8px;
font-size: 13px;
color: #999;
}
.meta-item {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 480px) {
.container {
padding: 32px 12px 24px;
}
.header h1 {
font-size: 24px;
}
.input-row {
flex-direction: column;
}
.btn {
width: 100%;
}
.result-row {
flex-direction: column;
}
}
</style>

72
frontend/src/api.js

@ -0,0 +1,72 @@
const TOKEN_KEY = 'admin_token';
export function getToken() {
return sessionStorage.getItem(TOKEN_KEY) || '';
}
export function setToken(t) {
sessionStorage.setItem(TOKEN_KEY, t);
}
export function clearToken() {
sessionStorage.removeItem(TOKEN_KEY);
}
async function adminFetch(path, options = {}) {
const res = await fetch(path, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${getToken()}`,
},
});
if (res.status === 401) {
clearToken();
throw new Error('认证失败,请检查 Token');
}
return res;
}
export async function fetchLinks() {
const res = await adminFetch('/api/admin/links');
if (!res.ok) throw new Error('加载失败');
return res.json();
}
export async function updateLink(code, url) {
const res = await adminFetch(`/api/admin/links/${code}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || '更新失败');
}
return res.json();
}
export async function deleteLink(code) {
const res = await adminFetch(`/api/admin/links/${code}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('删除失败');
return res.json();
}
export async function exportData(format = 'json') {
const res = await adminFetch(`/api/admin/export?format=${format}`);
if (!res.ok) throw new Error('导出失败');
return res.blob();
}
export function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}

7
frontend/src/main.js

@ -0,0 +1,7 @@
import App from './App.svelte';
const app = new App({
target: document.getElementById('app')
});
export default app;

12
frontend/vite.config.js

@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
server: {
proxy: {
'/api': 'http://localhost:8080',
'^/[a-zA-Z0-9]{7}$': 'http://localhost:8080'
}
}
});

18
short-link.service

@ -0,0 +1,18 @@
[Unit]
Description=Short Link Service
After=network.target
[Service]
Type=simple
ExecStart=/opt/short-link/short-link-server
Environment=DB_PATH=/opt/short-link/data
Environment=DOMAIN=https://xieyaxin.top:8899
Environment=ADDR=:8080
Environment=ADMIN_TOKEN=asdaersfdgfdhtretewfsddfgfdgdsfs
Restart=always
RestartSec=5
User=nobody
Group=nogroup
[Install]
WantedBy=multi-user.target
Loading…
Cancel
Save