Browse Source
- 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
commit
0f0f211ac2
21 changed files with 3426 additions and 0 deletions
@ -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 |
||||
@ -0,0 +1,4 @@ |
|||||
|
node_modules |
||||
|
dist |
||||
|
backend/.go |
||||
|
backend/file::memory:?cache=shared |
||||
@ -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 |
||||
|
} |
||||
|
} |
||||
@ -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 格式下载 |
||||
@ -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) |
||||
|
} |
||||
@ -0,0 +1,3 @@ |
|||||
|
module shortlink |
||||
|
|
||||
|
go 1.22 |
||||
@ -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 |
||||
|
} |
||||
@ -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) |
||||
|
} |
||||
|
} |
||||
@ -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") |
||||
|
} |
||||
@ -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" |
||||
|
} |
||||
|
] |
||||
@ -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) |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
|
} |
||||
@ -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> |
||||
File diff suppressed because it is too large
@ -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" |
||||
|
} |
||||
|
} |
||||
@ -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> |
||||
@ -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> |
||||
@ -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); |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
import App from './App.svelte'; |
||||
|
|
||||
|
const app = new App({ |
||||
|
target: document.getElementById('app') |
||||
|
}); |
||||
|
|
||||
|
export default app; |
||||
@ -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' |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
@ -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…
Reference in new issue