feat: sync current progress (P0 hardening + P1 observability + deploy docs/systemd)

This commit is contained in:
OpenClaw Agent
2026-02-28 23:51:23 +08:00
commit d17296d794
96 changed files with 6358 additions and 0 deletions

72
web/legacy/index.html Normal file
View File

@@ -0,0 +1,72 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Asset Tracker</title>
<link rel="stylesheet" href="/app/static/style.css" />
</head>
<body>
<div class="container">
<h1>Asset Tracker</h1>
<section id="login-section" class="card">
<h2>登录</h2>
<div class="row">
<input id="username" placeholder="用户名" value="admin" />
<input id="password" type="password" placeholder="密码" value="admin123" />
<button id="login-btn">登录</button>
</div>
<p class="hint">默认账号admin / admin123</p>
</section>
<section id="app-section" class="hidden">
<div class="card">
<div class="row between">
<h2>仪表盘</h2>
<button id="refresh-dashboard">刷新</button>
</div>
<div id="dashboard" class="grid"></div>
</div>
<div class="card">
<h2>分类管理</h2>
<div class="row">
<input id="cat-name" placeholder="分类名,如:服务器" />
<select id="cat-type">
<option value="digital">digital</option>
<option value="real">real</option>
</select>
<button id="add-cat">新增分类</button>
</div>
<ul id="category-list"></ul>
</div>
<div class="card">
<h2>资产管理</h2>
<div class="row wrap">
<input id="asset-name" placeholder="资产名称" />
<select id="asset-category"></select>
<input id="asset-quantity" type="number" step="0.01" placeholder="数量" value="1" />
<input id="asset-price" type="number" step="0.01" placeholder="单价" value="100" />
<input id="asset-currency" placeholder="币种,如 USD" value="USD" />
<input id="asset-expiry" type="datetime-local" />
<button id="add-asset">新增资产</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr><th>ID</th><th>名称</th><th>分类</th><th>金额</th><th>状态</th><th>到期</th><th>操作</th></tr>
</thead>
<tbody id="asset-tbody"></tbody>
</table>
</div>
<div id="asset-cards" class="asset-cards"></div>
</div>
</section>
<pre id="msg"></pre>
</div>
<script src="/app/static/app.js"></script>
</body>
</html>

38
web/legacy/records.html Normal file
View File

