Initial commit: Sproutlink short URL system
This commit is contained in:
19
web/index.html
Normal file
19
web/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SproutLink</title>
|
||||
<meta name="description" content="短链与二维码" />
|
||||
<meta name="theme-color" content="#fafafa" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/pwa-192x192.png" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
27
web/package.json
Normal file
27
web/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "sproutlink-web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"gen:pwa-icons": "node scripts/gen-pwa-icons.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.8",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
}
|
||||
}
|
||||
BIN
web/public/favicon.ico
Normal file
BIN
web/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
BIN
web/public/logo.png
Normal file
BIN
web/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 MiB |
BIN
web/public/pwa-192x192.png
Normal file
BIN
web/public/pwa-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
BIN
web/public/pwa-512x512.png
Normal file
BIN
web/public/pwa-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 366 KiB |
23
web/scripts/gen-pwa-icons.mjs
Normal file
23
web/scripts/gen-pwa-icons.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Regenerate PWA icons from public/logo.png (run after logo change).
|
||||
* node scripts/gen-pwa-icons.mjs
|
||||
*/
|
||||
import sharp from 'sharp';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const pub = join(__dirname, '..', 'public');
|
||||
const src = join(pub, 'logo.png');
|
||||
|
||||
await sharp(src)
|
||||
.resize(192, 192, { fit: 'cover', position: 'center' })
|
||||
.png()
|
||||
.toFile(join(pub, 'pwa-192x192.png'));
|
||||
|
||||
await sharp(src)
|
||||
.resize(512, 512, { fit: 'cover', position: 'center' })
|
||||
.png()
|
||||
.toFile(join(pub, 'pwa-512x512.png'));
|
||||
|
||||
console.log('Wrote public/pwa-192x192.png, public/pwa-512x512.png');
|
||||
510
web/src/App.css
Normal file
510
web/src/App.css
Normal file
@@ -0,0 +1,510 @@
|
||||
.app-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
width: 100%;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
max-width: var(--doc-max);
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem;
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
.app-layout--home .header-inner {
|
||||
max-width: min(var(--content-max), 100% - 2rem);
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-btn:hover { color: var(--text); }
|
||||
.nav-btn.active {
|
||||
color: var(--ink);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
.nav-btn-ghost { font-size: 0.8125rem; color: var(--muted); }
|
||||
|
||||
/* Main + document shell */
|
||||
.doc-main {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 1.75rem 1.5rem 2.5rem;
|
||||
}
|
||||
|
||||
.doc-container {
|
||||
max-width: var(--doc-max);
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 首页:统计在上、主内容下,整体收拢居中 */
|
||||
.doc-container.doc-home {
|
||||
max-width: min(var(--content-max), 100% - 1.5rem);
|
||||
}
|
||||
|
||||
.doc-home-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.15rem;
|
||||
}
|
||||
|
||||
.doc-home-stats {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.doc-col-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 首页三列统计:顶部横排(桌面/手机同结构) */
|
||||
.doc-home .stats-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.doc-home .stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
min-height: 3.6rem;
|
||||
padding: 0.55rem 0.4rem;
|
||||
}
|
||||
.doc-home .stat-value {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.doc-home .stat-label {
|
||||
font-size: 0.64rem;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.04em;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.doc-home .stat-value {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
.doc-home .stat-card {
|
||||
min-height: 3.9rem;
|
||||
padding: 0.65rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 899px) {
|
||||
.doc-main {
|
||||
padding: 1.1rem 0.75rem 1.75rem;
|
||||
}
|
||||
.page-title {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.paper, .card {
|
||||
padding: 1.1rem 1rem;
|
||||
}
|
||||
.result-box {
|
||||
padding: 0.85rem 0.8rem;
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
.result-box-body {
|
||||
align-items: center;
|
||||
}
|
||||
.result-qr {
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
.result-qr-img,
|
||||
.result-qr img {
|
||||
width: min(200px, 78vw) !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
.result-text > div {
|
||||
justify-content: center !important;
|
||||
}
|
||||
.result-meta {
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 1rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* 统计条(非首页场景保留横向) */
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
padding: 0.8rem 0.9rem;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--muted);
|
||||
margin-top: 0.2rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
color: var(--faint);
|
||||
font-size: 0.75rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.site-footer a { color: var(--faint); text-decoration: none; }
|
||||
.site-footer a:hover { text-decoration: underline; color: var(--muted); }
|
||||
|
||||
/* Paper / card */
|
||||
.paper, .card {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
padding: 1.5rem 1.35rem;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
background: var(--ink);
|
||||
color: var(--surface);
|
||||
padding: 0.5rem 1.1rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-primary:hover { background: #262626; }
|
||||
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.btn-danger {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.3rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-danger:hover { background: var(--hover); }
|
||||
|
||||
.btn-ghost {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-ghost:hover { background: var(--hover); }
|
||||
.btn-ghost:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.page-num {
|
||||
min-width: 2rem;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0.3rem 0.45rem;
|
||||
}
|
||||
.page-num.is-active {
|
||||
background: var(--ink) !important;
|
||||
color: var(--surface) !important;
|
||||
border-color: var(--ink) !important;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.form-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.form-hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.form-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Result */
|
||||
.result-box {
|
||||
background: var(--hover);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.15rem;
|
||||
margin-top: 1.1rem;
|
||||
}
|
||||
.result-box-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
@media (min-width: 520px) {
|
||||
.result-box-body {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
.result-qr {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.result-qr img,
|
||||
.result-qr-img {
|
||||
display: block;
|
||||
width: 200px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 1;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.result-qr-download {
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.result-text { flex: 1; min-width: 0; width: 100%; }
|
||||
.result-url {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
word-break: break-all;
|
||||
text-decoration: none;
|
||||
}
|
||||
.result-url:hover { text-decoration: underline; }
|
||||
.result-meta {
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Error (neutral) */
|
||||
.error-msg {
|
||||
background: var(--hover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: var(--text);
|
||||
font-size: 0.82rem;
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
padding: 1.5rem 1.4rem;
|
||||
width: 100%;
|
||||
max-width: 22rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.9rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.9rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.link-table-wrap {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.link-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.link-table th {
|
||||
text-align: left;
|
||||
padding: 0.45rem 0.6rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid var(--ink);
|
||||
white-space: nowrap;
|
||||
background: var(--surface);
|
||||
}
|
||||
.link-table td {
|
||||
padding: 0.6rem 0.6rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: top;
|
||||
}
|
||||
.link-table tr:last-child td { border-bottom: none; }
|
||||
.link-table tr:hover td { background: var(--hover); }
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 0.08rem 0.32rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
background: var(--bg);
|
||||
border-radius: 1px;
|
||||
margin-left: 0.2rem;
|
||||
vertical-align: middle;
|
||||
line-height: 1.3;
|
||||
max-width: 12rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tag-void, .tag-red {
|
||||
color: var(--text);
|
||||
border-color: var(--faint);
|
||||
background: #f0f0f0;
|
||||
}
|
||||
.tag-ok, .tag-green {
|
||||
color: var(--ink);
|
||||
border-color: #c4c4c4;
|
||||
background: var(--surface);
|
||||
}
|
||||
.tag-mute, .tag-gray, .tag-blue { color: var(--muted); }
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
justify-content: center;
|
||||
margin-top: 1.1rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Edit drawer */
|
||||
.edit-drawer {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
padding: 1.1rem 1.15rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.edit-drawer-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.85rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.logotype {
|
||||
text-decoration: none;
|
||||
}
|
||||
.logotype span {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-muted {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.link-muted:hover { text-decoration: underline; color: var(--text); }
|
||||
|
||||
.admin-toolbar {
|
||||
text-align: right;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
margin: -0.2rem 0 0.65rem;
|
||||
}
|
||||
105
web/src/App.tsx
Normal file
105
web/src/App.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import Logo from './components/Logo';
|
||||
import CreateForm from './components/CreateForm';
|
||||
import AdminPanel from './components/AdminPanel';
|
||||
import TokenModal from './components/TokenModal';
|
||||
import Stats from './components/Stats';
|
||||
import './App.css';
|
||||
|
||||
export default function App() {
|
||||
const [isAdmin, setIsAdmin] = useState(() => !!sessionStorage.getItem('admin_token'));
|
||||
const [showTokenModal, setShowTokenModal] = useState(false);
|
||||
const [logoClickCount, setLogoClickCount] = useState(0);
|
||||
const [view, setView] = useState<'home' | 'admin'>('home');
|
||||
|
||||
const handleLogoClick = useCallback(() => {
|
||||
if (isAdmin) {
|
||||
setView(v => (v === 'admin' ? 'home' : 'admin'));
|
||||
return;
|
||||
}
|
||||
const next = logoClickCount + 1;
|
||||
setLogoClickCount(next);
|
||||
if (next >= 5) {
|
||||
setLogoClickCount(0);
|
||||
setShowTokenModal(true);
|
||||
}
|
||||
}, [logoClickCount, isAdmin]);
|
||||
|
||||
const handleTokenSuccess = () => {
|
||||
setIsAdmin(true);
|
||||
setShowTokenModal(false);
|
||||
setView('admin');
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
sessionStorage.removeItem('admin_token');
|
||||
setIsAdmin(false);
|
||||
setView('home');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`app-layout${view === 'home' ? ' app-layout--home' : ''}`}>
|
||||
<header className="site-header">
|
||||
<div className="header-inner">
|
||||
<Logo onClick={handleLogoClick} />
|
||||
<nav className="header-nav" aria-label="主导航">
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`nav-btn ${view === 'home' ? 'active' : ''}`}
|
||||
onClick={() => setView('home')}
|
||||
>
|
||||
创建
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`nav-btn ${view === 'admin' ? 'active' : ''}`}
|
||||
onClick={() => setView('admin')}
|
||||
>
|
||||
管理
|
||||
</button>
|
||||
<button type="button" className="nav-btn nav-btn-ghost" onClick={handleLogout}>
|
||||
退出
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="doc-main">
|
||||
{view === 'home' && (
|
||||
<div className="doc-container doc-home">
|
||||
<div className="doc-home-layout">
|
||||
<div className="doc-home-stats" aria-label="全站统计">
|
||||
<Stats />
|
||||
</div>
|
||||
<div className="doc-col-main">
|
||||
<h1 className="page-title">新建</h1>
|
||||
<CreateForm isAdmin={isAdmin} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{view === 'admin' && isAdmin && (
|
||||
<div className="doc-container">
|
||||
<h1 className="page-title">管理</h1>
|
||||
<AdminPanel />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="site-footer">
|
||||
<p>SproutLink</p>
|
||||
</footer>
|
||||
|
||||
{showTokenModal && (
|
||||
<TokenModal
|
||||
onSuccess={handleTokenSuccess}
|
||||
onClose={() => setShowTokenModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
web/src/api.ts
Normal file
160
web/src/api.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
// API client — talks to the Cloudflare Worker
|
||||
|
||||
const BASE = import.meta.env.VITE_API_BASE ?? '';
|
||||
|
||||
function authHeader(): Record<string, string> {
|
||||
const token = sessionStorage.getItem('admin_token');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
return fetch(`${BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeader(),
|
||||
...(init.headers as Record<string, string> ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GeoRule {
|
||||
mode: 'allow' | 'block';
|
||||
countries: string[];
|
||||
}
|
||||
|
||||
/** 终端与浏览器:与 Worker 内校验逻辑一致 */
|
||||
export type ClientDevice = 'mobile' | 'desktop';
|
||||
export type ClientBrowser = 'edge' | 'chrome' | 'safari' | 'firefox' | 'opera' | 'other';
|
||||
|
||||
export interface ClientRule {
|
||||
/** null 表示不限制 */
|
||||
device: ClientDevice | null;
|
||||
/**
|
||||
* 非空 = 仅允许名单内浏览器;空数组 = 不限制浏览器
|
||||
* 与 null 在存储时统一为「无浏览器限制」
|
||||
*/
|
||||
browsers: ClientBrowser[] | null;
|
||||
}
|
||||
|
||||
/** IP 白/黑名单,与边缘 CF-Connecting-IP 比较 */
|
||||
export interface IpRule {
|
||||
mode: 'allow' | 'block';
|
||||
/** 支持 IPv4、IPv4 CIDR(如 10.0.0.0/8)、完整 IPv6 地址;最多约 200 条 */
|
||||
ips: string[];
|
||||
}
|
||||
|
||||
export interface LinkItem {
|
||||
id: number;
|
||||
code: string;
|
||||
target_url: string;
|
||||
expires_at: number | null;
|
||||
max_clicks: number | null;
|
||||
clicks: number;
|
||||
has_password: number;
|
||||
geo_rule: string | null; // JSON string of GeoRule, or null
|
||||
client_rule: string | null; // JSON string of ClientRule, or null
|
||||
ip_rule: string | null; // JSON string of IpRule, or null
|
||||
deleted: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface CreatePayload {
|
||||
target_url: string;
|
||||
/**
|
||||
* 仅管理员可传:1–32 位,仅 a-zA-Z0-9_-,且不能为「恰好 4 位纯英数字」以免与系统随机短码空间混淆。
|
||||
*/
|
||||
custom_code?: string | null;
|
||||
ttl_seconds?: number | null;
|
||||
max_clicks?: number | null;
|
||||
password?: string | null;
|
||||
geo_rule?: GeoRule | null;
|
||||
client_rule?: ClientRule | null;
|
||||
ip_rule?: IpRule | null;
|
||||
}
|
||||
|
||||
export interface UpdatePayload {
|
||||
target_url?: string;
|
||||
expires_at?: number | null;
|
||||
max_clicks?: number | null;
|
||||
password?: string | null;
|
||||
clear_password?: boolean;
|
||||
geo_rule?: GeoRule | null;
|
||||
client_rule?: ClientRule | null;
|
||||
ip_rule?: IpRule | null;
|
||||
deleted?: number;
|
||||
}
|
||||
|
||||
export async function verifyAdmin(token: string): Promise<boolean> {
|
||||
const res = await fetch(`${BASE}/api/auth`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
export async function createLink(payload: CreatePayload): Promise<{ code: string; url: string; target_url: string }> {
|
||||
const res = await apiFetch('/api/links', { method: 'POST', body: JSON.stringify(payload) });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({})) as { error?: string };
|
||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function listLinks(page = 1, limit = 20): Promise<{ items: LinkItem[]; total: number; page: number; limit: number }> {
|
||||
const res = await apiFetch(`/api/links?page=${page}&limit=${limit}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function encCode(code: string): string {
|
||||
return encodeURIComponent(code);
|
||||
}
|
||||
|
||||
export async function getLink(code: string): Promise<LinkItem> {
|
||||
const res = await apiFetch(`/api/links/${encCode(code)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateLink(code: string, payload: UpdatePayload): Promise<void> {
|
||||
const res = await apiFetch(`/api/links/${encCode(code)}`, { method: 'PATCH', body: JSON.stringify(payload) });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({})) as { error?: string };
|
||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteLink(code: string): Promise<void> {
|
||||
const res = await apiFetch(`/api/links/${encCode(code)}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
export interface SiteStats {
|
||||
total_links: number;
|
||||
total_clicks: number;
|
||||
/** 全站页浏览量(由前端进入站点时 POST /api/pv 累计) */
|
||||
page_views: number;
|
||||
}
|
||||
|
||||
export async function getStats(): Promise<SiteStats> {
|
||||
const res = await fetch(`${BASE}/api/stats`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 全站「浏览量」+1,建议在应用根组件挂载时调用一次(需配合 useRef 避免 Strict 双次) */
|
||||
/** @returns 是否请求成功(200),失败可再次尝试上报 */
|
||||
export async function recordPageView(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/api/pv`, { method: 'POST', headers: { 'Content-Type': 'application/json' } });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
37
web/src/clientRuleHelpers.ts
Normal file
37
web/src/clientRuleHelpers.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { ClientBrowser, ClientDevice, ClientRule } from './api';
|
||||
|
||||
export const DEVICE_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: '', label: '不限制' },
|
||||
{ value: 'mobile', label: '仅手机端可访问' },
|
||||
{ value: 'desktop', label: '仅电脑端可访问' },
|
||||
];
|
||||
|
||||
export const BROWSER_OPTIONS: { id: ClientBrowser; label: string }[] = [
|
||||
{ id: 'chrome', label: 'Chrome' },
|
||||
{ id: 'edge', label: 'Edge' },
|
||||
{ id: 'safari', label: 'Safari' },
|
||||
{ id: 'firefox', label: 'Firefox' },
|
||||
{ id: 'opera', label: 'Opera' },
|
||||
];
|
||||
|
||||
export function buildClientRule(device: string, selected: Set<ClientBrowser>): ClientRule | null {
|
||||
const d: ClientDevice | null = device === 'mobile' || device === 'desktop' ? device : null;
|
||||
const browsers = BROWSER_OPTIONS.filter(o => selected.has(o.id)).map(o => o.id);
|
||||
if (!d && browsers.length === 0) return null;
|
||||
return { device: d, browsers: browsers.length > 0 ? browsers : null };
|
||||
}
|
||||
|
||||
export function parseClientRuleJson(raw: string | null | undefined): { device: string; selected: Set<ClientBrowser> } {
|
||||
if (!raw) return { device: '', selected: new Set() };
|
||||
try {
|
||||
const r = JSON.parse(raw) as ClientRule;
|
||||
const device = r.device === 'mobile' || r.device === 'desktop' ? r.device : '';
|
||||
const selected = new Set<ClientBrowser>();
|
||||
for (const b of r.browsers ?? []) {
|
||||
if (BROWSER_OPTIONS.some(o => o.id === b)) selected.add(b);
|
||||
}
|
||||
return { device, selected };
|
||||
} catch {
|
||||
return { device: '', selected: new Set() };
|
||||
}
|
||||
}
|
||||
296
web/src/components/AdminPanel.tsx
Normal file
296
web/src/components/AdminPanel.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { listLinks, updateLink, deleteLink, type ClientRule, type IpRule, type LinkItem, type GeoRule } from '../api';
|
||||
import EditDrawer from './EditDrawer';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function formatDate(ts: number | null): string {
|
||||
if (!ts) return '—';
|
||||
return new Date(ts * 1000).toLocaleString('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function geoBadge(item: LinkItem): JSX.Element | null {
|
||||
if (!item.geo_rule) return null;
|
||||
let rule: GeoRule;
|
||||
try { rule = JSON.parse(item.geo_rule); } catch { return null; }
|
||||
const label = rule.mode === 'allow'
|
||||
? `仅 ${rule.countries.join('/')}`
|
||||
: `屏蔽 ${rule.countries.join('/')}`;
|
||||
return <span className="tag tag-mute" style={{ marginLeft: '0.3rem' }} title={label}>{label}</span>;
|
||||
}
|
||||
|
||||
function clientBadge(item: LinkItem): JSX.Element | null {
|
||||
if (!item.client_rule) return null;
|
||||
let r: ClientRule;
|
||||
try { r = JSON.parse(item.client_rule); } catch { return null; }
|
||||
const parts: string[] = [];
|
||||
if (r.device === 'mobile') parts.push('仅手机');
|
||||
if (r.device === 'desktop') parts.push('仅电脑');
|
||||
if (r.browsers && r.browsers.length) {
|
||||
const m: Record<string, string> = { chrome: 'Chrome', edge: 'Edge', safari: 'Safari', firefox: 'Fx', opera: 'Op', other: '其他' };
|
||||
parts.push(r.browsers.map(b => m[b] || b).join('+'));
|
||||
}
|
||||
if (!parts.length) return null;
|
||||
const label = [r.device && (r.device === 'mobile' ? '手机' : '电脑'), r.browsers?.length ? `浏览器: ${r.browsers.join(',')}` : ''].filter(Boolean).join(' · ');
|
||||
return (
|
||||
<span
|
||||
className="tag tag-mute"
|
||||
style={{ marginLeft: '0.3rem', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
title={label}
|
||||
>
|
||||
{parts.join(' · ')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ipBadge(item: LinkItem): JSX.Element | null {
|
||||
if (!item.ip_rule) return null;
|
||||
let r: IpRule;
|
||||
try { r = JSON.parse(item.ip_rule); } catch { return null; }
|
||||
const n = r.ips?.length ?? 0;
|
||||
if (!n) return null;
|
||||
const short = r.mode === 'allow' ? `白${n}条` : `黑${n}条`;
|
||||
return (
|
||||
<span
|
||||
className="tag tag-mute"
|
||||
style={{ marginLeft: '0.3rem' }}
|
||||
title={r.mode === 'allow' ? 'IP 白名单' : 'IP 黑名单'}
|
||||
>
|
||||
{short}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function expiredBadge(item: LinkItem): JSX.Element {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (item.expires_at && now > item.expires_at) {
|
||||
return <span className="tag tag-void">已过期</span>;
|
||||
}
|
||||
if (item.max_clicks !== null && item.clicks >= item.max_clicks) {
|
||||
return <span className="tag tag-void">已满</span>;
|
||||
}
|
||||
if (item.deleted) return <span className="tag tag-mute">停用</span>;
|
||||
return <span className="tag tag-ok">有效</span>;
|
||||
}
|
||||
|
||||
export default function AdminPanel() {
|
||||
const [items, setItems] = useState<LinkItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [editing, setEditing] = useState<LinkItem | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (p: number) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await listLinks(p, PAGE_SIZE);
|
||||
setItems(data.items);
|
||||
setTotal(data.total);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(page); }, [page, load]);
|
||||
|
||||
const handleDelete = async (code: string) => {
|
||||
try {
|
||||
await deleteLink(code);
|
||||
setConfirmDelete(null);
|
||||
load(page);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleDeleted = async (item: LinkItem) => {
|
||||
try {
|
||||
await updateLink(item.code, { deleted: item.deleted ? 0 : 1 });
|
||||
load(page);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setEditing(null);
|
||||
load(page);
|
||||
};
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="admin-toolbar">{total}</div>
|
||||
|
||||
{error && <div className="error-msg" style={{ marginBottom: '1rem' }}>{error}</div>}
|
||||
|
||||
{loading && <p className="link-muted" style={{ fontSize: '0.8rem', marginBottom: '0.75rem' }}>加载中</p>}
|
||||
|
||||
{editing && (
|
||||
<EditDrawer
|
||||
item={editing}
|
||||
onSave={handleSave}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<div className="link-table-wrap">
|
||||
<table className="link-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>短码</th>
|
||||
<th>目标链接</th>
|
||||
<th>状态</th>
|
||||
<th>点击</th>
|
||||
<th>过期时间</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ textAlign: 'center', color: 'var(--muted)', padding: '2rem' }}>
|
||||
暂无数据
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{items.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td style={{ maxWidth: 200, wordBreak: 'break-all' }}>
|
||||
<code style={{ fontFamily: 'ui-monospace, monospace', fontSize: '0.88rem', fontWeight: 500, color: 'var(--ink)' }}>
|
||||
{item.code}
|
||||
</code>
|
||||
{item.has_password ? (
|
||||
<span className="tag tag-mute" style={{ marginLeft: '0.35rem' }}>密</span>
|
||||
) : null}
|
||||
{geoBadge(item)}
|
||||
{clientBadge(item)}
|
||||
{ipBadge(item)}
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href={item.target_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: '0.82rem', color: 'var(--muted)', wordBreak: 'break-all' }}
|
||||
>
|
||||
{item.target_url.length > 45 ? item.target_url.slice(0, 45) + '…' : item.target_url}
|
||||
</a>
|
||||
</td>
|
||||
<td>{expiredBadge(item)}</td>
|
||||
<td style={{ fontSize: '0.85rem' }}>
|
||||
{item.clicks}
|
||||
{item.max_clicks !== null && (
|
||||
<span style={{ color: 'var(--muted)' }}> / {item.max_clicks}</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.8rem', color: 'var(--muted)', whiteSpace: 'nowrap' }}>
|
||||
{formatDate(item.expires_at)}
|
||||
</td>
|
||||
<td style={{ fontSize: '0.8rem', color: 'var(--muted)', whiteSpace: 'nowrap' }}>
|
||||
{formatDate(item.created_at)}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '0.35rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => setEditing(item)}
|
||||
disabled={!!editing}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => handleToggleDeleted(item)}
|
||||
style={{ fontSize: '0.78rem' }}
|
||||
>
|
||||
{item.deleted ? '恢复' : '禁用'}
|
||||
</button>
|
||||
{confirmDelete === item.code ? (
|
||||
<>
|
||||
<button
|
||||
className="btn-danger"
|
||||
onClick={() => handleDelete(item.code)}
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="btn-danger"
|
||||
onClick={() => setConfirmDelete(item.code)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="pagination">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter(p => Math.abs(p - page) <= 2 || p === 1 || p === totalPages)
|
||||
.reduce<(number | '...')[]>((acc, p, idx, arr) => {
|
||||
if (idx > 0 && (arr[idx - 1] as number) < p - 1) acc.push('...');
|
||||
acc.push(p);
|
||||
return acc;
|
||||
}, [])
|
||||
.map((p, i) =>
|
||||
p === '...'
|
||||
? <span key={`ellipsis-${i}`} style={{ padding: '0.3rem 0.5rem', color: 'var(--muted)' }}>…</span>
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
key={p}
|
||||
className={`btn-ghost page-num ${p === page ? 'is-active' : ''}`}
|
||||
onClick={() => setPage(p as number)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
362
web/src/components/CreateForm.tsx
Normal file
362
web/src/components/CreateForm.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import { useState } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { createLink, type ClientBrowser, type GeoRule } from '../api';
|
||||
import {
|
||||
BROWSER_OPTIONS,
|
||||
DEVICE_OPTIONS,
|
||||
buildClientRule,
|
||||
} from '../clientRuleHelpers';
|
||||
import { buildIpRule } from '../ipRuleHelpers';
|
||||
|
||||
const TTL_OPTIONS = [
|
||||
{ label: '永久有效', value: '' },
|
||||
{ label: '1 小时', value: '3600' },
|
||||
{ label: '6 小时', value: '21600' },
|
||||
{ label: '1 天', value: '86400' },
|
||||
{ label: '3 天', value: '259200' },
|
||||
{ label: '7 天', value: '604800' },
|
||||
{ label: '30 天', value: '2592000' },
|
||||
{ label: '自定义天数', value: 'custom' },
|
||||
];
|
||||
|
||||
// Preset geo rules shown in the dropdown
|
||||
const GEO_OPTIONS = [
|
||||
{ label: '无限制', value: '' },
|
||||
{ label: '仅允许中国大陆访问', value: 'allow_cn' },
|
||||
{ label: '屏蔽中国大陆访问', value: 'block_cn' },
|
||||
{ label: '仅允许中国大陆 + 港澳台', value: 'allow_cntw' },
|
||||
{ label: '自定义…', value: 'custom' },
|
||||
];
|
||||
|
||||
function buildGeoRule(preset: string, custom: string): GeoRule | null {
|
||||
switch (preset) {
|
||||
case 'allow_cn': return { mode: 'allow', countries: ['CN'] };
|
||||
case 'block_cn': return { mode: 'block', countries: ['CN'] };
|
||||
case 'allow_cntw': return { mode: 'allow', countries: ['CN', 'TW', 'HK', 'MO'] };
|
||||
case 'custom': {
|
||||
const raw = custom.toUpperCase().split(/[\s,]+/).filter(c => /^[A-Z]{2}$/.test(c));
|
||||
if (!raw.length) return null;
|
||||
return { mode: 'block', countries: raw };
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface CreatedLink {
|
||||
code: string;
|
||||
url: string;
|
||||
target_url: string;
|
||||
/** PNG data URL; missing if 浏览器生成失败 */
|
||||
qrDataUrl?: string;
|
||||
}
|
||||
|
||||
type CreateFormProps = { isAdmin?: boolean };
|
||||
|
||||
export default function CreateForm({ isAdmin = false }: CreateFormProps) {
|
||||
const [targetUrl, setTargetUrl] = useState('');
|
||||
const [customCode, setCustomCode] = useState('');
|
||||
const [ttl, setTtl] = useState('');
|
||||
const [customDays, setCustomDays] = useState('');
|
||||
const [maxClicks, setMaxClicks] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [geoPreset, setGeoPreset] = useState('');
|
||||
const [geoCustomMode, setGeoCustomMode] = useState<'allow' | 'block'>('block');
|
||||
const [geoCustom, setGeoCustom] = useState('');
|
||||
const [clientDevice, setClientDevice] = useState('');
|
||||
const [clientBrowsers, setClientBrowsers] = useState<Set<ClientBrowser>>(() => new Set());
|
||||
const [ipMode, setIpMode] = useState<'' | 'allow' | 'block'>('');
|
||||
const [ipText, setIpText] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [created, setCreated] = useState<CreatedLink | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setCreated(null);
|
||||
|
||||
const url = targetUrl.trim();
|
||||
if (!url) { setError('请输入目标 URL'); return; }
|
||||
|
||||
let ttlSeconds: number | null = null;
|
||||
if (ttl === 'custom') {
|
||||
const days = parseFloat(customDays);
|
||||
if (isNaN(days) || days <= 0) { setError('请输入有效的自定义天数'); return; }
|
||||
ttlSeconds = Math.round(days * 86400);
|
||||
} else if (ttl) {
|
||||
ttlSeconds = parseInt(ttl, 10);
|
||||
}
|
||||
|
||||
const maxClicksNum = maxClicks ? parseInt(maxClicks, 10) : null;
|
||||
if (maxClicks && (isNaN(maxClicksNum!) || maxClicksNum! < 1)) {
|
||||
setError('请输入有效的访问次数限制'); return;
|
||||
}
|
||||
|
||||
let geoRule: GeoRule | null = buildGeoRule(geoPreset, geoCustom);
|
||||
if (geoPreset === 'custom' && geoRule) {
|
||||
geoRule.mode = geoCustomMode;
|
||||
}
|
||||
|
||||
const clientRule = buildClientRule(clientDevice, clientBrowsers);
|
||||
const ipRule = buildIpRule(ipMode, ipText);
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await createLink({
|
||||
target_url: url,
|
||||
...(isAdmin && customCode.trim() ? { custom_code: customCode.trim() } : {}),
|
||||
ttl_seconds: ttlSeconds,
|
||||
max_clicks: maxClicksNum,
|
||||
password: password.trim() || null,
|
||||
geo_rule: geoRule,
|
||||
client_rule: clientRule,
|
||||
ip_rule: ipRule,
|
||||
});
|
||||
let qrDataUrl: string | undefined;
|
||||
try {
|
||||
qrDataUrl = await QRCode.toDataURL(result.url, {
|
||||
width: 200,
|
||||
margin: 1,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: { dark: '#0a0a0a', light: '#ffffff' },
|
||||
});
|
||||
} catch {
|
||||
// 仍展示短链
|
||||
}
|
||||
setCreated({ ...result, qrDataUrl });
|
||||
setTargetUrl('');
|
||||
setCustomCode('');
|
||||
setPassword('');
|
||||
setMaxClicks('');
|
||||
setTtl('');
|
||||
setCustomDays('');
|
||||
setGeoPreset('');
|
||||
setGeoCustom('');
|
||||
setClientDevice('');
|
||||
setClientBrowsers(new Set());
|
||||
setIpMode('');
|
||||
setIpText('');
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!created) return;
|
||||
navigator.clipboard.writeText(created.url).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const qrDownloadName = (code: string) => {
|
||||
const safe = code.replace(/[^\w.\-]+/g, '_').replace(/^_+|_+$/g, '') || 'link';
|
||||
return `sproutlink-${safe}.png`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="paper">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="target_url">URL</label>
|
||||
<input
|
||||
id="target_url"
|
||||
type="url"
|
||||
placeholder="https://"
|
||||
value={targetUrl}
|
||||
onChange={e => setTargetUrl(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="custom_code">短码</label>
|
||||
<input
|
||||
id="custom_code"
|
||||
type="text"
|
||||
inputMode="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="可空,自定义(管理员)"
|
||||
value={customCode}
|
||||
onChange={e => setCustomCode(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="ttl">期限</label>
|
||||
<select id="ttl" value={ttl} onChange={e => setTtl(e.target.value)}>
|
||||
{TTL_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{ttl === 'custom' && (
|
||||
<input
|
||||
type="number" min="0.01" step="any" placeholder="天"
|
||||
value={customDays} onChange={e => setCustomDays(e.target.value)}
|
||||
style={{ marginTop: '0.4rem' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="max_clicks">点击上限</label>
|
||||
<input
|
||||
id="max_clicks" type="number" min="1" step="1"
|
||||
placeholder="空为不限"
|
||||
value={maxClicks} onChange={e => setMaxClicks(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="geo_preset">地区</label>
|
||||
<select id="geo_preset" value={geoPreset} onChange={e => setGeoPreset(e.target.value)}>
|
||||
{GEO_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{geoPreset === 'custom' && (
|
||||
<div style={{ marginTop: '0.5rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<select
|
||||
value={geoCustomMode}
|
||||
onChange={e => setGeoCustomMode(e.target.value as 'allow' | 'block')}
|
||||
style={{ width: 'auto', flexShrink: 0 }}
|
||||
>
|
||||
<option value="block">排除</option>
|
||||
<option value="allow">仅</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ISO,如 US,GB"
|
||||
value={geoCustom}
|
||||
onChange={e => setGeoCustom(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 200 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="client_device">设备</label>
|
||||
<select
|
||||
id="client_device"
|
||||
value={clientDevice}
|
||||
onChange={e => setClientDevice(e.target.value)}
|
||||
>
|
||||
{DEVICE_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<span className="form-label" style={{ display: 'block', marginBottom: '0.35rem' }}>浏览器</span>
|
||||
<div
|
||||
className="browser-checkboxes"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem 1rem',
|
||||
fontSize: '0.82rem',
|
||||
}}
|
||||
>
|
||||
{BROWSER_OPTIONS.map(o => (
|
||||
<label
|
||||
key={o.id}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.3rem', cursor: 'pointer' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
style={{ width: 'auto' }}
|
||||
checked={clientBrowsers.has(o.id)}
|
||||
onChange={e => {
|
||||
setClientBrowsers(prev => {
|
||||
const n = new Set(prev);
|
||||
if (e.target.checked) n.add(o.id);
|
||||
else n.delete(o.id);
|
||||
return n;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{o.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="ip_mode">IP</label>
|
||||
<select
|
||||
id="ip_mode"
|
||||
value={ipMode}
|
||||
onChange={e => setIpMode(e.target.value as '' | 'allow' | 'block')}
|
||||
>
|
||||
<option value="">不限</option>
|
||||
<option value="allow">白名单</option>
|
||||
<option value="block">黑名单</option>
|
||||
</select>
|
||||
{ipMode && (
|
||||
<textarea
|
||||
value={ipText}
|
||||
onChange={e => setIpText(e.target.value)}
|
||||
placeholder="IP / CIDR,每行或逗号"
|
||||
rows={3}
|
||||
style={{ marginTop: '0.45rem', fontFamily: 'ui-monospace, monospace', fontSize: '0.82rem' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="password">密码</label>
|
||||
<input
|
||||
id="password" type="password"
|
||||
placeholder="可空"
|
||||
value={password} onChange={e => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-primary" disabled={loading} style={{ width: '100%' }}>
|
||||
{loading ? '生成中…' : '生成短链'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && <div className="error-msg">{error}</div>}
|
||||
|
||||
{created && (
|
||||
<div className="result-box">
|
||||
<div className="result-box-body">
|
||||
{created.qrDataUrl && (
|
||||
<div className="result-qr">
|
||||
<img className="result-qr-img" src={created.qrDataUrl} alt="" />
|
||||
<a
|
||||
className="btn-ghost result-qr-download"
|
||||
href={created.qrDataUrl}
|
||||
download={qrDownloadName(created.code)}
|
||||
>
|
||||
下载二维码
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="result-text">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<a className="result-url" href={created.url} target="_blank" rel="noopener noreferrer">
|
||||
{created.url}
|
||||
</a>
|
||||
<button className="btn-ghost" onClick={handleCopy} style={{ flexShrink: 0 }}>
|
||||
{copied ? '已复制' : '复制链接'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="result-meta">→ {created.target_url}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
237
web/src/components/EditDrawer.tsx
Normal file
237
web/src/components/EditDrawer.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { useState } from 'react';
|
||||
import { updateLink, type ClientBrowser, type LinkItem, type GeoRule } from '../api';
|
||||
import {
|
||||
BROWSER_OPTIONS,
|
||||
DEVICE_OPTIONS,
|
||||
buildClientRule,
|
||||
parseClientRuleJson,
|
||||
} from '../clientRuleHelpers';
|
||||
import { buildIpRule, parseIpRuleText } from '../ipRuleHelpers';
|
||||
|
||||
interface EditDrawerProps {
|
||||
item: LinkItem;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const GEO_OPTIONS = [
|
||||
{ label: '无限制', value: '' },
|
||||
{ label: '仅允许中国大陆访问', value: 'allow_cn' },
|
||||
{ label: '屏蔽中国大陆访问', value: 'block_cn' },
|
||||
{ label: '仅允许中国大陆 + 港澳台', value: 'allow_cntw' },
|
||||
{ label: '自定义…', value: 'custom' },
|
||||
];
|
||||
|
||||
function ruleToPreset(rule: GeoRule | null): string {
|
||||
if (!rule) return '';
|
||||
const key = rule.countries.slice().sort().join(',');
|
||||
if (rule.mode === 'allow' && key === 'CN') return 'allow_cn';
|
||||
if (rule.mode === 'block' && key === 'CN') return 'block_cn';
|
||||
if (rule.mode === 'allow' && key === 'CN,HK,MO,TW') return 'allow_cntw';
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
function buildGeoRule(preset: string, customMode: 'allow' | 'block', customText: string): GeoRule | null {
|
||||
switch (preset) {
|
||||
case 'allow_cn': return { mode: 'allow', countries: ['CN'] };
|
||||
case 'block_cn': return { mode: 'block', countries: ['CN'] };
|
||||
case 'allow_cntw': return { mode: 'allow', countries: ['CN', 'TW', 'HK', 'MO'] };
|
||||
case 'custom': {
|
||||
const raw = customText.toUpperCase().split(/[\s,]+/).filter(c => /^[A-Z]{2}$/.test(c));
|
||||
if (!raw.length) return null;
|
||||
return { mode: customMode, countries: raw };
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function EditDrawer({ item, onSave, onClose }: EditDrawerProps) {
|
||||
const [targetUrl, setTargetUrl] = useState(item.target_url);
|
||||
const [expiresAt, setExpiresAt] = useState<string>(
|
||||
item.expires_at ? new Date(item.expires_at * 1000).toISOString().slice(0, 16) : ''
|
||||
);
|
||||
const [maxClicks, setMaxClicks] = useState<string>(
|
||||
item.max_clicks !== null ? String(item.max_clicks) : ''
|
||||
);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [clearPassword, setClearPassword] = useState(false);
|
||||
|
||||
// Geo state
|
||||
const existingRule: GeoRule | null = item.geo_rule ? JSON.parse(item.geo_rule) : null;
|
||||
const initPreset = ruleToPreset(existingRule);
|
||||
const [geoPreset, setGeoPreset] = useState(initPreset);
|
||||
const [geoCustomMode, setGeoCustomMode] = useState<'allow' | 'block'>(existingRule?.mode ?? 'block');
|
||||
const [geoCustom, setGeoCustom] = useState(
|
||||
initPreset === 'custom' && existingRule ? existingRule.countries.join(', ') : ''
|
||||
);
|
||||
|
||||
const initClient = parseClientRuleJson(item.client_rule);
|
||||
const [clientDevice, setClientDevice] = useState(initClient.device);
|
||||
const [clientBrowsers, setClientBrowsers] = useState<Set<ClientBrowser>>(() => new Set(initClient.selected));
|
||||
|
||||
const initIp = parseIpRuleText(item.ip_rule);
|
||||
const [ipMode, setIpMode] = useState<'' | 'allow' | 'block'>(initIp.mode);
|
||||
const [ipText, setIpText] = useState(initIp.text);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!targetUrl.trim()) { setError('目标链接不能为空'); return; }
|
||||
try { new URL(targetUrl.trim()); } catch { setError('请输入有效的 URL'); return; }
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const geoRule = buildGeoRule(geoPreset, geoCustomMode, geoCustom);
|
||||
const clientRule = buildClientRule(clientDevice, clientBrowsers);
|
||||
const ipRule = buildIpRule(ipMode, ipText);
|
||||
const payload: Parameters<typeof updateLink>[1] = {
|
||||
target_url: targetUrl.trim(),
|
||||
expires_at: expiresAt ? Math.floor(new Date(expiresAt).getTime() / 1000) : null,
|
||||
max_clicks: maxClicks ? parseInt(maxClicks, 10) : null,
|
||||
geo_rule: geoRule,
|
||||
client_rule: clientRule,
|
||||
ip_rule: ipRule,
|
||||
};
|
||||
if (clearPassword) payload.clear_password = true;
|
||||
else if (newPassword) payload.password = newPassword;
|
||||
|
||||
await updateLink(item.code, payload);
|
||||
onSave();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="edit-drawer">
|
||||
<div className="edit-drawer-title">
|
||||
<span>编辑 <code style={{ color: 'var(--ink)', fontSize: '0.95em' }}>{item.code}</code></span>
|
||||
<button onClick={onClose}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--muted)', fontSize: '1.1rem', borderRadius: 0, padding: '0 0.25rem' }}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSave}>
|
||||
{/* Target URL */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">URL</label>
|
||||
<input type="url" value={targetUrl} onChange={e => setTargetUrl(e.target.value)} required />
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="form-label">到期</label>
|
||||
<input type="datetime-local" value={expiresAt} onChange={e => setExpiresAt(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">点击上限</label>
|
||||
<input type="number" min="1" step="1" placeholder="空为不限"
|
||||
value={maxClicks} onChange={e => setMaxClicks(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">地区</label>
|
||||
<select value={geoPreset} onChange={e => setGeoPreset(e.target.value)}>
|
||||
{GEO_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{geoPreset === 'custom' && (
|
||||
<div style={{ marginTop: '0.5rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<select value={geoCustomMode} onChange={e => setGeoCustomMode(e.target.value as 'allow' | 'block')}
|
||||
style={{ width: 'auto', flexShrink: 0 }}>
|
||||
<option value="block">屏蔽</option>
|
||||
<option value="allow">仅允许</option>
|
||||
</select>
|
||||
<input type="text" placeholder="国家码,逗号分隔,如 US,GB"
|
||||
value={geoCustom} onChange={e => setGeoCustom(e.target.value)} style={{ flex: 1, minWidth: 160 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">设备</label>
|
||||
<select value={clientDevice} onChange={e => setClientDevice(e.target.value)}>
|
||||
{DEVICE_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<span className="form-label" style={{ display: 'block', marginBottom: '0.35rem' }}>浏览器</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem 1rem', fontSize: '0.82rem' }}>
|
||||
{BROWSER_OPTIONS.map(o => (
|
||||
<label
|
||||
key={o.id}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.3rem', cursor: 'pointer' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
style={{ width: 'auto' }}
|
||||
checked={clientBrowsers.has(o.id)}
|
||||
onChange={e => {
|
||||
setClientBrowsers(prev => {
|
||||
const n = new Set(prev);
|
||||
if (e.target.checked) n.add(o.id);
|
||||
else n.delete(o.id);
|
||||
return n;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{o.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">IP</label>
|
||||
<select value={ipMode} onChange={e => setIpMode(e.target.value as '' | 'allow' | 'block')}>
|
||||
<option value="">不限</option>
|
||||
<option value="allow">白名单</option>
|
||||
<option value="block">黑名单</option>
|
||||
</select>
|
||||
{ipMode && (
|
||||
<textarea
|
||||
value={ipText}
|
||||
onChange={e => setIpText(e.target.value)}
|
||||
placeholder="每行一个 IP 或 CIDR,最多 200 条"
|
||||
rows={4}
|
||||
style={{ marginTop: '0.45rem', fontFamily: 'ui-monospace, monospace', fontSize: '0.86rem' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">密码</label>
|
||||
<input type="password" placeholder={item.has_password ? '新密码' : '可空'}
|
||||
value={newPassword}
|
||||
onChange={e => { setNewPassword(e.target.value); if (e.target.value) setClearPassword(false); }}
|
||||
disabled={clearPassword} autoComplete="new-password" />
|
||||
{item.has_password && (
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginTop: '0.35rem', fontSize: '0.8rem', cursor: 'pointer', color: 'var(--muted)' }}>
|
||||
<input type="checkbox" style={{ width: 'auto' }} checked={clearPassword}
|
||||
onChange={e => { setClearPassword(e.target.checked); if (e.target.checked) setNewPassword(''); }} />
|
||||
清除密码
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="error-msg" style={{ marginBottom: '0.75rem' }}>{error}</div>}
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
||||
<button type="button" className="btn-ghost" onClick={onClose} disabled={loading}>取消</button>
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
{loading ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
web/src/components/Logo.tsx
Normal file
37
web/src/components/Logo.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
interface LogoProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export default function Logo({ onClick }: LogoProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label="首页"
|
||||
className="logotype"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
borderRadius: 0,
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="SproutLink"
|
||||
width={30}
|
||||
height={30}
|
||||
draggable={false}
|
||||
style={{ display: 'block', objectFit: 'contain' }}
|
||||
/>
|
||||
<span style={{ fontWeight: 700, fontSize: '1rem', color: 'var(--text)', letterSpacing: '-0.01em' }}>
|
||||
SproutLink
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
69
web/src/components/Stats.tsx
Normal file
69
web/src/components/Stats.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStats, type SiteStats } from '../api';
|
||||
import { ensurePageViewOnce } from '../pageView';
|
||||
|
||||
export default function Stats() {
|
||||
const [stats, setStats] = useState<SiteStats | null>(null);
|
||||
|
||||
const load = () => {
|
||||
getStats().then(setStats).catch(() => { /* ignore */ });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
let ok = await ensurePageViewOnce();
|
||||
if (!ok && !cancelled) {
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
ok = await ensurePageViewOnce();
|
||||
}
|
||||
if (cancelled) return;
|
||||
load();
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onVis = () => { if (document.visibilityState === 'visible') load(); };
|
||||
document.addEventListener('visibilitychange', onVis);
|
||||
return () => document.removeEventListener('visibilitychange', onVis);
|
||||
}, []);
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="stats-bar" aria-hidden>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">—</div>
|
||||
<div className="stat-label">短链</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">—</div>
|
||||
<div className="stat-label">点击</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">—</div>
|
||||
<div className="stat-label">浏览</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pv = typeof stats.page_views === 'number' ? stats.page_views : 0;
|
||||
|
||||
return (
|
||||
<div className="stats-bar">
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.total_links.toLocaleString()}</div>
|
||||
<div className="stat-label">短链</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.total_clicks.toLocaleString()}</div>
|
||||
<div className="stat-label">点击</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{pv.toLocaleString()}</div>
|
||||
<div className="stat-label">浏览</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
web/src/components/TokenModal.tsx
Normal file
65
web/src/components/TokenModal.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { verifyAdmin } from '../api';
|
||||
|
||||
interface TokenModalProps {
|
||||
onSuccess: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function TokenModal({ onSuccess, onClose }: TokenModalProps) {
|
||||
const [token, setToken] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token.trim()) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const ok = await verifyAdmin(token.trim());
|
||||
if (ok) {
|
||||
sessionStorage.setItem('admin_token', token.trim());
|
||||
onSuccess();
|
||||
} else {
|
||||
setError('Token 错误,请重试');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Close on backdrop click
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={handleBackdropClick}>
|
||||
<div className="modal-card" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
<p className="modal-title" id="modal-title">管理 Token</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="password"
|
||||
placeholder="管理 Token"
|
||||
value={token}
|
||||
onChange={e => setToken(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{error && <p style={{ color: 'var(--text)', fontSize: '0.8rem', marginTop: '0.5rem' }}>{error}</p>}
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-ghost" onClick={onClose} disabled={loading}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={loading || !token.trim()}>
|
||||
{loading ? '验证中…' : '确认'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
web/src/index.css
Normal file
63
web/src/index.css
Normal file
@@ -0,0 +1,63 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--ink: #0a0a0a;
|
||||
--text: #171717;
|
||||
--muted: #737373;
|
||||
--faint: #a3a3a3;
|
||||
--border: #d4d4d4;
|
||||
--line: #e5e5e5;
|
||||
--bg: #fafafa;
|
||||
--surface: #ffffff;
|
||||
--hover: #f5f5f5;
|
||||
--radius: 2px;
|
||||
--doc-max: 1240px;
|
||||
--content-max: 36rem;
|
||||
--font: 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
a:hover { color: var(--ink); }
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
border: none;
|
||||
outline: none;
|
||||
border-radius: var(--radius);
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
font-family: inherit;
|
||||
font-size: 0.925rem;
|
||||
outline: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.45rem 0.6rem;
|
||||
width: 100%;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
transition: border-color 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus {
|
||||
border-color: var(--text);
|
||||
box-shadow: 0 0 0 1px var(--text);
|
||||
}
|
||||
|
||||
::placeholder { color: var(--faint); }
|
||||
26
web/src/ipRuleHelpers.ts
Normal file
26
web/src/ipRuleHelpers.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { IpRule } from './api';
|
||||
|
||||
const MAX_IPS = 200;
|
||||
|
||||
/** 从表单生成 IpRule,未选模式或内容为空时返回 null */
|
||||
export function buildIpRule(mode: '' | 'allow' | 'block', text: string): IpRule | null {
|
||||
if (mode !== 'allow' && mode !== 'block') return null;
|
||||
const ips = text
|
||||
.split(/[\n,;,]+/u)
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, MAX_IPS);
|
||||
if (ips.length === 0) return null;
|
||||
return { mode, ips };
|
||||
}
|
||||
|
||||
export function parseIpRuleText(json: string | null | undefined): { mode: '' | 'allow' | 'block'; text: string } {
|
||||
if (!json) return { mode: '', text: '' };
|
||||
try {
|
||||
const r = JSON.parse(json) as IpRule;
|
||||
if (r.mode !== 'allow' && r.mode !== 'block') return { mode: '', text: '' };
|
||||
return { mode: r.mode, text: (r.ips ?? []).join('\n') };
|
||||
} catch {
|
||||
return { mode: '', text: '' };
|
||||
}
|
||||
}
|
||||
13
web/src/main.tsx
Normal file
13
web/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
registerSW({ immediate: true });
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
11
web/src/pageView.ts
Normal file
11
web/src/pageView.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { recordPageView } from './api';
|
||||
|
||||
let succeeded = false;
|
||||
|
||||
/** 全站浏览 +1;成功后才记住。返回是否已成功写入(当次或以往)。 */
|
||||
export async function ensurePageViewOnce(): Promise<boolean> {
|
||||
if (succeeded) return true;
|
||||
const ok = await recordPageView();
|
||||
if (ok) succeeded = true;
|
||||
return ok;
|
||||
}
|
||||
2
web/src/vite-env.d.ts
vendored
Normal file
2
web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
18
web/tsconfig.app.json
Normal file
18
web/tsconfig.app.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
web/tsconfig.app.tsbuildinfo
Normal file
1
web/tsconfig.app.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/api.ts","./src/clientrulehelpers.ts","./src/iprulehelpers.ts","./src/main.tsx","./src/pageview.ts","./src/vite-env.d.ts","./src/components/adminpanel.tsx","./src/components/createform.tsx","./src/components/editdrawer.tsx","./src/components/logo.tsx","./src/components/stats.tsx","./src/components/tokenmodal.tsx"],"version":"5.9.3"}
|
||||
7
web/tsconfig.json
Normal file
7
web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
11
web/tsconfig.node.json
Normal file
11
web/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
1
web/tsconfig.node.tsbuildinfo
Normal file
1
web/tsconfig.node.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./vite.config.ts"],"version":"5.9.3"}
|
||||
73
web/vite.config.ts
Normal file
73
web/vite.config.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
/** logo.png 约 3MB+,不预缓存;由网络按需加载 */
|
||||
includeAssets: ['favicon.ico', 'pwa-192x192.png', 'pwa-512x512.png'],
|
||||
manifest: {
|
||||
name: 'SproutLink',
|
||||
short_name: 'SproutLink',
|
||||
description: '短链与二维码',
|
||||
lang: 'zh-CN',
|
||||
dir: 'ltr',
|
||||
display: 'standalone',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
theme_color: '#fafafa',
|
||||
background_color: '#fafafa',
|
||||
orientation: 'portrait-primary',
|
||||
icons: [
|
||||
{
|
||||
src: 'pwa-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
purpose: 'any',
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any',
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,woff,ttf}'],
|
||||
globIgnores: ['**/logo.png'],
|
||||
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
|
||||
/**
|
||||
* 不设 navigateFallback:同域短链如 /aOzb、POST /aOzb/verify 由 Worker 处理;
|
||||
* 若用 index.html 兜底会抢走导航请求。静态资源仍由预缓存 + 网络回退提供。
|
||||
*/
|
||||
navigateFallback: null,
|
||||
},
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8787',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user