Files

1002 lines
36 KiB
Go

package basemapapi
const adminTemplate = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>底图资源管理平台</title>
<style>
:root {
--bg: #edf3f9;
--bg-strong: #e3ecf6;
--panel: rgba(255,255,255,0.92);
--panel-soft: #f7fafd;
--ink: #102033;
--muted: #5d6c7c;
--line: rgba(16,32,51,0.10);
--primary: #0f5ea8;
--primary-strong: #0a4b86;
--success: #0f7a5c;
--warning: #ad6a07;
--danger: #bf3d32;
--shadow: 0 18px 44px rgba(16,32,51,0.10);
--radius: 20px;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
color: var(--ink);
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
background:
radial-gradient(circle at top left, rgba(15,94,168,0.12), transparent 34%),
radial-gradient(circle at right 12%, rgba(15,122,92,0.10), transparent 28%),
linear-gradient(180deg, #f8fbff 0%, var(--bg) 44%, var(--bg-strong) 100%);
}
a { color: inherit; }
.shell {
max-width: 1520px;
margin: 0 auto;
padding: 28px 18px 64px;
}
.hero,
.panel,
.notice,
.token-box {
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.hero {
position: relative;
overflow: hidden;
padding: 28px;
margin-bottom: 18px;
color: #f6fbff;
background:
radial-gradient(circle at right top, rgba(255,255,255,0.16), transparent 30%),
linear-gradient(135deg, #0d2d4f 0%, #114a7f 54%, #10616b 100%);
}
.hero::after {
content: "";
position: absolute;
right: -110px;
bottom: -130px;
width: 320px;
height: 320px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255,255,255,0.16), transparent 68%);
pointer-events: none;
}
.hero-grid,
.cards,
.section-nav {
position: relative;
z-index: 1;
}
.hero-grid {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.95fr);
gap: 18px;
align-items: start;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
padding: 7px 12px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.18);
background: rgba(255,255,255,0.08);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.hero h1 {
margin: 0 0 10px;
font-size: clamp(30px, 4vw, 46px);
letter-spacing: 0.02em;
}
.hero p {
margin: 0;
max-width: 860px;
line-height: 1.75;
color: rgba(246,251,255,0.82);
font-size: 15px;
}
.hero-note {
margin-top: 18px;
padding: 14px 16px;
border-radius: 16px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(255,255,255,0.08);
color: rgba(246,251,255,0.92);
line-height: 1.7;
font-size: 14px;
}
.hero-meta {
display: grid;
gap: 12px;
padding: 18px;
border-radius: 18px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(5,20,38,0.22);
backdrop-filter: blur(10px);
}
.meta-row {
display: grid;
gap: 4px;
}
.meta-row span {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(246,251,255,0.58);
}
.meta-row strong,
.meta-row div {
font-size: 14px;
color: #f6fbff;
line-height: 1.6;
}
.section-nav {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 20px;
}
.section-nav a {
text-decoration: none;
padding: 10px 14px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.16);
background: rgba(255,255,255,0.08);
color: #f6fbff;
font-size: 13px;
font-weight: 700;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-top: 18px;
}
.card {
padding: 16px 18px;
border-radius: 18px;
border: 1px solid rgba(255,255,255,0.12);
background: rgba(255,255,255,0.10);
backdrop-filter: blur(8px);
}
.card b {
display: block;
margin-bottom: 8px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(246,251,255,0.62);
}
.card span {
display: block;
font-size: 24px;
font-weight: 700;
color: #ffffff;
}
.notice,
.token-box {
margin-bottom: 16px;
padding: 16px 18px;
background: var(--panel);
}
.notice {
border-left: 5px solid var(--primary);
background: #f0f7ff;
color: var(--ink);
}
.notice.danger {
border-left-color: var(--danger);
background: #fff3f1;
}
.token-box {
border-left: 5px solid var(--warning);
background: #fff9ef;
}
.grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 16px;
align-items: start;
}
.span-4 { grid-column: span 4; }
.span-6 { grid-column: span 6; }
.span-8 { grid-column: span 8; }
.span-12 { grid-column: span 12; }
.panel {
padding: 20px;
overflow: hidden;
background: var(--panel);
backdrop-filter: blur(12px);
}
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
margin-bottom: 16px;
}
.panel-head h2 {
margin: 0;
font-size: 21px;
letter-spacing: 0.01em;
}
.panel-head p {
margin: 6px 0 0;
color: var(--muted);
line-height: 1.7;
font-size: 13px;
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 100%;
font-size: 14px;
}
th,
td {
padding: 12px 10px;
border-bottom: 1px solid var(--line);
vertical-align: top;
text-align: left;
}
thead th {
background: var(--panel-soft);
color: var(--muted);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
}
tbody tr:hover {
background: rgba(15,94,168,0.03);
}
.mono,
code,
input,
textarea,
select {
font-family: "Consolas", "SFMono-Regular", monospace;
}
.break-all {
word-break: break-all;
}
.badge {
display: inline-flex;
align-items: center;
padding: 5px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
background: rgba(16,32,51,0.08);
color: var(--ink);
}
.badge.success {
background: rgba(15,122,92,0.12);
color: var(--success);
}
.badge.warning {
background: rgba(173,106,7,0.14);
color: var(--warning);
}
.badge.danger {
background: rgba(191,61,50,0.13);
color: var(--danger);
}
.badge.neutral {
background: rgba(15,94,168,0.11);
color: var(--primary);
}
form.inline { display: inline; }
.stack { display: grid; gap: 12px; }
.row2,
.row3 {
display: grid;
gap: 12px;
}
.row2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.row3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.inline-stack {
display: grid;
gap: 4px;
}
label {
display: block;
margin-bottom: 6px;
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
input,
textarea,
select {
width: 100%;
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px 12px;
background: #ffffff;
color: var(--ink);
font-size: 14px;
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: rgba(15,94,168,0.42);
box-shadow: 0 0 0 4px rgba(15,94,168,0.10);
}
textarea {
min-height: 100px;
resize: vertical;
}
.field-hint {
color: var(--muted);
font-size: 12px;
line-height: 1.6;
}
button {
border: 0;
border-radius: 12px;
padding: 10px 14px;
color: #ffffff;
font-size: 14px;
font-weight: 700;
cursor: pointer;
background: linear-gradient(135deg, var(--primary), var(--primary-strong));
box-shadow: 0 10px 22px rgba(15,94,168,0.22);
}
button.alt {
background: linear-gradient(135deg, #0f7a5c, #0b5e47);
box-shadow: 0 10px 22px rgba(15,122,92,0.20);
}
button.danger {
background: linear-gradient(135deg, var(--danger), #9f2d25);
box-shadow: 0 10px 22px rgba(191,61,50,0.20);
}
.small { font-size: 12px; }
.muted { color: var(--muted); }
.version-row {
margin-bottom: 10px;
padding: 12px;
border-radius: 14px;
border: 1px solid var(--line);
background: var(--panel-soft);
}
.version-row:last-child {
margin-bottom: 0;
}
.token-box .mono {
display: block;
margin: 10px 0 8px;
padding: 12px 14px;
border-radius: 12px;
border: 1px dashed rgba(173,106,7,0.28);
background: #ffffff;
word-break: break-all;
}
.panel-tools {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 14px;
}
.search-input {
width: min(320px, 100%);
background: var(--panel-soft);
}
.ghost-btn {
border: 1px solid rgba(15,94,168,0.16);
background: #ffffff;
color: var(--primary);
box-shadow: none;
padding: 8px 12px;
font-size: 12px;
}
.copy-stack {
display: grid;
gap: 10px;
margin-top: 10px;
}
.copy-inline {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.copy-inline .ghost-btn {
flex: 0 0 auto;
}
.details-block {
border: 1px solid var(--line);
border-radius: 12px;
background: #ffffff;
overflow: hidden;
}
.details-block summary {
cursor: pointer;
list-style: none;
padding: 10px 12px;
background: var(--panel-soft);
color: var(--primary);
font-size: 12px;
font-weight: 700;
}
.details-block summary::-webkit-details-marker {
display: none;
}
.details-block summary::after {
content: "展开";
float: right;
color: var(--muted);
font-weight: 400;
}
.details-block[open] summary::after {
content: "收起";
}
.details-block > div {
padding: 12px;
}
.filter-empty {
background: #fafcff;
}
.copy-feedback {
color: var(--success);
font-size: 12px;
font-weight: 700;
}
@media (max-width: 1120px) {
.hero-grid {
grid-template-columns: 1fr;
}
.span-4,
.span-6,
.span-8,
.span-12 {
grid-column: span 12;
}
}
@media (max-width: 760px) {
.shell {
padding: 18px 12px 42px;
}
.hero {
padding: 22px 18px;
}
.cards {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.row2,
.row3 {
grid-template-columns: 1fr;
}
.panel-tools {
flex-direction: column;
}
.search-input {
width: 100%;
}
table {
min-width: 720px;
}
}
</style>
</head>
<body>
<div class="shell">
<section class="hero">
<div class="hero-grid">
<div>
<div class="eyebrow">Tile Server Admin Console</div>
<h1>底图资源管理平台</h1>
<p>统一管理已接入的底图瓦片、GeoJSON 与 Shapefile 资源,负责目录扫描、元数据治理、访问授权以及下游系统推送记录,满足内部服务化管理场景的日常运维需求。</p>
<div class="hero-note">本系统仅维护资源台账、访问控制与投递链路,不承担切片生成、矢量转换或重建任务。界面中的编码、路径与 URL 保持技术原值,便于与现有业务系统直接对接。</div>
</div>
<aside class="hero-meta">
<div class="meta-row">
<span>服务标识</span>
<strong>{{.SystemInfo.ServiceName}}</strong>
</div>
<div class="meta-row">
<span>运行环境</span>
<strong>{{if .SystemInfo.Env}}{{.SystemInfo.Env}}{{else}}未配置{{end}}</strong>
</div>
<div class="meta-row">
<span>启动时间</span>
<strong>{{fmtTime .SystemInfo.StartedAt}}</strong>
</div>
<div class="meta-row">
<span>接口基址</span>
<div class="mono small break-all">{{.SystemInfo.APIBaseURL}}</div>
</div>
<div class="meta-row">
<span>瓦片出口</span>
<div class="mono small break-all">{{.SystemInfo.TileBaseURL}}</div>
</div>
<div class="meta-row">
<span>矢量出口</span>
<div class="mono small break-all">{{.SystemInfo.VectorBaseURL}}</div>
</div>
<div class="meta-row">
<span>数据库文件</span>
<div class="mono small break-all">{{.SystemInfo.SQLitePath}}</div>
</div>
</aside>
</div>
<nav class="section-nav">
<a href="#scan-sources">扫描源</a>
<a href="#vector-assets">矢量资源</a>
<a href="#targets">目标系统</a>
<a href="#basemaps">底图台账</a>
<a href="#tokens">访问令牌</a>
<a href="#activities">运行记录</a>
</nav>
<div class="cards">
<div class="card"><b>服务版本</b><span>{{.SystemInfo.ServiceVersion}}</span></div>
<div class="card"><b>底图资源数</b><span>{{len .Basemaps}}</span></div>
<div class="card"><b>矢量资源数</b><span>{{len .VectorAssets}}</span></div>
<div class="card"><b>扫描源数</b><span>{{len .ScanSources}}</span></div>
<div class="card"><b>目标系统数</b><span>{{len .TargetSystems}}</span></div>
<div class="card"><b>令牌总数</b><span>{{len .Tokens}}</span></div>
</div>
</section>
{{if .Flash}}<div class="notice {{flashClass .Flash}}">{{.Flash}}</div>{{end}}
{{if .CreatedToken}}<div class="token-box"><b>新建访问令牌</b><div class="mono">{{.CreatedToken}}</div><div class="copy-inline"><button type="button" class="ghost-btn" data-copy="{{.CreatedToken}}">复制令牌</button><span class="copy-feedback" data-copy-feedback hidden>已复制到剪贴板</span></div><div class="small muted">令牌明文仅展示一次,后续系统仅保存哈希前缀,请立即妥善保管。</div></div>{{end}}
<section class="grid">
<article class="panel span-4" id="scan-sources">
<div class="panel-head">
<div>
<h2>底图扫描源</h2>
<p>登记底图目录与清单文件规则,并按需执行增量扫描。</p>
</div>
<span class="badge neutral">{{len .ScanSources}} 个</span>
</div>
<div class="panel-tools">
<input class="search-input" data-table-filter="#scan-source-table" placeholder="检索扫描源编码、名称或路径">
</div>
<form class="stack" method="post" action="/admin/scan-sources">
<div class="row2">
<div><label>扫描源编码</label><input name="code" value="local-basemaps"></div>
<div><label>扫描源名称</label><input name="name" value="本地底图目录"></div>
</div>
<div><label>根目录路径</label><input name="root_path" value="{{.SystemInfo.ScanRoot}}"></div>
<div><label>清单文件名</label><input name="manifest_name" value="manifest.json"></div>
<button type="submit">保存扫描源</button>
</form>
<div class="table-wrap">
<table id="scan-source-table">
<thead><tr><th>扫描源</th><th>路径与规则</th><th>状态</th><th>操作</th></tr></thead>
<tbody>
{{range .ScanSources}}
<tr data-filterable="true">
<td><b>{{.Code}}</b><div class="small muted">{{.Name}}</div></td>
<td class="inline-stack"><div class="mono small break-all">{{.RootPath}}</div><div class="small muted">清单文件:{{.ManifestName}}</div></td>
<td><span class="badge {{enabledTone .Enabled}}">{{enabledText .Enabled}}</span></td>
<td><form class="inline" method="post" action="/admin/scan-sources/{{.Code}}/run"><button type="submit">立即扫描</button></form></td>
</tr>
{{else}}
<tr class="empty-state"><td colspan="4" class="muted">暂无已登记的底图扫描源。</td></tr>
{{end}}
<tr class="filter-empty" hidden><td colspan="4" class="muted">未找到符合条件的扫描源记录。</td></tr>
</tbody>
</table>
</div>
</article>
<article class="panel span-4" id="vector-assets">
<div class="panel-head">
<div>
<h2>矢量资源</h2>
<p>递归扫描 GeoJSON、Shapefile 及压缩包,并维护可下载资源清单。</p>
</div>
<span class="badge neutral">{{len .VectorAssets}} 项</span>
</div>
<div class="panel-tools">
<input class="search-input" data-table-filter="#vector-assets-table" placeholder="检索编码、名称、路径、格式或状态">
</div>
<div class="field-hint" style="margin-bottom:12px">扫描根目录:<span class="mono break-all">{{.SystemInfo.VectorScanRoot}}</span></div>
<form method="post" action="/admin/vectors/rescan" style="margin-bottom:14px">
<button type="submit">重新扫描矢量目录</button>
</form>
<div class="table-wrap">
<table id="vector-assets-table">
<thead><tr><th>资源</th><th>格式与状态</th><th>大小</th><th>下载地址</th></tr></thead>
<tbody>
{{range .VectorAssets}}
<tr data-filterable="true">
<td>
<b>{{.Code}}</b>
<div class="small muted">{{.Name}}</div>
<div class="mono small break-all">{{vectorPath .}}</div>
</td>
<td>
<span class="badge neutral">{{.Format}}</span>
<div class="small muted" style="margin-top:6px">状态:{{statusText .Status}}</div>
<div class="small muted">{{.FileName}}</div>
</td>
<td class="small">{{formatBytes .FileSize}}</td>
<td>
<details class="details-block">
<summary>查看下载地址</summary>
<div class="copy-stack">
<div class="mono small break-all">{{.DownloadURL}}</div>
<div class="copy-inline">
<button type="button" class="ghost-btn" data-copy="{{.DownloadURL}}">复制地址</button>
<span class="copy-feedback" data-copy-feedback hidden>已复制到剪贴板</span>
</div>
</div>
</details>
</td>
</tr>
{{else}}
<tr class="empty-state"><td colspan="4" class="muted">当前未扫描到矢量资源文件。</td></tr>
{{end}}
<tr class="filter-empty" hidden><td colspan="4" class="muted">未找到符合条件的矢量资源。</td></tr>
</tbody>
</table>
</div>
</article>
<article class="panel span-4" id="targets">
<div class="panel-head">
<div>
<h2>目标系统</h2>
<p>维护下游回调地址与请求头配置,用于资源推送对接。</p>
</div>
<span class="badge neutral">{{len .TargetSystems}} 个</span>
</div>
<div class="panel-tools">
<input class="search-input" data-table-filter="#targets-table" placeholder="检索目标系统编码、名称、回调地址">
</div>
<form class="stack" method="post" action="/admin/targets">
<div class="row2">
<div><label>系统编码</label><input name="code" placeholder="portal-a"></div>
<div><label>系统名称</label><input name="name" placeholder="业务门户 A"></div>
</div>
<div class="row2">
<div><label>回调地址</label><input name="callback_url" placeholder="http://target/api/catalog"></div>
<div><label>请求方法</label><input name="callback_method" value="POST"></div>
</div>
<div><label>附加请求头</label><textarea name="headers" placeholder="X-App-Key: abc123"></textarea></div>
<button type="submit" class="alt">保存目标系统</button>
</form>
<div class="table-wrap">
<table id="targets-table">
<thead><tr><th>目标系统</th><th>回调配置</th><th>状态</th></tr></thead>
<tbody>
{{range .TargetSystems}}
<tr data-filterable="true">
<td><b>{{.Code}}</b><div class="small muted">{{.Name}}</div></td>
<td>
<div class="small muted">{{.CallbackMethod}}</div>
<details class="details-block" style="margin-top:8px">
<summary>查看回调地址</summary>
<div class="copy-stack">
<div class="mono small break-all">{{.CallbackURL}}</div>
<div class="copy-inline">
<button type="button" class="ghost-btn" data-copy="{{.CallbackURL}}">复制地址</button>
<span class="copy-feedback" data-copy-feedback hidden>已复制到剪贴板</span>
</div>
</div>
</details>
</td>
<td><span class="badge {{enabledTone .Enabled}}">{{enabledText .Enabled}}</span></td>
</tr>
{{else}}
<tr class="empty-state"><td colspan="3" class="muted">暂无下游目标系统配置。</td></tr>
{{end}}
<tr class="filter-empty" hidden><td colspan="3" class="muted">未找到符合条件的目标系统。</td></tr>
</tbody>
</table>
</div>
</article>
<article class="panel span-8" id="basemaps">
<div class="panel-head">
<div>
<h2>底图台账</h2>
<p>查看底图资源编码、版本、缩放级别、默认版本与对外访问模板。</p>
</div>
<span class="badge neutral">{{len .Basemaps}} 项</span>
</div>
<div class="panel-tools">
<input class="search-input" data-table-filter="#basemaps-table" placeholder="检索底图编码、名称、描述、版本或 URL">
</div>
<div class="table-wrap">
<table id="basemaps-table">
<thead><tr><th>底图资源</th><th>版本清单</th><th>默认版本</th><th>当前访问地址</th></tr></thead>
<tbody>
{{range .Basemaps}}
<tr data-filterable="true">
<td>
<b>{{.Code}}</b>
<div class="small muted">{{.Name}}</div>
<div class="small muted">{{if .Description}}{{.Description}}{{else}}未填写资源说明{{end}}</div>
</td>
<td>
{{range .Versions}}
<div class="version-row">
<span class="badge {{statusTone .Status}}">{{versionLabel .}}</span>
<details class="details-block" style="margin-top:8px">
<summary>查看版本访问模板</summary>
<div class="copy-stack">
<div class="mono small break-all">{{.URLTemplate}}</div>
<div class="copy-inline">
<button type="button" class="ghost-btn" data-copy="{{.URLTemplate}}">复制地址</button>
<span class="copy-feedback" data-copy-feedback hidden>已复制到剪贴板</span>
</div>
</div>
</details>
<div class="small muted" style="margin-top:6px">级别 {{.MinZoom}}-{{.MaxZoom}} · 格式 {{.TileFormat}} · 方案 {{.TileScheme}} · 状态 {{statusText .Status}}</div>
{{if .Attribution}}<div class="small muted" style="margin-top:4px">署名:{{.Attribution}}</div>{{end}}
</div>
{{end}}
</td>
<td>
<div class="mono">{{defaultVersion .Default}}</div>
<form method="post" action="/admin/basemaps/{{.Code}}/default" class="stack" style="margin-top:10px">
<select name="version">
{{range .Versions}}<option value="{{.Version}}">{{.Version}}</option>{{end}}
</select>
<button type="submit" class="alt">设为默认版本</button>
</form>
</td>
<td>
{{if .Default}}
<details class="details-block">
<summary>查看默认访问地址</summary>
<div class="copy-stack">
<div class="mono small break-all">{{.Default.URLTemplate}}</div>
<div class="copy-inline">
<button type="button" class="ghost-btn" data-copy="{{.Default.URLTemplate}}">复制地址</button>
<span class="copy-feedback" data-copy-feedback hidden>已复制到剪贴板</span>
</div>
</div>
</details>
{{else}}-{{end}}
</td>
</tr>
{{else}}
<tr class="empty-state"><td colspan="4" class="muted">暂无底图资源台账。</td></tr>
{{end}}
<tr class="filter-empty" hidden><td colspan="4" class="muted">未找到符合条件的底图记录。</td></tr>
</tbody>
</table>
</div>
</article>
<article class="panel span-4" id="tokens">
<div class="panel-head">
<div>
<h2>访问令牌</h2>
<p>为下游系统或前端应用分配底图与矢量资源访问权限。</p>
</div>
<span class="badge neutral">{{len .Tokens}} 枚</span>
</div>
<div class="panel-tools">
<input class="search-input" data-table-filter="#tokens-table" placeholder="检索令牌名称、前缀、授权范围或状态">
</div>
<form class="stack" method="post" action="/admin/tokens">
<div class="row2">
<div><label>令牌名称</label><input name="name" placeholder="viewer-a"></div>
<div><label>有效时长(分钟)</label><input name="expires_at" placeholder="留空表示长期有效"></div>
</div>
<div>
<label>底图编码</label>
<textarea name="basemap_codes" placeholder="gaode-image, google-image"></textarea>
<div class="field-hint">多个值可使用逗号、空格或换行分隔。</div>
</div>
<div>
<label>矢量编码或原始路径</label>
<textarea name="vector_codes" placeholder="全国行政区.geojson, landuse/parcel.shp"></textarea>
<div class="field-hint">支持填写内部资源编码,也支持填写扫描目录下的相对路径。</div>
</div>
<button type="submit">创建访问令牌</button>
</form>
<div class="table-wrap">
<table id="tokens-table">
<thead><tr><th>令牌</th><th>授权范围</th><th>状态</th><th>操作</th></tr></thead>
<tbody>
{{range .Tokens}}
<tr data-filterable="true">
<td>
<b>{{.Name}}</b>
<div class="mono small">{{.TokenPrefix}}</div>
<div class="small muted">创建时间:{{fmtTime .CreatedAt}}</div>
</td>
<td class="small">
<div><b>底图:</b>{{tokenGrants .Grants}}</div>
<div style="margin-top:6px"><b>矢量:</b>{{vectorTokenGrants .VectorGrants}}</div>
</td>
<td>
<span class="badge {{statusTone .Status}}">{{statusText .Status}}</span>
<div class="small muted" style="margin-top:6px">到期时间:{{fmtTimePtr .ExpiresAt}}</div>
<div class="small muted">最近使用:{{fmtTimePtr .LastUsedAt}}</div>
</td>
<td>
<form class="inline" method="post" action="/admin/tokens/{{.ID}}/disable">
<button type="submit" class="danger">停用令牌</button>
</form>
</td>
</tr>
{{else}}
<tr class="empty-state"><td colspan="4" class="muted">暂无已发放的访问令牌。</td></tr>
{{end}}
<tr class="filter-empty" hidden><td colspan="4" class="muted">未找到符合条件的令牌记录。</td></tr>
</tbody>
</table>
</div>
</article>
<article class="panel span-6">
<div class="panel-head">
<div>
<h2>底图推送</h2>
<p>向下游系统发送底图元数据与瓦片 URL 模板,不直接传输瓦片内容。</p>
</div>
</div>
<form class="stack" method="post" action="/admin/pushes">
<div><label>目标系统编码</label><input name="target_code" placeholder="portal-a"></div>
<div><label>底图编码</label><input name="basemap_code" placeholder="gaode-image"></div>
<div><label>版本</label><input name="version" value="current"></div>
<button type="submit">执行底图推送</button>
</form>
</article>
<article class="panel span-6">
<div class="panel-head">
<div>
<h2>矢量推送</h2>
<p>向下游系统发送矢量元数据与受控下载地址,Shapefile 将以压缩包方式提供。</p>
</div>
</div>
<form class="stack" method="post" action="/admin/vector-pushes">
<div><label>目标系统编码</label><input name="target_code" placeholder="portal-a"></div>
<div><label>矢量资源编码</label><input name="vector_code" placeholder="admin-boundary"></div>
<button type="submit" class="alt">执行矢量推送</button>
</form>
</article>
<article class="panel span-12" id="activities">
<div class="panel-head">
<div>
<h2>近期运行记录</h2>
<p>汇总展示扫描任务、底图推送与矢量推送的最新执行情况,便于审计与排障。</p>
</div>
</div>
<div class="panel-tools">
<input class="search-input" data-table-filter="#activities-table" placeholder="检索记录类型、对象编码或状态">
</div>
<div class="table-wrap">
<table id="activities-table">
<thead><tr><th>记录类型</th><th>对象</th><th>状态</th><th>时间</th></tr></thead>
<tbody>
{{range .ScanRuns}}
<tr data-filterable="true">
<td>底图扫描</td>
<td><b>{{.SourceCode}}</b><div class="small muted">新增 {{.AddedCount}} · 更新 {{.UpdatedCount}} · 删除 {{.RemovedCount}}</div></td>
<td><span class="badge {{statusTone .Status}}">{{statusText .Status}}</span></td>
<td class="small">{{fmtTime .StartedAt}}</td>
</tr>
{{end}}
{{range .PushRecords}}
<tr data-filterable="true">
<td>底图推送</td>
<td><b>{{.TargetSystemCode}}</b><div class="small muted">{{.BasemapCode}} / {{.BasemapVersion}}</div></td>
<td><span class="badge {{statusTone .Status}}">{{statusText .Status}}</span></td>
<td class="small">{{fmtTime .PushedAt}}</td>
</tr>
{{end}}
{{range .VectorPushRecords}}
<tr data-filterable="true">
<td>矢量推送</td>
<td><b>{{.TargetSystemCode}}</b><div class="small muted">{{.VectorCode}}</div></td>
<td><span class="badge {{statusTone .Status}}">{{statusText .Status}}</span></td>
<td class="small">{{fmtTime .PushedAt}}</td>
</tr>
{{end}}
{{if and (eq (len .ScanRuns) 0) (eq (len .PushRecords) 0) (eq (len .VectorPushRecords) 0)}}
<tr class="empty-state"><td colspan="4" class="muted">暂无运行记录。</td></tr>
{{end}}
<tr class="filter-empty" hidden><td colspan="4" class="muted">未找到符合条件的运行记录。</td></tr>
</tbody>
</table>
</div>
</article>
</section>
</div>
<script>
(function () {
function applyFilter(input) {
var tableSelector = input.getAttribute("data-table-filter");
if (!tableSelector) return;
var table = document.querySelector(tableSelector);
if (!table) return;
var rows = Array.prototype.slice.call(table.querySelectorAll("tbody tr[data-filterable='true']"));
var emptyRow = table.querySelector("tbody .filter-empty");
var keyword = input.value.trim().toLowerCase();
var visibleCount = 0;
rows.forEach(function (row) {
var haystack = (row.getAttribute("data-search") || row.textContent || "").toLowerCase();
var show = keyword === "" || haystack.indexOf(keyword) !== -1;
row.hidden = !show;
if (show) visibleCount += 1;
});
if (emptyRow) {
emptyRow.hidden = !(rows.length > 0 && keyword !== "" && visibleCount === 0);
}
}
Array.prototype.slice.call(document.querySelectorAll("[data-table-filter]")).forEach(function (input) {
input.addEventListener("input", function () {
applyFilter(input);
});
applyFilter(input);
});
function fallbackCopy(text) {
var area = document.createElement("textarea");
area.value = text;
area.setAttribute("readonly", "readonly");
area.style.position = "absolute";
area.style.left = "-9999px";
document.body.appendChild(area);
area.select();
document.execCommand("copy");
document.body.removeChild(area);
}
Array.prototype.slice.call(document.querySelectorAll("[data-copy]")).forEach(function (button) {
var originalText = button.textContent;
button.addEventListener("click", function () {
var text = button.getAttribute("data-copy") || "";
var feedback = button.parentElement ? button.parentElement.querySelector("[data-copy-feedback]") : null;
Promise.resolve()
.then(function () {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
fallbackCopy(text);
})
.then(function () {
button.textContent = "已复制";
if (feedback) feedback.hidden = false;
window.setTimeout(function () {
button.textContent = originalText;
if (feedback) feedback.hidden = true;
}, 1400);
})
.catch(function () {
button.textContent = "复制失败";
window.setTimeout(function () {
button.textContent = originalText;
}, 1400);
});
});
});
})();
</script>
</body>
</html>`