@@ -0,0 +1,38 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Asset Records</title>
<link rel="stylesheet" href="/app/static/style.css" />
<style>
.top{display:flex;justify-content:space-between;align-items:center;gap:8px}
.muted{color:#6b7280;font-size:13px}
.record-card{border:1px solid #e5e7eb;border-radius:10px;padding:10px;margin-bottom:8px;background:#fff}
.record-card .line{display:flex;justify-content:space-between;gap:10px;font-size:13px;margin:4px 0}
.empty{padding:18px;border:1px dashed #cbd5e1;border-radius:10px;background:#f8fafc;color:#64748b}
</style>
</head>
<body>
<div class="container">
<div class="card top">
<div>
<h1 style="margin:0">已记录内容</h1>
<div class="muted">只读展示页(管理入口:/app</div>
</div>
<button id="refresh-btn">刷新</button>
</div>
<div class="card">
<h2>汇总</h2>
<div id="summary" class="grid"></div>
</div>
<div class="card">
<h2>记录列表</h2>
<div id="records"></div>
</div>
</div>
<script src="/app/static/public.js"></script>
</body>
</html>

134
web/legacy/static/app.js Normal file
View File

@@ -0,0 +1,134 @@
const $ = (id) => document.getElementById(id);
const msg = (t) => $("msg").textContent = typeof t === 'string' ? t : JSON.stringify(t, null, 2);
const tokenKey = 'asset_tracker_token';
let categories = [];
function token(){ return localStorage.getItem(tokenKey) || ''; }
async function api(path, method='GET', body){
const headers = {};
if(token()) headers['Authorization'] = `Bearer ${token()}`;
if(body) headers['Content-Type'] = 'application/json';
const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined });
const data = await res.json().catch(() => ({}));
if(!res.ok) throw new Error(data.error || `${res.status}`);
return data;
}
async function doLogin(){
try{
const data = await api('/api/v1/auth/login', 'POST', {
username: $('username').value.trim(),
password: $('password').value
});
localStorage.setItem(tokenKey, data.access_token);
$('login-section').classList.add('hidden');
$('app-section').classList.remove('hidden');
await reloadAll();
msg('登录成功');
}catch(e){ msg(`登录失败: ${e.message}`); }
}
async function loadCategories(){
const data = await api('/api/v1/categories');
categories = data.data || [];
$('category-list').innerHTML = categories.map(c => `<li>#${c.id} ${c.name} (${c.type})</li>`).join('') || '<li>暂无分类</li>';
$('asset-category').innerHTML = categories.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
}
async function addCategory(){
try{
await api('/api/v1/categories', 'POST', {
name: $('cat-name').value.trim(),
type: $('cat-type').value
});
$('cat-name').value='';
await loadCategories();
msg('分类创建成功');
}catch(e){ msg(`分类创建失败: ${e.message}`); }
}
function toRFC3339(localVal){
if(!localVal) return '';
const d = new Date(localVal);
return d.toISOString();
}
async function addAsset(){
try{
await api('/api/v1/assets', 'POST', {
name: $('asset-name').value.trim(),
category_id: Number($('asset-category').value),
quantity: Number($('asset-quantity').value || 0),
unit_price: Number($('asset-price').value || 0),
currency: $('asset-currency').value.trim().toUpperCase(),
expiry_date: toRFC3339($('asset-expiry').value)
});
$('asset-name').value='';
await loadAssets();
await loadDashboard();
msg('资产创建成功');
}catch(e){ msg(`资产创建失败: ${e.message}`); }
}
async function loadAssets(){
const data = await api('/api/v1/assets?page=1&page_size=100');
const rows = data.data || [];
const map = new Map(categories.map(c => [c.id, c.name]));
$('asset-tbody').innerHTML = rows.map(a => `<tr>
<td>${a.id}</td>
<td>${a.name}</td>
<td>${map.get(a.category_id) || a.category_id}</td>
<td>${a.total_value} ${a.currency}</td>
<td>${a.status}</td>
<td>${a.expiry_date || '-'}</td>
<td><button onclick="delAsset(${a.id})">删除</button></td>
</tr>`).join('') || '<tr><td colspan="7">暂无资产</td></tr>';
$('asset-cards').innerHTML = rows.map(a => `<div class="asset-card">
<div class="line"><b>#${a.id} ${a.name}</b><span>${a.status}</span></div>
<div class="line"><span>分类</span><span>${map.get(a.category_id) || a.category_id}</span></div>
<div class="line"><span>金额</span><span>${a.total_value} ${a.currency}</span></div>
<div class="line"><span>到期</span><span>${a.expiry_date || '-'}</span></div>
<div class="line"><button onclick="delAsset(${a.id})">删除</button></div>
</div>`).join('') || '<div class="asset-card">暂无资产</div>';
}
async function delAsset(id){
try{
await api(`/api/v1/assets/${id}`, 'DELETE');
await loadAssets();
await loadDashboard();
msg(`已删除资产 #${id}`);
}catch(e){ msg(`删除失败: ${e.message}`); }
}
window.delAsset = delAsset;
async function loadDashboard(){
const d = await api('/api/v1/dashboard/summary');
const byCat = (d.by_category || []).map(x => `${x.category_name}: ${x.total_value}`).join('<br>') || '无';
$('dashboard').innerHTML = `
<div class="kpi"><b>总资产</b><div>${d.total_assets_value}</div></div>
<div class="kpi"><b>分类占比</b><div>${byCat}</div></div>
<div class="kpi"><b>30天到期</b><div>${(d.expiring_in_30_days || []).length} 条</div></div>
`;
}
async function reloadAll(){
await loadCategories();
await loadAssets();
await loadDashboard();
}
$('login-btn').addEventListener('click', doLogin);
$('add-cat').addEventListener('click', addCategory);
$('add-asset').addEventListener('click', addAsset);
$('refresh-dashboard').addEventListener('click', loadDashboard);
(async function init(){
if(token()){
$('login-section').classList.add('hidden');
$('app-section').classList.remove('hidden');
try{ await reloadAll(); }catch(e){ msg(`自动加载失败: ${e.message}`); }
}
})();

View File

@@ -0,0 +1,33 @@
async function loadPublicRecords(){
const res = await fetch('/public/records');
const data = await res.json();
const s = data.summary || {};
const by = s.by_category || {};
document.getElementById('summary').innerHTML = `
<div class="kpi"><b>用户ID</b><div>${s.user_id ?? '-'}</div></div>
<div class="kpi"><b>活跃资产数</b><div>${s.active_asset_count ?? 0}</div></div>
<div class="kpi"><b>总资产值</b><div>${s.total_assets_value ?? 0}</div></div>
<div class="kpi" style="grid-column:1/-1"><b>分类汇总</b><div>${Object.keys(by).length ? Object.entries(by).map(([k,v])=>`${k||'未分类'}: ${v}`).join('<br>') : '暂无'}</div></div>
`;
const rows = data.records || [];
const box = document.getElementById('records');
if(!rows.length){
box.innerHTML = '<div class="empty">当前 0 条记录。可前往 <a href="/app">/app</a> 添加资产后回来查看。</div>';
return;
}
box.innerHTML = rows.map(r => `
<div class="record-card">
<div class="line"><b>#${r.id} ${r.name || ''}</b><span>${r.status || '-'}</span></div>
<div class="line"><span>分类</span><span>${r.category_name || '-'}</span></div>
<div class="line"><span>金额</span><span>${r.total_value ?? 0} ${r.currency || ''}</span></div>
<div class="line"><span>到期</span><span>${r.expiry_date || '-'}</span></div>
<div class="line"><span>更新时间</span><span>${r.updated_at || '-'}</span></div>
</div>
`).join('');
}
document.getElementById('refresh-btn').addEventListener('click', loadPublicRecords);
loadPublicRecords();

View File

@@ -0,0 +1,33 @@
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#f5f7fb;margin:0;color:#1f2937}
.container{max-width:1100px;margin:20px auto;padding:0 12px}
h1{margin:0 0 12px;font-size:26px}
h2{margin:0 0 10px;font-size:18px}
.card{background:#fff;border-radius:12px;padding:14px;margin-bottom:12px;box-shadow:0 1px 6px rgba(0,0,0,.06)}
.row{display:flex;gap:8px;align-items:center}
.row.wrap{flex-wrap:wrap}
.row.between{justify-content:space-between}
input,select,button{padding:10px;border:1px solid #d0d7e2;border-radius:10px;font-size:14px}
button{background:#2b7cff;color:#fff;border:none;cursor:pointer}
button:hover{opacity:.92}
.hidden{display:none}
.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
.kpi{background:#f0f5ff;padding:10px;border-radius:10px;line-height:1.5}
.table-wrap{overflow:auto;border:1px solid #edf1f7;border-radius:10px}
table{width:100%;border-collapse:collapse;min-width:760px;background:#fff}
th,td{border-bottom:1px solid #eceff5;padding:8px;text-align:left;white-space:nowrap}
.asset-cards{display:none}
.asset-card{border:1px solid #edf1f7;border-radius:10px;padding:10px;margin-bottom:8px;background:#fff}
.asset-card .line{display:flex;justify-content:space-between;margin:4px 0;font-size:13px}
pre{white-space:pre-wrap;background:#0d1117;color:#9ecbff;padding:10px;border-radius:8px;min-height:28px}
.hint{color:#666;font-size:12px}
@media (max-width: 768px){
.container{padding:0 10px;margin:12px auto}
h1{font-size:22px}
.grid{grid-template-columns:1fr}
.row{flex-wrap:wrap}
.row > *{flex:1 1 calc(50% - 8px);min-width:120px}
.row > button{flex:1 1 100%}
.table-wrap{display:none}
.asset-cards{display:block}
}