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
@@ -0,0 +1 @@
import{d as v,i as $,z as x,u as w,r as u,o as i,l as d,w as e,a as s,m as r,f as n}from"./index-CnyV6Gd0.js";const A=v({__name:"AppNav",setup(b){const f=$(),l=x(),m=w();function p(y){f.push(y)}function k(){m.logout(),f.push("/login")}return(y,t)=>{const o=u("el-button"),C=u("el-space"),g=u("el-card");return i(),d(g,{class:"card",shadow:"never",style:{"margin-bottom":"12px"}},{default:e(()=>[s(C,{wrap:""},{default:e(()=>[s(o,{type:r(l).path==="/"?"primary":"default",onClick:t[0]||(t[0]=a=>p("/"))},{default:e(()=>[...t[6]||(t[6]=[n("公开记录",-1)])]),_:1},8,["type"]),s(o,{type:r(l).path==="/app"?"primary":"default",onClick:t[1]||(t[1]=a=>p("/app"))},{default:e(()=>[...t[7]||(t[7]=[n("仪表盘",-1)])]),_:1},8,["type"]),s(o,{type:r(l).path==="/assets"?"primary":"default",onClick:t[2]||(t[2]=a=>p("/assets"))},{default:e(()=>[...t[8]||(t[8]=[n("资产",-1)])]),_:1},8,["type"]),s(o,{type:r(l).path==="/categories"?"primary":"default",onClick:t[3]||(t[3]=a=>p("/categories"))},{default:e(()=>[...t[9]||(t[9]=[n("分类",-1)])]),_:1},8,["type"]),s(o,{type:r(l).path==="/reminders"?"primary":"default",onClick:t[4]||(t[4]=a=>p("/reminders"))},{default:e(()=>[...t[10]||(t[10]=[n("提醒",-1)])]),_:1},8,["type"]),r(m).token?(i(),d(o,{key:0,type:"danger",plain:"",onClick:k},{default:e(()=>[...t[11]||(t[11]=[n("退出",-1)])]),_:1})):(i(),d(o,{key:1,onClick:t[5]||(t[5]=a=>p("/login"))},{default:e(()=>[...t[12]||(t[12]=[n("登录",-1)])]),_:1}))]),_:1})]),_:1})}}});export{A as _};
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{_ as k}from"./AppNav.vue_vue_type_script_setup_true_lang-RQbRtBk5.js";import{_ as E}from"./PageState.vue_vue_type_script_setup_true_lang-DEQuT0jG.js";import{l as M,c as B}from"./categories-Bz8nm2Da.js";import{g as v}from"./errors-E9D9vVes.js";import{d as N,j as U,c as $,a as e,w as t,r as a,h as p,f as b,g as j,E as _,o as D}from"./index-CnyV6Gd0.js";const I={class:"page"},H=N({__name:"Categories",setup(T){const c=p([]),l=j({name:"",type:"digital"}),r=p(!1),s=p("");async function d(){r.value=!0,s.value="";try{const n=await M();c.value=n.data||[]}catch(n){s.value=v(n,"分类数据加载失败")}finally{r.value=!1}}async function w(){if(!l.name.trim())return _.error("分类名必填");try{await B({name:l.name.trim(),type:l.type}),l.name="",await d(),_.success("新增成功")}catch(n){_.error(v(n,"新增失败"))}}return U(d),(n,o)=>{const x=a("el-input"),i=a("el-col"),f=a("el-option"),C=a("el-select"),y=a("el-button"),V=a("el-row"),g=a("el-card"),m=a("el-table-column"),h=a("el-table");return D(),$("div",I,[e(k),e(g,{class:"card"},{default:t(()=>[e(V,{gutter:12},{default:t(()=>[e(i,{xs:24,sm:10},{default:t(()=>[e(x,{modelValue:l.name,"onUpdate:modelValue":o[0]||(o[0]=u=>l.name=u),placeholder:"分类名称"},null,8,["modelValue"])]),_:1}),e(i,{xs:24,sm:8},{default:t(()=>[e(C,{modelValue:l.type,"onUpdate:modelValue":o[1]||(o[1]=u=>l.type=u),style:{width:"100%"}},{default:t(()=>[e(f,{label:"digital",value:"digital"}),e(f,{label:"real",value:"real"})]),_:1},8,["modelValue"])]),_:1}),e(i,{xs:24,sm:6},{default:t(()=>[e(y,{type:"primary",onClick:w},{default:t(()=>[...o[2]||(o[2]=[b("新增分类",-1)])]),_:1})]),_:1})]),_:1})]),_:1}),e(g,{class:"card"},{default:t(()=>[e(E,{loading:r.value,error:s.value,empty:!r.value&&!s.value&&c.value.length===0,"empty-text":"当前没有分类记录"},{retry:t(()=>[e(y,{onClick:d},{default:t(()=>[...o[3]||(o[3]=[b("重试",-1)])]),_:1})]),default:t(()=>[e(h,{data:c.value},{default:t(()=>[e(m,{prop:"id",label:"ID",width:"80"}),e(m,{prop:"name",label:"分类名","min-width":"140"}),e(m,{prop:"type",label:"类型",width:"120"}),e(m,{prop:"created_at",label:"创建时间","min-width":"180"})]),_:1},8,["data"])]),_:1},8,["loading","error","empty"])]),_:1})])}}});export{H as default};
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{d as g,u as V,c as b,a as o,w as a,r as s,b as x,e as y,f as v,g as h,h as C,E as p,i as E,o as k}from"./index-CnyV6Gd0.js";import{g as B}from"./errors-E9D9vVes.js";const M={class:"page",style:{"max-width":"420px","margin-top":"80px"}},A=g({__name:"Login",setup(N){const t=h({username:"",password:""}),i=V(),c=E(),l=C(!1);async function u(){l.value=!0;try{await i.login(t.username,t.password),p.success("登录成功"),c.push("/app")}catch(d){p.error(B(d,"登录失败,请检查账号密码"))}finally{l.value=!1}}return(d,e)=>{const m=s("el-input"),n=s("el-form-item"),_=s("el-button"),f=s("el-form"),w=s("el-card");return k(),b("div",M,[o(w,{class:"card"},{default:a(()=>[e[3]||(e[3]=x("h2",null,"登录",-1)),o(f,{onSubmit:y(u,["prevent"]),"label-width":"80px"},{default:a(()=>[o(n,{label:"用户名"},{default:a(()=>[o(m,{modelValue:t.username,"onUpdate:modelValue":e[0]||(e[0]=r=>t.username=r)},null,8,["modelValue"])]),_:1}),o(n,{label:"密码"},{default:a(()=>[o(m,{modelValue:t.password,"onUpdate:modelValue":e[1]||(e[1]=r=>t.password=r),"show-password":"",type:"password"},null,8,["modelValue"])]),_:1}),o(n,null,{default:a(()=>[o(_,{type:"primary",loading:l.value,onClick:u,style:{width:"100%"}},{default:a(()=>[...e[2]||(e[2]=[v("登录",-1)])]),_:1},8,["loading"])]),_:1})]),_:1})]),_:1})])}}});export{A as default};
@@ -0,0 +1 @@
import{d,c as i,a as m,l as n,w as p,y as a,r as t,o}from"./index-CnyV6Gd0.js";const y={key:0,style:{padding:"12px"}},k=d({__name:"PageState",props:{loading:{type:Boolean,default:!1},error:{default:""},empty:{type:Boolean,default:!1},emptyText:{default:"暂无数据"}},setup(e){return(l,u)=>{const r=t("el-skeleton"),s=t("el-result"),c=t("el-empty");return e.loading?(o(),i("div",y,[m(r,{rows:5,animated:""})])):e.error?(o(),n(s,{key:1,icon:"error",title:"加载失败","sub-title":e.error},{extra:p(()=>[a(l.$slots,"retry")]),_:3},8,["sub-title"])):e.empty?(o(),n(c,{key:2,description:e.emptyText},null,8,["description"])):a(l.$slots,"default",{key:3})}}});export{k as _};
+1
View File
@@ -0,0 +1 @@
import{_ as x}from"./AppNav.vue_vue_type_script_setup_true_lang-RQbRtBk5.js";import{_ as k}from"./PageState.vue_vue_type_script_setup_true_lang-DEQuT0jG.js";import{d as C,j,c as B,a as e,w as t,r as l,h as u,o as N,f as p,t as w,b as y}from"./index-CnyV6Gd0.js";const V={class:"page"},D={style:{display:"flex","justify-content":"space-between","align-items":"center"}},P=C({__name:"PublicRecords",setup(E){const n=u(!1),s=u(""),c=u({}),r=u([]);async function i(){n.value=!0,s.value="";try{const _=await fetch("/public/records");if(!_.ok)throw new Error("请求失败");const a=await _.json();c.value=a.summary||{},r.value=a.records||[]}catch{s.value="公开记录加载失败"}finally{n.value=!1}}return j(i),(_,a)=>{const f=l("el-button"),d=l("el-statistic"),m=l("el-col"),b=l("el-row"),o=l("el-table-column"),h=l("el-table"),g=l("el-card");return N(),B("div",V,[e(x),e(g,{class:"card"},{header:t(()=>[y("div",D,[a[1]||(a[1]=y("span",null,"已记录内容(公开只读)",-1)),e(f,{onClick:i},{default:t(()=>[...a[0]||(a[0]=[p("刷新",-1)])]),_:1})])]),default:t(()=>[e(k,{loading:n.value,error:s.value,empty:!n.value&&!s.value&&r.value.length===0,"empty-text":"当前 0 条记录。请先到 /app 添加资产"},{retry:t(()=>[e(f,{onClick:i},{default:t(()=>[...a[2]||(a[2]=[p("重试",-1)])]),_:1})]),default:t(()=>[e(b,{gutter:12,style:{"margin-bottom":"12px"}},{default:t(()=>[e(m,{xs:24,sm:8},{default:t(()=>[e(d,{title:"活跃资产",value:c.value.active_asset_count||0},null,8,["value"])]),_:1}),e(m,{xs:24,sm:8},{default:t(()=>[e(d,{title:"总资产值",value:c.value.total_assets_value||0},null,8,["value"])]),_:1}),e(m,{xs:24,sm:8},{default:t(()=>[e(d,{title:"记录数",value:r.value.length},null,8,["value"])]),_:1})]),_:1}),e(h,{data:r.value,style:{width:"100%"}},{default:t(()=>[e(o,{prop:"id",label:"ID",width:"70"}),e(o,{prop:"name",label:"名称","min-width":"140"}),e(o,{prop:"category_name",label:"分类","min-width":"120"}),e(o,{label:"金额","min-width":"120"},{default:t(v=>[p(w(v.row.total_value)+" "+w(v.row.currency),1)]),_:1}),e(o,{prop:"status",label:"状态",width:"100"}),e(o,{prop:"expiry_date",label:"到期日","min-width":"180"})]),_:1},8,["data"])]),_:1},8,["loading","error","empty"])]),_:1})])}}});export{P as default};
+1
View File
@@ -0,0 +1 @@
import{_ as N}from"./AppNav.vue_vue_type_script_setup_true_lang-RQbRtBk5.js";import{_ as j}from"./PageState.vue_vue_type_script_setup_true_lang-DEQuT0jG.js";import{x as D,d as E,j as M,c as y,a as e,w as a,r as t,h as n,l as R,f as w,t as S,b as T,o as g}from"./index-CnyV6Gd0.js";import{g as $}from"./errors-E9D9vVes.js";async function I(f){const{data:r}=await D.get("/reminders",{params:f});return r}const U={class:"page"},q={key:1},A={style:{"margin-top":"12px",display:"flex","justify-content":"flex-end"}},K=E({__name:"Reminders",setup(f){const r=n("pending"),m=n([]),v=n(0),p=n(1),b=n(20),u=n(!1),d=n("");function h(){p.value=1,_()}async function _(){u.value=!0,d.value="";try{const i=await I({status:r.value||void 0,page:p.value,page_size:b.value});m.value=i.data||[],v.value=i.total||0}catch(i){d.value=$(i,"提醒数据加载失败")}finally{u.value=!1}}return M(_),(i,o)=>{const c=t("el-tab-pane"),x=t("el-tabs"),k=t("el-button"),l=t("el-table-column"),C=t("el-tag"),V=t("el-table"),z=t("el-pagination"),B=t("el-card");return g(),y("div",U,[e(N),e(B,{class:"card"},{default:a(()=>[e(x,{modelValue:r.value,"onUpdate:modelValue":o[0]||(o[0]=s=>r.value=s),onTabChange:h},{default:a(()=>[e(c,{label:"待处理",name:"pending"}),e(c,{label:"发送中",name:"sending"}),e(c,{label:"已发送",name:"sent"}),e(c,{label:"失败",name:"failed"})]),_:1},8,["modelValue"]),e(j,{loading:u.value,error:d.value,empty:!u.value&&!d.value&&m.value.length===0,"empty-text":"当前没有提醒记录"},{retry:a(()=>[e(k,{onClick:_},{default:a(()=>[...o[2]||(o[2]=[w("重试",-1)])]),_:1})]),default:a(()=>[e(V,{data:m.value},{default:a(()=>[e(l,{prop:"id",label:"ID",width:"70"}),e(l,{prop:"asset_name",label:"资产","min-width":"140"}),e(l,{prop:"status",label:"状态",width:"100"}),e(l,{prop:"remind_at",label:"提醒时间","min-width":"180"}),e(l,{prop:"next_retry_at",label:"下次重试","min-width":"180"}),e(l,{prop:"retry_count",label:"重试",width:"80"}),e(l,{label:"错误信息","min-width":"240"},{default:a(s=>[s.row.last_error?(g(),R(C,{key:0,type:"danger",size:"small"},{default:a(()=>[w(S(s.row.last_error),1)]),_:2},1024)):(g(),y("span",q,"-"))]),_:1})]),_:1},8,["data"]),T("div",A,[e(z,{background:"",layout:"prev, pager, next, total",total:v.value,"page-size":b.value,"current-page":p.value,onCurrentChange:o[1]||(o[1]=s=>{p.value=s,_()})},null,8,["total","page-size","current-page"])])]),_:1},8,["loading","error","empty"])]),_:1})])}}});export{K as default};
+1
View File
@@ -0,0 +1 @@
import{_ as p,c as d,a as t,w as o,b as n,f as i,r as s,o as u}from"./index-CnyV6Gd0.js";const c={},m={class:"page",style:{"max-width":"520px","margin-top":"80px"}};function _(r,e){const a=s("el-button"),l=s("el-card");return u(),d("div",m,[t(l,{class:"card"},{default:o(()=>[e[2]||(e[2]=n("h2",null,"会话已过期",-1)),e[3]||(e[3]=n("p",null,"登录状态失效,请重新登录后继续操作。",-1)),t(a,{type:"primary",onClick:e[0]||(e[0]=f=>r.$router.push("/login"))},{default:o(()=>[...e[1]||(e[1]=[i("去登录",-1)])]),_:1})]),_:1})])}const b=p(c,[["render",_]]);export{b as default};
+1
View File
@@ -0,0 +1 @@
import{x as s}from"./index-CnyV6Gd0.js";async function c(t){const{data:a}=await s.get("/assets",{params:t});return a}async function r(t){const{data:a}=await s.post("/assets",t);return a}async function o(t,a){const{data:e}=await s.put(`/assets/${t}`,a);return e}async function u(t){const{data:a}=await s.delete(`/assets/${t}`);return a}async function d(){const{data:t}=await s.get("/dashboard/summary");return t}export{u as a,r as c,d,c as l,o as u};
+1
View File
@@ -0,0 +1 @@
import{x as a}from"./index-CnyV6Gd0.js";async function r(){const{data:t}=await a.get("/categories");return t}async function s(t){const{data:e}=await a.post("/categories",t);return e}export{s as c,r as l};
+1
View File
@@ -0,0 +1 @@
function a(e,o="请求失败,请稍后重试"){const t=e?.response?.status,n=e?.response?.data?.message;if(n&&typeof n=="string"){const i=e?.response?.data?.request_id;return i?`${n}(请求编号: ${i}`:n}let s=o;t===400?s="请求参数有误,请检查后重试":t===401?s="登录状态已失效,请重新登录":t===403?s="你没有权限执行该操作":t===404?s="请求资源不存在":t>=500&&(s="服务暂时不可用,请稍后重试");const r=e?.response?.data?.request_id||e?.response?.headers?.["x-request-id"];return r?`${s}(请求编号: ${r}`:s}export{a as g};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<script type="module" crossorigin src="/_assets/index-CnyV6Gd0.js"></script>
<link rel="stylesheet" crossorigin href="/_assets/index-Bys16WK2.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2178
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "asset-tracker-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.11.0",
"echarts": "^5.6.0",
"element-plus": "^2.11.4",
"pinia": "^3.0.3",
"vue": "^3.5.25",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vue-tsc": "^3.1.5"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
+28
View File
@@ -0,0 +1,28 @@
import client from './client'
export type AssetQuery = { page?: number; page_size?: number; status?: string; category_id?: number }
export async function listAssets(params: AssetQuery) {
const { data } = await client.get('/assets', { params })
return data
}
export async function createAsset(payload: any) {
const { data } = await client.post('/assets', payload)
return data
}
export async function updateAsset(id: number, payload: any) {
const { data } = await client.put(`/assets/${id}`, payload)
return data
}
export async function deleteAsset(id: number) {
const { data } = await client.delete(`/assets/${id}`)
return data
}
export async function dashboardSummary() {
const { data } = await client.get('/dashboard/summary')
return data
}
+6
View File
@@ -0,0 +1,6 @@
import client from './client'
export async function loginApi(username: string, password: string) {
const { data } = await client.post('/auth/login', { username, password })
return data
}
+11
View File
@@ -0,0 +1,11 @@
import client from './client'
export async function listCategories() {
const { data } = await client.get('/categories')
return data
}
export async function createCategory(payload: { name: string; type: 'real' | 'digital' }) {
const { data } = await client.post('/categories', payload)
return data
}
+84
View File
@@ -0,0 +1,84 @@
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
import router from '../router'
type RetryableConfig = InternalAxiosRequestConfig & { _retry?: boolean }
let isRefreshing = false
let pendingQueue: Array<(token: string | null) => void> = []
function processQueue(token: string | null) {
pendingQueue.forEach((cb) => cb(token))
pendingQueue = []
}
const client = axios.create({
baseURL: '/api/v1',
timeout: 10000,
withCredentials: true,
})
client.interceptors.request.use((config) => {
const token = localStorage.getItem('asset_tracker_token')
if (token) {
config.headers = config.headers || {}
config.headers.Authorization = `Bearer ${token}`
}
return config
})
async function refreshAccessToken(): Promise<string> {
const resp = await axios.post('/api/v1/auth/refresh', {}, { withCredentials: true, timeout: 10000 })
const token = resp?.data?.access_token || ''
if (!token) throw new Error('refresh failed')
localStorage.setItem('asset_tracker_token', token)
return token
}
function redirectSessionExpired() {
localStorage.removeItem('asset_tracker_token')
if (router.currentRoute.value.path !== '/session-expired') {
router.push('/session-expired')
}
}
client.interceptors.response.use(
(resp) => resp,
async (err: AxiosError) => {
const status = err?.response?.status
const original = (err.config || {}) as RetryableConfig
if (status !== 401 || original._retry) {
return Promise.reject(err)
}
original._retry = true
if (isRefreshing) {
return new Promise((resolve, reject) => {
pendingQueue.push((token) => {
if (!token) return reject(err)
original.headers = original.headers || {}
original.headers.Authorization = `Bearer ${token}`
resolve(client(original))
})
})
}
isRefreshing = true
try {
const token = await refreshAccessToken()
processQueue(token)
original.headers = original.headers || {}
original.headers.Authorization = `Bearer ${token}`
return client(original)
} catch (e) {
processQueue(null)
redirectSessionExpired()
return Promise.reject(e)
} finally {
isRefreshing = false
}
},
)
export default client
+6
View File
@@ -0,0 +1,6 @@
import client from './client'
export async function listReminders(params: { status?: string; page?: number; page_size?: number }) {
const { data } = await client.get('/reminders', { params })
return data
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
function go(path: string) { router.push(path) }
function logout() { auth.logout(); router.push('/login') }
</script>
<template>
<el-card class="card" shadow="never" style="margin-bottom: 12px;">
<el-space wrap>
<el-button :type="route.path==='/' ? 'primary':'default'" @click="go('/')">公开记录</el-button>
<el-button :type="route.path==='/app' ? 'primary':'default'" @click="go('/app')">仪表盘</el-button>
<el-button :type="route.path==='/assets' ? 'primary':'default'" @click="go('/assets')">资产</el-button>
<el-button :type="route.path==='/categories' ? 'primary':'default'" @click="go('/categories')">分类</el-button>
<el-button :type="route.path==='/reminders' ? 'primary':'default'" @click="go('/reminders')">提醒</el-button>
<el-button v-if="auth.token" type="danger" plain @click="logout">退出</el-button>
<el-button v-else @click="go('/login')">登录</el-button>
</el-space>
</el-card>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
const props = withDefaults(defineProps<{ modelValue: boolean; editing?: any; categories: any[] }>(), { editing: null })
const emit = defineEmits(['update:modelValue', 'submit'])
const form = reactive<any>({ name: '', category_id: undefined, quantity: 1, unit_price: 0, currency: 'USD', expiry_date: '' })
watch(() => props.editing, (v) => {
if (v) Object.assign(form, { ...v, expiry_date: v.expiry_date ? v.expiry_date.slice(0,16) : '' })
else Object.assign(form, { name: '', category_id: undefined, quantity: 1, unit_price: 0, currency: 'USD', expiry_date: '' })
}, { immediate: true })
function save() {
if (!form.name?.trim()) return ElMessage.error('资产名必填')
if (Number(form.quantity) < 0 || Number(form.unit_price) < 0) return ElMessage.error('数量/单价必须 >= 0')
emit('submit', {
name: form.name.trim(),
category_id: Number(form.category_id),
quantity: Number(form.quantity),
unit_price: Number(form.unit_price),
currency: String(form.currency || '').toUpperCase(),
expiry_date: form.expiry_date ? new Date(form.expiry_date).toISOString() : '',
})
}
</script>
<template>
<el-dialog :model-value="modelValue" :title="editing ? '编辑资产' : '新增资产'" width="560px" @close="emit('update:modelValue', false)">
<el-form label-width="90px">
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="分类"><el-select v-model="form.category_id" style="width:100%"><el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" /></el-select></el-form-item>
<el-form-item label="数量"><el-input-number v-model="form.quantity" :min="0" style="width:100%" /></el-form-item>
<el-form-item label="单价"><el-input-number v-model="form.unit_price" :min="0" style="width:100%" /></el-form-item>
<el-form-item label="币种"><el-input v-model="form.currency" /></el-form-item>
<el-form-item label="到期日"><el-date-picker v-model="form.expiry_date" type="datetime" value-format="YYYY-MM-DDTHH:mm" style="width:100%" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
</template>
</el-dialog>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
const props = defineProps<{ expiryDate?: string }>()
function text(v?: string) {
if (!v) return '无到期日'
const ms = new Date(v).getTime() - Date.now()
const d = Math.ceil(ms / (24 * 3600 * 1000))
return d >= 0 ? `剩余 ${d}` : `已过期 ${Math.abs(d)}`
}
</script>
<template>
<el-tag size="small" :type="!expiryDate ? 'info' : 'warning'">{{ text(props.expiryDate) }}</el-tag>
</template>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
withDefaults(defineProps<{ loading?: boolean; error?: string; empty?: boolean; emptyText?: string }>(), {
loading: false,
error: '',
empty: false,
emptyText: '暂无数据',
})
</script>
<template>
<div v-if="loading" style="padding: 12px;"><el-skeleton :rows="5" animated /></div>
<el-result v-else-if="error" icon="error" title="加载失败" :sub-title="error">
<template #extra><slot name="retry" /></template>
</el-result>
<el-empty v-else-if="empty" :description="emptyText" />
<slot v-else />
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
defineProps<{ total: number; assetsCount: number; expiringCount: number }>()
</script>
<template>
<el-row :gutter="12">
<el-col :xs="24" :sm="8"><el-card><div>总资产</div><h2>{{ total }}</h2></el-card></el-col>
<el-col :xs="24" :sm="8"><el-card><div>资产数量</div><h2>{{ assetsCount }}</h2></el-card></el-col>
<el-col :xs="24" :sm="8"><el-card><div>30天到期</div><h2>{{ expiringCount }}</h2></el-card></el-col>
</el-row>
</template>
+13
View File
@@ -0,0 +1,13 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')
+31
View File
@@ -0,0 +1,31 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const Login = () => import('../views/Login.vue')
const PublicRecords = () => import('../views/PublicRecords.vue')
const Dashboard = () => import('../views/Dashboard.vue')
const Assets = () => import('../views/Assets.vue')
const Categories = () => import('../views/Categories.vue')
const Reminders = () => import('../views/Reminders.vue')
const SessionExpired = () => import('../views/SessionExpired.vue')
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: PublicRecords },
{ path: '/login', component: Login },
{ path: '/session-expired', component: SessionExpired },
{ path: '/app', component: Dashboard, meta: { requiresAuth: true } },
{ path: '/assets', component: Assets, meta: { requiresAuth: true } },
{ path: '/categories', component: Categories, meta: { requiresAuth: true } },
{ path: '/reminders', component: Reminders, meta: { requiresAuth: true } },
],
})
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.token) return '/login'
if (to.path === '/login' && auth.token) return '/app'
return true
})
export default router
+22
View File
@@ -0,0 +1,22 @@
import { defineStore } from 'pinia'
import { createAsset, deleteAsset, listAssets, updateAsset } from '../api/assets'
export const useAssetsStore = defineStore('assets', {
state: () => ({
list: [] as any[],
total: 0,
page: 1,
page_size: 10,
status: '' as '' | 'active' | 'inactive',
}),
actions: {
async fetch() {
const data = await listAssets({ page: this.page, page_size: this.page_size, status: this.status || undefined })
this.list = data.data || []
this.total = data.total || 0
},
async create(payload: any) { await createAsset(payload); await this.fetch() },
async update(id: number, payload: any) { await updateAsset(id, payload); await this.fetch() },
async remove(id: number) { await deleteAsset(id); await this.fetch() },
},
})
+19
View File
@@ -0,0 +1,19 @@
import { defineStore } from 'pinia'
import { loginApi } from '../api/auth'
export const useAuthStore = defineStore('auth', {
state: () => ({
token: localStorage.getItem('asset_tracker_token') || '',
}),
actions: {
async login(username: string, password: string) {
const data = await loginApi(username, password)
this.token = data.access_token
localStorage.setItem('asset_tracker_token', this.token)
},
logout() {
this.token = ''
localStorage.removeItem('asset_tracker_token')
},
},
})
+18
View File
@@ -0,0 +1,18 @@
import { defineStore } from 'pinia'
import { dashboardSummary } from '../api/assets'
export const useDashboardStore = defineStore('dashboard', {
state: () => ({
total_assets_value: 0,
by_category: [] as any[],
expiring_in_30_days: [] as any[],
}),
actions: {
async fetch() {
const data = await dashboardSummary()
this.total_assets_value = data.total_assets_value || 0
this.by_category = data.by_category || []
this.expiring_in_30_days = data.expiring_in_30_days || []
},
},
})
+3
View File
@@ -0,0 +1,3 @@
body { margin: 0; background: #f5f7fb; font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
.page { max-width: 1200px; margin: 16px auto; padding: 0 12px; }
.card { background: white; border-radius: 12px; padding: 12px; box-shadow: 0 1px 6px rgba(0,0,0,.06); margin-bottom: 12px; }
+18
View File
@@ -0,0 +1,18 @@
export function getErrorMessage(err: any, fallback = '请求失败,请稍后重试') {
const status = err?.response?.status
const serverMsg = err?.response?.data?.message
if (serverMsg && typeof serverMsg === 'string') {
const rid = err?.response?.data?.request_id
return rid ? `${serverMsg}(请求编号: ${rid}` : serverMsg
}
let msg = fallback
if (status === 400) msg = '请求参数有误,请检查后重试'
else if (status === 401) msg = '登录状态已失效,请重新登录'
else if (status === 403) msg = '你没有权限执行该操作'
else if (status === 404) msg = '请求资源不存在'
else if (status >= 500) msg = '服务暂时不可用,请稍后重试'
const rid = err?.response?.data?.request_id || err?.response?.headers?.['x-request-id']
return rid ? `${msg}(请求编号: ${rid}` : msg
}
+144
View File
@@ -0,0 +1,144 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AppNav from '../components/AppNav.vue'
import AssetFormDialog from '../components/AssetFormDialog.vue'
import ExpiryBadge from '../components/ExpiryBadge.vue'
import PageState from '../components/PageState.vue'
import { useAssetsStore } from '../stores/assets'
import { listCategories } from '../api/categories'
import { getErrorMessage } from '../utils/errors'
const assets = useAssetsStore()
const categories = ref<any[]>([])
const dialogVisible = ref(false)
const editing = ref<any>(null)
const loading = ref(false)
const error = ref('')
const keyword = ref('')
let kwTimer: any = null
async function loadCategories() {
const data = await listCategories()
categories.value = data.data || []
}
async function load() {
loading.value = true
error.value = ''
try {
await Promise.all([assets.fetch(), loadCategories()])
} catch (e: any) {
error.value = getErrorMessage(e, '资产数据加载失败')
} finally {
loading.value = false
}
}
function openCreate() {
editing.value = null
dialogVisible.value = true
}
function openEdit(row: any) {
editing.value = row
dialogVisible.value = true
}
async function save(payload: any) {
try {
if (editing.value) await assets.update(editing.value.id, payload)
else await assets.create(payload)
dialogVisible.value = false
ElMessage.success('保存成功')
} catch (e: any) {
ElMessage.error(getErrorMessage(e, '保存失败'))
}
}
async function remove(id: number) {
await ElMessageBox.confirm('确认删除该资产?', '提示', { type: 'warning' })
await assets.remove(id)
ElMessage.success('删除成功')
}
const categoryMap = computed(() => Object.fromEntries(categories.value.map((x) => [x.id, x.name])))
const filteredList = computed(() => {
const kw = keyword.value.trim().toLowerCase()
if (!kw) return assets.list
return (assets.list || []).filter((x: any) => {
return String(x.name || '').toLowerCase().includes(kw) || String(categoryMap.value[x.category_id] || '').toLowerCase().includes(kw)
})
})
function onKeywordInput() {
if (kwTimer) clearTimeout(kwTimer)
kwTimer = setTimeout(() => {
// local filter only; reserved for server-side keyword later
}, 300)
}
onMounted(load)
</script>
<template>
<div class="page">
<AppNav />
<el-card class="card">
<el-row :gutter="12">
<el-col :xs="24" :sm="8">
<el-select v-model="assets.status" placeholder="状态筛选" clearable @change="assets.fetch" style="width:100%">
<el-option label="active" value="active" />
<el-option label="inactive" value="inactive" />
</el-select>
</el-col>
<el-col :xs="24" :sm="8">
<el-input v-model="keyword" placeholder="按资产名/分类搜索" clearable @input="onKeywordInput" />
</el-col>
<el-col :xs="24" :sm="8">
<el-button type="primary" @click="openCreate">新增资产</el-button>
</el-col>
</el-row>
</el-card>
<el-card class="card">
<PageState :loading="loading" :error="error" :empty="!loading && !error && filteredList.length===0" empty-text="当前没有资产记录">
<template #retry><el-button @click="load">重试</el-button></template>
<el-table :data="filteredList">
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column label="分类" min-width="120">
<template #default="scope">{{ categoryMap[scope.row.category_id] || scope.row.category_id }}</template>
</el-table-column>
<el-table-column label="估值" min-width="120">
<template #default="scope">{{ scope.row.total_value }} {{ scope.row.currency }}</template>
</el-table-column>
<el-table-column label="到期" min-width="180">
<template #default="scope">
<div>{{ scope.row.expiry_date || '-' }}</div>
<ExpiryBadge :expiry-date="scope.row.expiry_date" />
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" />
<el-table-column label="操作" width="160">
<template #default="scope">
<el-button link type="primary" @click="openEdit(scope.row)">编辑</el-button>
<el-button link type="danger" @click="remove(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top:12px;display:flex;justify-content:flex-end;">
<el-pagination
background
layout="prev, pager, next, total"
:total="assets.total"
:page-size="assets.page_size"
:current-page="assets.page"
@current-change="(p:number)=>{assets.page=p;assets.fetch()}"
/>
</div>
</PageState>
</el-card>
<AssetFormDialog v-model="dialogVisible" :editing="editing" :categories="categories" @submit="save" />
</div>
</template>
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import AppNav from '../components/AppNav.vue'
import PageState from '../components/PageState.vue'
import { createCategory, listCategories } from '../api/categories'
import { getErrorMessage } from '../utils/errors'
const rows = ref<any[]>([])
const form = reactive<{ name: string; type: 'real' | 'digital' }>({ name: '', type: 'digital' })
const loading = ref(false)
const error = ref('')
async function load() {
loading.value = true
error.value = ''
try {
const data = await listCategories()
rows.value = data.data || []
} catch (e: any) {
error.value = getErrorMessage(e, '分类数据加载失败')
} finally {
loading.value = false
}
}
async function submit() {
if (!form.name.trim()) return ElMessage.error('分类名必填')
try {
await createCategory({ name: form.name.trim(), type: form.type })
form.name = ''
await load()
ElMessage.success('新增成功')
} catch (e: any) {
ElMessage.error(getErrorMessage(e, '新增失败'))
}
}
onMounted(load)
</script>
<template>
<div class="page">
<AppNav />
<el-card class="card">
<el-row :gutter="12">
<el-col :xs="24" :sm="10"><el-input v-model="form.name" placeholder="分类名称" /></el-col>
<el-col :xs="24" :sm="8"><el-select v-model="form.type" style="width:100%"><el-option label="digital" value="digital" /><el-option label="real" value="real" /></el-select></el-col>
<el-col :xs="24" :sm="6"><el-button type="primary" @click="submit">新增分类</el-button></el-col>
</el-row>
</el-card>
<el-card class="card">
<PageState :loading="loading" :error="error" :empty="!loading && !error && rows.length===0" empty-text="当前没有分类记录">
<template #retry><el-button @click="load">重试</el-button></template>
<el-table :data="rows">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="分类名" min-width="140" />
<el-table-column prop="type" label="类型" width="120" />
<el-table-column prop="created_at" label="创建时间" min-width="180" />
</el-table>
</PageState>
</el-card>
</div>
</template>
+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue'
import * as echarts from 'echarts'
import { useDashboardStore } from '../stores/dashboard'
import SummaryCards from '../components/SummaryCards.vue'
import AppNav from '../components/AppNav.vue'
const dashboard = useDashboardStore()
const chartEl = ref<HTMLDivElement>()
let chart: echarts.ECharts | null = null
function renderChart() {
if (!chartEl.value) return
if (!chart) chart = echarts.init(chartEl.value)
chart.setOption({
tooltip: { trigger: 'item' },
series: [{
type: 'pie',
radius: ['40%', '70%'],
data: (dashboard.by_category || []).map((x: any) => ({ name: x.category_name || '未分类', value: x.total_value })),
}],
})
}
async function load() {
await dashboard.fetch()
await nextTick()
renderChart()
}
onMounted(load)
</script>
<template>
<div class="page">
<AppNav />
<SummaryCards
:total="dashboard.total_assets_value"
:assets-count="dashboard.by_category.length"
:expiring-count="dashboard.expiring_in_30_days.length"
/>
<el-row :gutter="12" style="margin-top: 12px;">
<el-col :xs="24" :md="12">
<el-card class="card">
<template #header>分类占比</template>
<div ref="chartEl" style="height:320px"></div>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card class="card">
<template #header>30天到期 Top10</template>
<el-table :data="dashboard.expiring_in_30_days.slice(0,10)" size="small">
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column prop="expiry_date" label="到期日" min-width="180" />
<el-table-column label="金额" min-width="110">
<template #default="scope">{{ scope.row.total_value }} {{ scope.row.currency }}</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '../stores/auth'
import { useRouter } from 'vue-router'
import { getErrorMessage } from '../utils/errors'
const form = reactive({ username: '', password: '' })
const auth = useAuthStore()
const router = useRouter()
const loading = ref(false)
async function submit() {
loading.value = true
try {
await auth.login(form.username, form.password)
ElMessage.success('登录成功')
router.push('/app')
} catch (e: any) {
ElMessage.error(getErrorMessage(e, '登录失败,请检查账号密码'))
} finally {
loading.value = false
}
}
</script>
<template>
<div class="page" style="max-width: 420px; margin-top: 80px;">
<el-card class="card">
<h2>登录</h2>
<el-form @submit.prevent="submit" label-width="80px">
<el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item>
<el-form-item label="密码"><el-input v-model="form.password" show-password type="password" /></el-form-item>
<el-form-item><el-button type="primary" :loading="loading" @click="submit" style="width:100%">登录</el-button></el-form-item>
</el-form>
</el-card>
</div>
</template>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import AppNav from '../components/AppNav.vue'
import PageState from '../components/PageState.vue'
const loading = ref(false)
const error = ref('')
const summary = ref<any>({})
const records = ref<any[]>([])
async function load() {
loading.value = true
error.value = ''
try {
const res = await fetch('/public/records')
if (!res.ok) throw new Error('请求失败')
const data = await res.json()
summary.value = data.summary || {}
records.value = data.records || []
} catch {
error.value = '公开记录加载失败'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<div class="page">
<AppNav />
<el-card class="card">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span>已记录内容公开只读</span>
<el-button @click="load">刷新</el-button>
</div>
</template>
<PageState :loading="loading" :error="error" :empty="!loading && !error && records.length===0" empty-text="当前 0 条记录请先到 /app 添加资产">
<template #retry><el-button @click="load">重试</el-button></template>
<el-row :gutter="12" style="margin-bottom:12px;">
<el-col :xs="24" :sm="8"><el-statistic title="活跃资产" :value="summary.active_asset_count || 0" /></el-col>
<el-col :xs="24" :sm="8"><el-statistic title="总资产值" :value="summary.total_assets_value || 0" /></el-col>
<el-col :xs="24" :sm="8"><el-statistic title="记录数" :value="records.length" /></el-col>
</el-row>
<el-table :data="records" style="width:100%">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column prop="category_name" label="分类" min-width="120" />
<el-table-column label="金额" min-width="120">
<template #default="scope">{{ scope.row.total_value }} {{ scope.row.currency }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" />
<el-table-column prop="expiry_date" label="到期日" min-width="180" />
</el-table>
</PageState>
</el-card>
</div>
</template>
+79
View File
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import AppNav from '../components/AppNav.vue'
import PageState from '../components/PageState.vue'
import { listReminders } from '../api/reminders'
import { getErrorMessage } from '../utils/errors'
const status = ref('pending')
const rows = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const error = ref('')
function onTabChange() {
page.value = 1
load()
}
async function load() {
loading.value = true
error.value = ''
try {
const data = await listReminders({ status: status.value || undefined, page: page.value, page_size: pageSize.value })
rows.value = data.data || []
total.value = data.total || 0
} catch (e: any) {
error.value = getErrorMessage(e, '提醒数据加载失败')
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<div class="page">
<AppNav />
<el-card class="card">
<el-tabs v-model="status" @tab-change="onTabChange">
<el-tab-pane label="待处理" name="pending" />
<el-tab-pane label="发送中" name="sending" />
<el-tab-pane label="已发送" name="sent" />
<el-tab-pane label="失败" name="failed" />
</el-tabs>
<PageState :loading="loading" :error="error" :empty="!loading && !error && rows.length===0" empty-text="当前没有提醒记录">
<template #retry><el-button @click="load">重试</el-button></template>
<el-table :data="rows">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="asset_name" label="资产" min-width="140" />
<el-table-column prop="status" label="状态" width="100" />
<el-table-column prop="remind_at" label="提醒时间" min-width="180" />
<el-table-column prop="next_retry_at" label="下次重试" min-width="180" />
<el-table-column prop="retry_count" label="重试" width="80" />
<el-table-column label="错误信息" min-width="240">
<template #default="scope">
<el-tag v-if="scope.row.last_error" type="danger" size="small">{{ scope.row.last_error }}</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
<div style="margin-top:12px;display:flex;justify-content:flex-end;">
<el-pagination
background
layout="prev, pager, next, total"
:total="total"
:page-size="pageSize"
:current-page="page"
@current-change="(p:number)=>{page=p;load()}"
/>
</div>
</PageState>
</el-card>
</div>
</template>
@@ -0,0 +1,9 @@
<template>
<div class="page" style="max-width: 520px; margin-top: 80px;">
<el-card class="card">
<h2>会话已过期</h2>
<p>登录状态失效请重新登录后继续操作</p>
<el-button type="primary" @click="$router.push('/login')">去登录</el-button>
</el-card>
</div>
</template>
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
base: '/',
build: {
assetsDir: '_assets',
},
})
+72
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>
+72
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
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
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}`); }
}
})();
+33
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();
+33
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}
}
+38
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
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}`); }
}
})();
+33
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();
+33
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}
}