AI接入 v1.0

UI更新
This commit is contained in:
2026-09-24 14:27:12 +08:00
parent 90eeae51af
commit 2338ff5885
46 changed files with 2301 additions and 83 deletions
+257 -9
View File
@@ -40,20 +40,108 @@ input, textarea { width: 100%; }
input:focus, textarea:focus, select:focus { border-color: var(--brand); }
select { cursor: pointer; padding: 7px 8px; }
/* ---------- 登录 ---------- */
/* 图标:由 qiangbao_accounting/tools/make_icons.py 生成并同步到 /admin/icons/。
用 vertical-align 微调基线,让它和文字对齐得自然一点 */
.ci { display: inline-block; vertical-align: -0.16em; flex-shrink: 0; }
/* ===========================================================================
登录页 —— 视觉对齐手机端登录页(qiangbao_accounting/lib/pages/login/login_page.dart)
暖橙渐变底 + 漂浮圆球 + 白色圆形熊头 + 金色装饰线标题 + 毛玻璃卡片
=========================================================================== */
#login-view {
position: relative; overflow: hidden;
min-height: 100vh; display: flex; align-items: center; justify-content: center;
padding: 32px 20px;
background: linear-gradient(135deg, #FFE5CC 0%, #FFD4A8 50%, #FFC08A 100%);
}
/* ---------- 背景装饰圆球 ---------- */
.login-balls { position: absolute; inset: 0; pointer-events: none; }
.login-balls .ball {
position: absolute; border-radius: 50%; opacity: .3;
filter: blur(1px); will-change: transform;
}
.login-balls .b1 {
width: 170px; height: 170px; left: -34px; top: 6%;
background: linear-gradient(135deg, #FFB6C1, #FF8A9B);
animation: ball-a 11s ease-in-out infinite;
}
.login-balls .b2 {
width: 132px; height: 132px; right: -24px; top: 20%;
background: linear-gradient(135deg, #C8956E, #F5D59E);
animation: ball-b 13s ease-in-out infinite;
}
.login-balls .b3 {
width: 200px; height: 200px; left: 10%; bottom: -60px;
background: linear-gradient(135deg, #FFD700, #F5D59E);
animation: ball-c 15s ease-in-out infinite;
}
@keyframes ball-a { 0%,100% { transform: translate(0,0); } 50% { transform: translate(22px,30px); } }
@keyframes ball-b { 0%,100% { transform: translate(0,0); } 50% { transform: translate(-18px,26px); } }
@keyframes ball-c { 0%,100% { transform: translate(0,0); } 50% { transform: translate(16px,-24px); } }
/* ---------- 上半部分:logo + 标题 ---------- */
.login-wrap { position: relative; width: 100%; max-width: 380px; }
.login-header { text-align: center; margin-bottom: 32px; }
.login-logo {
width: 120px; height: 120px; margin: 0 auto; padding: 10px;
border-radius: 50%; background: #fff; overflow: hidden;
box-shadow: 0 8px 20px rgba(93,64,55,.3);
}
.login-logo img { width: 100%; height: 100%; object-fit: contain; display: block; }
.login-title {
display: flex; align-items: center; justify-content: center; gap: 10px;
margin: 16px 0 0; font-size: 28px; font-weight: bold; letter-spacing: 2px;
color: #5D4037; text-shadow: 0 3px 10px rgba(255,215,0,.4);
}
.login-title .deco {
width: 40px; height: 4px; border-radius: 4px; flex-shrink: 0;
background: linear-gradient(90deg, #C8956E, #FFD700);
}
.login-sub { margin: 6px 0 0; font-size: 14px; color: #8D6E63; }
/* ---------- 毛玻璃表单卡片 ---------- */
.login-box {
width: 340px; background: #fff; padding: 36px 32px;
border-radius: 16px; border: 1px solid var(--border);
box-shadow: 0 8px 32px rgba(0,0,0,.05);
padding: 24px; border-radius: 24px;
background: rgba(255,255,255,.65);
border: 1.5px solid #C8956E;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.login-box h1 { margin: 0 0 4px; font-size: 20px; }
.login-box p.sub { margin: 0 0 24px; color: var(--text-dim); font-size: 13px; }
.login-box .field { margin-bottom: 14px; }
.login-box .field label { display: block; margin-bottom: 6px; font-size: 13px; color: var(--text-dim); }
.login-box button { width: 100%; margin-top: 8px; padding: 10px; }
.login-field {
display: flex; align-items: center; gap: 10px;
padding: 0 14px; margin-bottom: 14px;
background: rgba(255,255,255,.8);
border: 1px solid #E0C9A6; border-radius: 14px;
transition: border-color .15s;
}
.login-field:focus-within { border-color: var(--brand); }
.login-field .ico { font-size: 18px; line-height: 1; flex-shrink: 0; }
/* 盖掉全局 input 的白底/边框,让它融进外层容器 */
.login-field input,
.login-field input:focus {
flex: 1; min-width: 0; width: auto;
border: none; border-radius: 0; background: transparent;
padding: 14px 0; font-size: 15px; color: #5D4037;
}
.login-field input::placeholder { color: #BCAAA4; font-size: 14px; }
.login-field .eye {
flex-shrink: 0; border: none; background: none; padding: 2px;
font-size: 16px; line-height: 1; opacity: .75;
}
.login-field .eye:hover { opacity: 1; border: none; color: inherit; }
.login-btn {
width: 100%; height: 48px; margin-top: 10px;
border: none; border-radius: 24px;
background: var(--brand); color: #fff;
font-size: 17px; font-weight: bold; letter-spacing: 4px;
}
.login-btn:hover { background: #B9855E; border: none; color: #fff; }
.login-btn:disabled { opacity: .65; cursor: default; }
/* ===========================================================================
主界面骨架
@@ -75,6 +163,7 @@ select { cursor: pointer; padding: 7px 8px; }
font-size: 16px; font-weight: 600; color: #3E2723; white-space: nowrap;
}
.brand-icon { font-size: 19px; line-height: 1; }
.brand-logo { width: 22px; height: 22px; object-fit: contain; display: block; }
.console { flex: 1; display: flex; min-height: 0; }
@@ -286,3 +375,162 @@ mark { background: #FFE9A8; color: inherit; padding: 0 1px; border-radius: 2px;
}
#toast.show { opacity: 1; }
#toast.err { background: var(--danger); }
/* ===========================================================================
AI 助手模块
=========================================================================== */
.toolbar-title { font-weight: 600; }
.toolbar-note { color: var(--text-dim); font-size: 12.5px; }
/* 会话区:消息自己滚,输入框钉在底部 */
.ai-main { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 0; }
.ai-messages { flex: 1; overflow-y: auto; padding: 18px 24px 6px; }
.ai-messages .ai-empty {
color: var(--text-dim); text-align: center; padding: 70px 20px; line-height: 1.9;
}
.ai-msg { display: flex; margin-bottom: 16px; }
.ai-msg.user { justify-content: flex-end; }
.ai-bubble {
max-width: 80%; min-width: 0;
padding: 10px 14px; border-radius: 12px; font-size: 14px;
}
.ai-msg.user .ai-bubble {
background: var(--brand); color: #fff;
border-bottom-right-radius: 4px;
/* 用户消息不渲染 Markdown,保留原始换行 */
white-space: pre-wrap; word-break: break-word;
}
.ai-msg.assistant .ai-bubble {
background: #fff; border: 1px solid var(--border);
border-bottom-left-radius: 4px;
}
/* 生成中:还没转 Markdown,用纯文本 + 光标,避免每个 delta 都重建一次 DOM */
.ai-msg.streaming .ai-bubble-body { white-space: pre-wrap; word-break: break-word; }
.ai-msg.streaming .ai-bubble-body::after {
content: '▋'; color: var(--brand); animation: ai-blink 1s steps(2) infinite;
}
@keyframes ai-blink { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0; } }
.ai-msg.failed .ai-bubble {
background: #FDECEA; border-color: #F5C6C2; color: #B71C1C;
}
/* 回答里的 Markdown(Vditor.preview 生成的 .vditor-reset 在这里要收敛一点) */
.ai-bubble .vditor-reset { font-size: 14px; line-height: 1.7; color: var(--text); }
.ai-bubble .vditor-reset > :first-child { margin-top: 0; }
.ai-bubble .vditor-reset > :last-child { margin-bottom: 0; }
.ai-bubble .vditor-reset h1 { font-size: 18px; }
.ai-bubble .vditor-reset h2 { font-size: 16.5px; }
.ai-bubble .vditor-reset h3 { font-size: 15px; }
.ai-bubble .vditor-reset pre { background: #2B2B2B; border-radius: 8px; padding: 12px; overflow-x: auto; }
.ai-bubble .vditor-reset code:not(.hljs) { background: var(--brand-light); padding: 1px 5px; border-radius: 4px; }
.ai-bubble .vditor-reset table { border-collapse: collapse; }
.ai-bubble .vditor-reset th, .ai-bubble .vditor-reset td { border: 1px solid var(--border); padding: 5px 9px; }
.ai-bubble .vditor-reset img { max-width: 100%; }
/* 工具调用提示 */
.ai-status {
flex-shrink: 0; padding: 7px 24px; font-size: 12.5px;
color: var(--brand); background: var(--brand-light);
}
/* 输入区 */
.ai-composer {
flex-shrink: 0; display: flex; align-items: flex-end; gap: 10px;
padding: 12px 24px 16px; background: #fff; border-top: 1px solid var(--border);
}
.ai-composer textarea { resize: none; max-height: 160px; line-height: 1.5; padding: 9px 12px; }
.ai-composer button { flex-shrink: 0; padding: 9px 20px; }
/* 会话列表 */
.ai-session {
position: relative; display: block; width: 100%; text-align: left;
border: none; background: transparent; padding: 8px 26px 8px 10px;
border-radius: 6px; margin-bottom: 2px; cursor: pointer;
}
.ai-session:hover { background: var(--brand-light); }
.ai-session.active { background: var(--brand-light); }
.ai-session.active .t { color: var(--brand); font-weight: 600; }
.ai-session .t {
display: block; font-size: 13.5px; color: var(--text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ai-session .d { display: block; font-size: 11.5px; color: var(--text-dim); }
.ai-session .del {
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
width: 18px; height: 18px; line-height: 17px; text-align: center;
border-radius: 4px; color: var(--text-dim); font-size: 14px; opacity: 0;
}
.ai-session .del:hover { background: #FDECEA; color: var(--danger); }
.ai-session:hover .del { opacity: 1; }
/* ===========================================================================
财务管理模块
=========================================================================== */
.toolbar-sep { width: 1px; height: 20px; background: var(--border); margin: 0 4px; }
.icon-btn {
width: 30px; height: 30px; padding: 0; line-height: 1;
font-size: 17px; color: var(--brand); border-radius: 8px;
}
.icon-btn:disabled { opacity: .3; cursor: default; }
.icon-btn:disabled:hover { border-color: var(--border); color: var(--brand); }
.fin-month { font-weight: 600; min-width: 90px; text-align: center; }
/* ---------- 侧栏:概览 ---------- */
.fin-stat { display: flex; align-items: baseline; justify-content: space-between; padding: 6px; }
.fin-stat .k { font-size: 13px; color: var(--text-dim); }
.fin-stat .v { font-size: 15px; font-weight: 600; }
.fin-stat .v.income { color: #19BE6B; }
.fin-stat .v.expense { color: #FA3534; }
.fin-stat .v.balance { color: #5D4037; }
.fin-divider { height: 1px; background: var(--border); margin: 10px 4px; }
.fin-budget { padding: 4px 6px 2px; }
.fin-budget-head {
display: flex; justify-content: space-between; align-items: baseline;
font-size: 13px; color: var(--text-dim); margin-bottom: 7px;
}
.fin-budget-head b { color: var(--brand); font-size: 14px; }
.fin-budget-foot { font-size: 12px; color: var(--text-dim); margin-top: 7px; }
.fin-bar { height: 6px; background: #F2EBE3; border-radius: 999px; overflow: hidden; }
.fin-bar i {
display: block; height: 100%; border-radius: 999px;
background: linear-gradient(90deg, #C8956E, #F5D59E);
}
/* ---------- 侧栏:分类 ---------- */
.fin-cat { padding: 7px 6px; border-radius: 8px; cursor: pointer; margin-bottom: 2px; }
.fin-cat:hover { background: var(--brand-light); }
.fin-cat.active { background: var(--brand-light); box-shadow: inset 0 0 0 1px var(--brand); }
.fin-cat-row {
display: flex; justify-content: space-between; align-items: baseline;
font-size: 13px; margin-bottom: 5px; gap: 8px;
}
.fin-cat-row .n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fin-cat-row b { font-weight: 600; flex-shrink: 0; }
/* ---------- 账单明细 ---------- */
.fin-day {
display: flex; align-items: baseline; gap: 10px;
padding: 14px 2px 8px; font-size: 12.5px; color: var(--text-dim);
}
.fin-day .d { font-weight: 600; color: var(--text); }
.fin-day .sum { margin-left: auto; }
.fin-day .sum.out { color: #FA3534; }
.fin-day .sum.in { color: #19BE6B; }
.fin-bill {
display: flex; align-items: center; gap: 10px;
background: #fff; border: 1px solid var(--border); border-radius: 10px;
padding: 11px 14px; margin-bottom: 6px;
}
.fin-bill .ico { font-size: 17px; flex-shrink: 0; line-height: 1; }
.fin-bill .cat { font-size: 13.5px; flex-shrink: 0; min-width: 46px; }
.fin-bill .desc {
flex: 1; min-width: 0; font-size: 12.5px; color: var(--text-dim);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.fin-bill .amt { font-size: 14px; font-weight: 600; flex-shrink: 0; }
.fin-bill .amt.out { color: #FA3534; }
.fin-bill .amt.in { color: #19BE6B; }
+686 -9
View File
@@ -123,9 +123,30 @@
});
}
// ---------------------------------------------------------------- 图标
//
// 分类图标现在是「图标名」(food / transport…),由 qiangbao_accounting 的
// tools/make_icons.py 生成并同步到 /admin/icons/。icons.js 里那份清单和
// emoji 兼容映射也是那个脚本产出的 —— **不要在这里再维护一份**。
// 兼容旧数据:迁移还没跑时数据库里存的还是 emoji。
function iconName(raw) {
var meta = window.CUTE_ICONS || { fallback: 'other', names: [], aliases: {} };
var v = (raw || '').trim();
if (!v) return meta.fallback;
if (meta.names.indexOf(v) >= 0) return v;
var stripped = v.replace(/\uFE0F/g, '');
return meta.aliases[v] || meta.aliases[stripped] || meta.fallback;
}
/** 图标 <img>,默认 20px */
function iconImg(raw, size) {
var s = size || 20;
return '<img class="ci" src="/admin/icons/' + iconName(raw) + '.svg" ' +
'width="' + s + '" height="' + s + '" alt="">';
}
// ---------------------------------------------------------------- 登录
function showApp() {
$('login-view').style.display = 'none';
function showApp() { $('login-view').style.display = 'none';
$('app-view').style.display = '';
$('who').textContent = state.username ? ('当前:' + state.username) : '';
refreshAll();
@@ -148,6 +169,14 @@
var password = $('login-password').value;
if (!username || !password) { toast('请填写用户名和密码', true); return; }
var btn = $('login-btn');
btn.disabled = true;
btn.textContent = '登录中…';
function restoreBtn() {
btn.disabled = false;
btn.textContent = '登 录';
}
fetch(API + '/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -166,7 +195,20 @@
localStorage.setItem(USER_KEY, state.username);
$('login-password').value = '';
showApp();
}).catch(function (e) { toast(e.message, true); });
restoreBtn();
}).catch(function (e) {
toast(e.message, true);
restoreBtn();
});
}
/** 密码框的显示/隐藏(手机端也有这个眼睛)。用透明度表示状态 */
function togglePassword() {
var input = $('login-password');
var show = input.type === 'password';
input.type = show ? 'text' : 'password';
$('login-eye').style.opacity = show ? '1' : '0.45';
$('login-eye').title = show ? '隐藏密码' : '显示密码';
}
// ---------------------------------------------------------------- 目录树
@@ -201,13 +243,13 @@
box.appendChild(buildSimpleRow({
mode: 'all',
icon: '📚',
icon: 'note',
name: '全部',
count: state.tree.totalCount
}));
box.appendChild(buildSimpleRow({
mode: 'uncategorized',
icon: '📥',
icon: 'inbox',
name: '未分类',
count: state.tree.uncategorizedCount
}));
@@ -248,7 +290,7 @@
row.innerHTML =
'<span class="tree-toggle"></span>' +
'<span class="tree-icon">' + opt.icon + '</span>' +
'<span class="tree-icon">' + iconImg(opt.icon, 15) + '</span>' +
'<span class="tree-name">' + esc(opt.name) + '</span>' +
'<span class="tree-count">' + opt.count + '</span>';
@@ -285,7 +327,7 @@
var icon = document.createElement('span');
icon.className = 'tree-icon';
icon.textContent = (open && hasKids) ? '📂' : '📁';
icon.innerHTML = iconImg((open && hasKids) ? 'folder-open' : 'folder', 15);
var name = document.createElement('span');
name.className = 'tree-name';
@@ -605,7 +647,7 @@
el.innerHTML =
'<div class="head">' +
'<h3>' + highlight(n.title, state.keyword) + '</h3>' +
(n.isPinned ? '<span class="pin">📌</span>' : '') +
(n.isPinned ? '<span class="pin">' + iconImg('pin', 14) + '</span>' : '') +
'</div>' +
'<div class="summary">' + (n.summary
? highlight(n.summary, state.keyword)
@@ -1221,12 +1263,618 @@
buildExportHtml(note, withImages, hljsCss),
'text/html'
);
toast('✅ 已导出');
toast('已导出');
} catch (e) {
toast('导出失败:' + e.message, true);
}
}
// ================================================================ 模块切换
function switchModule(name) {
document.querySelectorAll('.module-item').forEach(function (el) {
el.classList.toggle('active', el.dataset.module === name);
});
document.querySelectorAll('.module-panel').forEach(function (el) {
el.style.display = el.dataset.modulePanel === name ? '' : 'none';
});
if (name === 'ai') aiEnter();
if (name === 'finance') finEnter();
}
// ================================================================ AI 助手
//
// 和 App 端共用同一套后端接口,历史存在服务端的 chat_session / chat_message,
// 所以两边看到的是同一批会话 —— 手机上聊到一半,回电脑上能接着聊。
var ai = {
ready: false,
checked: false,
sessionId: null,
sessions: [],
streaming: false
};
function aiEnter() {
if (!ai.checked) {
ai.checked = true;
request('/ai/status').then(function (s) {
ai.ready = !!(s && s.enabled);
$('ai-hint').textContent = ai.ready ? '' : '后端未配置 AI';
if (!ai.ready) {
aiShowEmpty('后端还没配好 AI —— 检查服务端环境变量 AI_BASE_URL / AI_API_KEY / AI_MODEL');
}
aiUpdateComposer();
}).catch(function () {
// 状态都拿不到,就别让用户对着一个点了没反应的输入框干瞪眼
$('ai-hint').textContent = '无法获取 AI 状态';
aiShowEmpty('连不上后端,检查一下服务是否正常');
aiUpdateComposer();
});
}
// 第一次进来时自动展开最近一次对话(和 App 端一致)
if (ai.sessionId === null) aiLoadSessions(true);
setTimeout(function () { $('ai-input').focus(); }, 0);
}
function aiLoadSessions(autoOpen) {
return request('/ai/sessions').then(function (list) {
ai.sessions = list || [];
aiRenderSessions();
if (autoOpen && ai.sessionId === null && ai.sessions.length) {
aiOpenSession(ai.sessions[0].id);
} else if (!ai.sessions.length && ai.sessionId === null) {
aiShowEmpty();
}
}).catch(function (e) { toast(e.message, true); });
}
function aiRenderSessions() {
var box = $('ai-session-list');
box.innerHTML = '';
if (!ai.sessions.length) {
box.innerHTML = '<div class="side-title" style="padding:4px 6px">还没有对话</div>';
return;
}
ai.sessions.forEach(function (s) {
var el = document.createElement('div');
el.className = 'ai-session' + (s.id === ai.sessionId ? ' active' : '');
el.innerHTML = '<span class="t">' + esc(s.title) + '</span>' +
'<span class="d">' + esc(formatTime(s.updateTime)) + '</span>' +
'<span class="del" title="删除这个对话">×</span>';
el.onclick = function (e) {
if (e.target.classList && e.target.classList.contains('del')) {
aiDeleteSession(s);
} else {
aiOpenSession(s.id);
}
};
box.appendChild(el);
});
}
function aiOpenSession(id) {
ai.sessionId = id;
aiRenderSessions();
var box = $('ai-messages');
box.innerHTML = '<div class="ai-empty">加载中…</div>';
request('/ai/sessions/' + id + '/messages').then(function (list) {
if (ai.sessionId !== id) return; // 加载期间又切走了,丢弃
box.innerHTML = '';
if (!list.length) { aiShowEmpty(); return; }
list.forEach(function (m) { aiAppendBubble(m.role, m.content); });
box.scrollTop = box.scrollHeight;
}).catch(function (e) {
box.innerHTML = '';
aiShowEmpty(e.message);
});
}
function aiDeleteSession(s) {
if (!confirm('删除对话「' + s.title + '」?删了不能恢复。')) return;
request('/ai/sessions/' + s.id, { method: 'DELETE' }).then(function () {
if (ai.sessionId === s.id) { ai.sessionId = null; $('ai-messages').innerHTML = ''; }
return aiLoadSessions(false);
}).then(function () {
toast('已删除');
}).catch(function (e) { toast(e.message, true); });
}
function aiNewChat() {
if (ai.streaming) return;
ai.sessionId = null;
aiRenderSessions();
$('ai-messages').innerHTML = '';
aiShowEmpty();
$('ai-input').focus();
}
// ---------------------------------------------------------------- 消息渲染
function aiShowEmpty(msg) {
$('ai-messages').innerHTML = '<div class="ai-empty">' +
esc(msg || '问点什么吧 —— 我能查你的账单和笔记,也能联网搜索。') + '</div>';
}
function aiClearEmpty() {
var box = $('ai-messages');
if (box.querySelector('.ai-empty')) box.innerHTML = '';
}
function aiAppendBubble(role, content) {
var wrap = document.createElement('div');
wrap.className = 'ai-msg ' + role;
var bubble = document.createElement('div');
bubble.className = 'ai-bubble';
var body = document.createElement('div');
body.className = 'ai-bubble-body';
bubble.appendChild(body);
wrap.appendChild(bubble);
$('ai-messages').appendChild(wrap);
if (role === 'assistant') {
aiRenderMarkdown(body, content);
} else {
body.textContent = content;
}
aiScrollBottom();
return wrap;
}
/**
* 把 Markdown 渲染进气泡。
*
* 生成过程中**不走这里** —— 那时候用纯文本 + CSS 光标,等 done 再渲染一次。
* 因为 Vditor.preview 每次都会重建整块 DOM,一个回答几十上百个 delta
* 逐个渲染会肉眼可见地卡。
*/
function aiRenderMarkdown(el, md) {
if (!md) { el.textContent = ''; return; }
if (typeof Vditor === 'undefined' || !Vditor.preview) {
el.textContent = md; // 兜底:至少不白屏
return;
}
el.innerHTML = '';
Vditor.preview(el, md, {
mode: 'light',
cdn: '/admin/vendor/vditor',
lang: 'zh_CN',
hljs: { style: 'github' }
});
}
function aiScrollBottom() {
var box = $('ai-messages');
// 用户自己往上翻看历史时别抢滚动条
if (box.scrollHeight - box.scrollTop - box.clientHeight > 160) return;
box.scrollTop = box.scrollHeight;
}
// ---------------------------------------------------------------- 发送
function aiSend() {
var input = $('ai-input');
var text = input.value.trim();
if (!text || ai.streaming || !ai.ready) return;
input.value = '';
aiAutoGrow();
aiClearEmpty();
aiAppendBubble('user', text);
// 先放一个空的回答气泡,流式的文字往里追加
var answer = aiAppendBubble('assistant', '');
answer.classList.add('streaming');
answer._text = '';
answer.querySelector('.ai-bubble-body').textContent = '';
ai.streaming = true;
aiUpdateComposer();
aiStream(text, answer);
}
/**
* 读 SSE。
*
* 不能用 EventSource:它只支持 GET,而发消息必须 POST 带 body。
* 所以用 fetch 拿 ReadableStream 自己按 SSE 协议切。
*/
function aiStream(text, answerEl) {
fetch(API + '/ai/chat', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + state.token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ sessionId: ai.sessionId, content: text })
}).then(function (res) {
if (res.status === 401 || res.status === 403) {
doLogout();
throw new Error('登录已过期,请重新登录');
}
if (!res.ok) throw new Error('请求失败 ' + res.status);
if (!res.body) throw new Error('这个浏览器不支持流式响应');
var reader = res.body.getReader();
var decoder = new TextDecoder('utf-8');
var buf = '', evt = '', data = '';
function onLine(line) {
if (line === '') { // 空行 = 一个事件结束
if (data) aiHandleEvent(evt, data, answerEl);
evt = ''; data = '';
return;
}
if (line.indexOf('event:') === 0) evt = line.slice(6).trim();
else if (line.indexOf('data:') === 0) data += line.slice(5).trim();
}
function pump() {
return reader.read().then(function (r) {
if (r.done) {
buf += decoder.decode(); // 冲掉解码器里残留的字节
buf.split('\n').forEach(function (l) { onLine(l.replace(/\r$/, '')); });
return;
}
// stream:true 让 TextDecoder 自己处理「多字节汉字被 chunk 从中间切断」
buf += decoder.decode(r.value, { stream: true });
var lines = buf.split('\n');
buf = lines.pop(); // 最后一段可能是半行,留到下一轮
lines.forEach(function (l) { onLine(l.replace(/\r$/, '')); });
return pump();
});
}
return pump();
}).catch(function (e) {
aiFail(answerEl, e.message || 'AI 连接中断');
}).then(function () {
ai.streaming = false;
aiSetTool('');
aiUpdateComposer();
// 新会话的标题、老会话的「最近活跃」都会变,刷新一下左栏
aiLoadSessions(false);
});
}
function aiHandleEvent(name, data, answerEl) {
var obj;
try { obj = JSON.parse(data); } catch (e) { return; }
if (name === 'delta') {
answerEl._text += (obj.content || '');
answerEl.querySelector('.ai-bubble-body').textContent = answerEl._text;
aiScrollBottom();
} else if (name === 'tool') {
aiSetTool(aiToolLabel(obj.name));
} else if (name === 'done') {
// 新会话时后端在这一刻才把 ID 给它,必须记下来,否则下一条又会开新会话
if (obj.sessionId) ai.sessionId = obj.sessionId;
aiSetTool('');
aiFinish(answerEl);
} else if (name === 'error') {
aiFail(answerEl, obj.message);
}
}
function aiFinish(el) {
el.classList.remove('streaming');
if (el.classList.contains('failed')) return;
aiRenderMarkdown(el.querySelector('.ai-bubble-body'), el._text || '');
setTimeout(aiScrollBottom, 60); // 等 Markdown 渲染完高度变化后再贴底
}
function aiFail(el, msg) {
el.classList.remove('streaming');
el.classList.add('failed');
var body = el.querySelector('.ai-bubble-body');
body.textContent = (el._text ? el._text + '\n\n' : '') + '⚠ ' + msg;
aiScrollBottom();
}
function aiSetTool(label) {
var el = $('ai-status');
if (!label) { el.style.display = 'none'; el.textContent = ''; return; }
el.style.display = '';
el.textContent = label;
}
function aiToolLabel(name) {
var map = {
query_bills: '正在查账单…',
query_notes: '正在搜笔记…',
get_note: '正在读笔记…',
create_bill: '正在记账…',
create_note: '正在记笔记…',
web_search: '正在联网搜索…'
};
return map[name] || '正在处理…';
}
function aiUpdateComposer() {
var disabled = ai.streaming || !ai.ready;
$('ai-send').disabled = disabled;
$('ai-input').disabled = ai.streaming;
$('ai-send').textContent = ai.streaming ? '生成中…' : '发送';
}
function aiAutoGrow() {
var el = $('ai-input');
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 160) + 'px';
}
// ================================================================ 财务管理
//
// 纯展示:月度汇总 / 预算进度 / 分类占比 / 账单明细。
// 数据全部来自现成接口,后端一行没动:
// /statistics/monthly 月度汇总 + 分类聚合
// /bills 账单明细(按月取,类型和分类的筛选在本地做,切换零延迟)
// /budget /account 预算进度、账户余额
var fin = {
loaded: false,
year: 0, month: 0,
type: '', // '' 全部 / '1' 支出 / '2' 收入
categoryId: '',
categories: [],
bills: [],
stats: null,
budget: null,
account: null
};
function num(v) { var n = parseFloat(v); return isNaN(n) ? 0 : n; }
function pad2(n) { return n < 10 ? '0' + n : '' + n; }
/** 金额:两位小数 + 千分位 */
function money(v) {
var parts = num(v).toFixed(2).split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return parts.join('.');
}
function finEnter() {
if (fin.loaded) return;
fin.loaded = true;
var now = new Date();
fin.year = now.getFullYear();
fin.month = now.getMonth() + 1;
request('/categories').then(function (list) {
fin.categories = list || [];
finRenderCategoryOptions();
}).catch(function () { /* 分类拉不到不影响主流程,筛选下拉空着而已 */ });
finLoad();
}
function finIsCurrentMonth() {
var now = new Date();
return fin.year === now.getFullYear() && fin.month === now.getMonth() + 1;
}
function finLoad() {
var prefix = fin.year + '-' + pad2(fin.month) + '-';
// 下个月 1 号往前一天 = 本月最后一天,省得自己判闰年
var lastDay = new Date(fin.year, fin.month, 0).getDate();
$('fin-summary').innerHTML = '';
$('fin-cats').innerHTML = '';
$('fin-note').textContent = '';
$('fin-bills').innerHTML = '<div class="empty">加载中…</div>';
var tasks = [
request('/statistics/monthly?year=' + fin.year + '&month=' + fin.month),
request('/bills?startDate=' + prefix + '01&endDate=' + prefix + pad2(lastDay)),
// 预算接口只提供「当月」,看历史月份就别请求了,免得显示错月份的数
finIsCurrentMonth() ? request('/budget').catch(function () { return null; }) : Promise.resolve(null),
request('/account').catch(function () { return null; })
];
Promise.all(tasks).then(function (r) {
fin.stats = r[0] || null;
fin.bills = r[1] || [];
fin.budget = r[2];
fin.account = r[3];
finRender();
}).catch(function (e) {
$('fin-bills').innerHTML = '<div class="empty">' + esc(e.message) + '</div>';
toast(e.message, true);
});
}
function finRender() {
$('fin-month').textContent = fin.year + '年' + fin.month + '月';
$('fin-next').disabled = finIsCurrentMonth(); // 不允许翻到未来
finRenderSummary();
finRenderCats();
finRenderBills();
}
// ---------------------------------------------------------------- 侧栏:概览
function finRenderSummary() {
var s = fin.stats || {};
var html =
finStatRow('收入', s.totalIncome, 'income') +
finStatRow('支出', s.totalExpense, 'expense') +
finStatRow('结余', s.balance, 'balance');
if (fin.account) {
html += '<div class="fin-divider"></div>' +
finStatRow('账户余额', fin.account.balance, 'balance') +
finStatRow('累计收入', fin.account.totalIncome, 'income') +
finStatRow('累计支出', fin.account.totalExpense, 'expense');
}
var b = fin.budget;
if (b && num(b.amount) > 0) {
var total = num(b.amount), used = num(b.usedAmount);
var pct = Math.min(100, Math.round(used / total * 100));
html += '<div class="fin-divider"></div>' +
'<div class="fin-budget">' +
'<div class="fin-budget-head"><span>本月预算</span><b>' + pct + '%</b></div>' +
'<div class="fin-bar"><i style="width:' + pct + '%"></i></div>' +
'<div class="fin-budget-foot">已用 ¥' + money(used) + ' / ¥' + money(total) + '</div>' +
'<div class="fin-budget-foot">剩余 ¥' + money(b.remainingAmount) +
' · 日均可用 ¥' + money(b.remainingDaily) + '</div>' +
'</div>';
} else if (finIsCurrentMonth()) {
html += '<div class="fin-divider"></div>' +
'<div class="fin-budget-foot" style="padding:0 6px">本月还没设预算</div>';
}
$('fin-summary').innerHTML = html;
}
function finStatRow(label, value, cls) {
var v = num(value);
// 负数写成 -¥134.00 而不是 ¥-134.00
var text = (v < 0 ? '-¥' : '¥') + money(Math.abs(v));
return '<div class="fin-stat"><span class="k">' + label + '</span>' +
'<span class="v ' + cls + '">' + text + '</span></div>';
}
// ---------------------------------------------------------------- 侧栏:分类
function finRenderCats() {
var all = (fin.stats && fin.stats.categoryStatistics) || [];
// 「全部」时默认看支出分类;切到「只看收入」就换成收入分类
var wantType = fin.type === '2' ? 2 : 1;
var list = all.filter(function (c) { return c.type === wantType; })
.sort(function (a, b) { return num(b.amount) - num(a.amount); });
var box = $('fin-cats');
if (!list.length) {
box.innerHTML = '<div class="fin-budget-foot" style="padding:0 6px">暂无</div>';
return;
}
var max = num(list[0].amount) || 1;
box.innerHTML = list.map(function (c) {
var active = fin.categoryId !== '' && String(fin.categoryId) === String(c.categoryId);
return '<div class="fin-cat' + (active ? ' active' : '') + '" data-cat="' + c.categoryId + '">' +
'<div class="fin-cat-row">' +
'<span class="n">' + iconImg(c.categoryIcon, 16) + ' ' + esc(c.categoryName) + '</span>' +
'<b>¥' + money(c.amount) + '</b>' +
'</div>' +
'<div class="fin-bar"><i style="width:' + Math.round(num(c.amount) / max * 100) + '%"></i></div>' +
'</div>';
}).join('');
Array.prototype.forEach.call(box.querySelectorAll('.fin-cat'), function (el) {
el.onclick = function () { finToggleCategory(el.getAttribute('data-cat')); };
});
}
function finRenderCategoryOptions() {
var html = '<option value="">全部分类</option>';
// 支出在前收入在后,和 App 里的顺序一致
[1, 2].forEach(function (t) {
fin.categories.filter(function (c) { return c.type === t; })
.forEach(function (c) {
html += '<option value="' + c.id + '">' +
(t === 2 ? '(收入)' : '') + esc(c.name) + '</option>';
});
});
$('fin-cat').innerHTML = html;
$('fin-cat').value = fin.categoryId;
}
function finSetCategory(id) {
fin.categoryId = id ? String(id) : '';
$('fin-cat').value = fin.categoryId;
finRenderCats();
finRenderBills();
}
function finToggleCategory(id) {
finSetCategory(String(fin.categoryId) === String(id) ? '' : id);
}
// ---------------------------------------------------------------- 账单明细
function finFiltered() {
return fin.bills.filter(function (b) {
if (fin.type && String(b.type) !== fin.type) return false;
if (fin.categoryId && String(b.categoryId) !== String(fin.categoryId)) return false;
return true;
});
}
function finRenderBills() {
var list = finFiltered();
var isFiltered = !!(fin.type || fin.categoryId);
$('fin-note').textContent = list.length + ' 笔' +
(isFiltered ? '(本月共 ' + fin.bills.length + ' 笔)' : '');
var box = $('fin-bills');
if (!list.length) {
box.innerHTML = '<div class="empty">' +
(fin.bills.length ? '没有符合筛选条件的账单' : '这个月还没有账单') + '</div>';
return;
}
// 按日期分组。后端本来就按日期倒序返回,这里保持顺序即可
var groups = [], index = {};
list.forEach(function (b) {
var d = String(b.billDate || '').substring(0, 10);
if (!index[d]) {
index[d] = { date: d, items: [], out: 0, income: 0 };
groups.push(index[d]);
}
index[d].items.push(b);
if (b.type === 2) index[d].income += num(b.amount);
else index[d].out += num(b.amount);
});
box.innerHTML = groups.map(function (g) {
var head = '<div class="fin-day"><span class="d">' + finDayLabel(g.date) + '</span>';
if (g.out) head += '<span class="sum out">支 ¥' + money(g.out) + '</span>';
if (g.income) head += '<span class="sum in">收 ¥' + money(g.income) + '</span>';
head += '</div>';
return head + g.items.map(finBillRow).join('');
}).join('');
}
function finBillRow(b) {
var out = b.type !== 2;
return '<div class="fin-bill">' +
'<span class="ico">' + iconImg(b.categoryIcon, 22) + '</span>' +
'<span class="cat">' + esc(b.categoryName || '') + '</span>' +
'<span class="desc">' + esc(b.description || '') + '</span>' +
'<span class="amt ' + (out ? 'out' : 'in') + '">' +
(out ? '-' : '+') + '¥' + money(b.amount) +
'</span>' +
'</div>';
}
/** 今天/昨天/周几 —— 只看日期串没感觉,加上这层更容易定位 */
function finDayLabel(date) {
if (!date) return '';
var now = new Date();
var fmt = function (d) {
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
};
if (date === fmt(now)) return '今天 · ' + date.substring(5);
if (date === fmt(new Date(now.getTime() - 86400000))) return '昨天 · ' + date.substring(5);
// 用 '/' 分隔才是本地时区解析,'-' 会被当成 UTC,跨时区会差一天
var week = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][
new Date(date.replace(/-/g, '/')).getDay()
];
return date.substring(5) + ' ' + week;
}
function finShiftMonth(delta) {
var m = fin.month + delta, y = fin.year;
if (m < 1) { m = 12; y--; }
if (m > 12) { m = 1; y++; }
fin.year = y;
fin.month = m;
fin.categoryId = '';
$('fin-cat').value = '';
finLoad();
}
// ---------------------------------------------------------------- 刷新
function refreshAll() {
return Promise.all([loadTree(), loadTags()]).then(function () {
@@ -1236,6 +1884,7 @@
// ---------------------------------------------------------------- 事件绑定
$('login-btn').onclick = doLogin;
$('login-eye').onclick = togglePassword;
$('login-password').onkeydown = function (e) { if (e.key === 'Enter') doLogin(); };
$('login-username').onkeydown = function (e) { if (e.key === 'Enter') $('login-password').focus(); };
$('logout-btn').onclick = doLogout;
@@ -1308,6 +1957,34 @@
// 点空白处收起右键菜单
document.addEventListener('scroll', closeContextMenu, true);
// 左侧模块菜单
document.querySelectorAll('.module-item').forEach(function (el) {
el.onclick = function () { switchModule(el.dataset.module); };
});
// AI 助手
$('ai-new').onclick = aiNewChat;
$('ai-send').onclick = aiSend;
$('ai-input').oninput = aiAutoGrow;
$('ai-input').onkeydown = function (e) {
// isComposing:中文输入法候选框里按回车是「上屏」,不能当成发送
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
aiSend();
}
};
aiUpdateComposer();
// 财务管理
$('fin-prev').onclick = function () { finShiftMonth(-1); };
$('fin-next').onclick = function () { finShiftMonth(1); };
$('fin-type').onchange = function () {
fin.type = this.value;
finRenderCats();
finRenderBills();
};
$('fin-cat').onchange = function () { finSetCategory(this.value); };
// ---------------------------------------------------------------- 启动
if (state.token) {
request('/notes?page=1&size=1')
+89
View File
@@ -0,0 +1,89 @@
/* 由 tools/make_icons.py 生成,不要手工编辑 —— 改图标请改脚本再重跑 */
window.CUTE_ICONS = {
fallback: 'other',
names: [
'food',
'transport',
'shopping',
'entertainment',
'medical',
'education',
'housing',
'utilities',
'phone',
'other',
'salary',
'bonus',
'invest',
'parttime',
'bill',
'note',
'assistant',
'expense',
'income',
'camera',
'search',
'user',
'lock',
'eye',
'image',
'file',
'folder',
'folder-open',
'check',
'pin',
'target',
'inbox',
'error',
],
aliases: {
'☎': 'phone',
'☕': 'food',
'⚡': 'utilities',
'✈': 'transport',
'✏': 'education',
'❓': 'other',
'🍔': 'food',
'🍕': 'food',
'🍚': 'food',
'🍜': 'food',
'🎁': 'bonus',
'🎓': 'education',
'🎬': 'entertainment',
'🎮': 'entertainment',
'🎵': 'entertainment',
'🏠': 'housing',
'🏡': 'housing',
'🏥': 'medical',
'🏦': 'salary',
'🏷': 'other',
'👕': 'shopping',
'💉': 'medical',
'💊': 'medical',
'💡': 'utilities',
'💧': 'utilities',
'💰': 'salary',
'💴': 'salary',
'💵': 'salary',
'💹': 'invest',
'💻': 'phone',
'💼': 'parttime',
'📈': 'invest',
'📊': 'invest',
'📌': 'other',
'📖': 'education',
'📚': 'education',
'📦': 'other',
'📱': 'phone',
'🔌': 'utilities',
'🔧': 'parttime',
'🚇': 'transport',
'🚌': 'transport',
'🚗': 'transport',
'🛍': 'shopping',
'🛏': 'housing',
'🛒': 'shopping',
'🧧': 'bonus',
'🪙': 'invest',
},
};
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#D9A93C" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2.5C12.6 7.8 16.2 11.4 21.5 12 16.2 12.6 12.6 16.2 12 21.55.8 16.2 9.4 12.6 4.2 12 9.4 11.4 12.6 7.8 12 2.5z" fill="#F8ECD2"/>
<path d="M18.4 15.6l.9 2.3 2.3.9-2.3.9-.9 2.3-.9-2.3-2.3-.9 2.3-.9.9-2.3z" fill="#F8ECD2"/>
</svg>

After

Width:  |  Height:  |  Size: 426 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#C8956E" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M5.8 6.6h12.399999999999999a3 3 0 0 1 3 3v6.4a3 3 0 0 1 -3 3h-12.399999999999999a3 3 0 0 1 -3 -3v-6.4a3 3 0 0 1 3 -3z" fill="#F6E8DB"/>
<path d="M2.8 11.4h18.4" fill="none"/>
<path d="M14.899999999999999 15a1.5 1.5 0 1 0 3.0 0a1.5 1.5 0 1 0 -3.0 0z" fill="#F6E8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 474 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#D99C3C" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M5.6 10h12.799999999999999a2.2 2.2 0 0 1 2.2 2.2v5.799999999999999a2.2 2.2 0 0 1 -2.2 2.2h-12.799999999999999a2.2 2.2 0 0 1 -2.2 -2.2v-5.799999999999999a2.2 2.2 0 0 1 2.2 -2.2z" fill="#F9EDD5"/>
<path d="M4.0 6.2h16.0a1.6 1.6 0 0 1 1.6 1.6v0.7999999999999998a1.6 1.6 0 0 1 -1.6 1.6h-16.0a1.6 1.6 0 0 1 -1.6 -1.6v-0.7999999999999998a1.6 1.6 0 0 1 1.6 -1.6z" fill="#F9EDD5"/>
<path d="M12 6.2v14" fill="none"/>
<path d="M12 6.2C10 3.4 6.6 4.2 7.6 6.6" fill="none"/>
<path d="M12 6.2c2-2.8 5.4-2 4.4.4" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 729 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 6.8h12a3 3 0 0 1 3 3v7.4a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-7.4a3 3 0 0 1 3 -3z" fill="#F1E7E0"/>
<path d="M8.2 13.4a3.8 3.8 0 1 0 7.6 0a3.8 3.8 0 1 0 -7.6 0z" fill="none"/>
<path d="M9 6.8l1.3-2.4h3.4L15 6.8" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 435 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M3.4000000000000004 12a8.6 8.6 0 1 0 17.2 0a8.6 8.6 0 1 0 -17.2 0z" fill="#F1E7E0"/>
<path d="M8 12.3l2.9 2.9 5.2-6" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 334 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6B8E4E" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 4.4l9 4.6-9 4.6-9-4.6 9-4.6z" fill="#E4EDDA"/>
<path d="M20.6 9.2v5.4" fill="none"/>
<path d="M19.3 16.2a1.3 1.3 0 1 0 2.6 0a1.3 1.3 0 1 0 -2.6 0z" fill="#E4EDDA"/>
</svg>

After

Width:  |  Height:  |  Size: 376 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#9B7BC4" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M5.7 9.6h12.600000000000001a2.5 2.5 0 0 1 2.5 2.5v5.4a2.5 2.5 0 0 1 -2.5 2.5h-12.600000000000001a2.5 2.5 0 0 1 -2.5 -2.5v-5.4a2.5 2.5 0 0 1 2.5 -2.5z" fill="#EBE3F5"/>
<path d="M3.2 9.6h17.6" fill="none"/>
<path d="M7 6.2l-1.6 3.4M12 6.2l-1.6 3.4M17 6.2l-1.6 3.4" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 485 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M3.4000000000000004 12a8.6 8.6 0 1 0 17.2 0a8.6 8.6 0 1 0 -17.2 0z" fill="#F1E7E0"/>
<path d="M12 7.6v5.2" fill="none"/>
<path d="M10.85 16.4a1.15 1.15 0 1 0 2.3 0a1.15 1.15 0 1 0 -2.3 0z" fill="#F1E7E0"/>
</svg>

After

Width:  |  Height:  |  Size: 413 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 13.6v3.4a2.6 2.6 0 0 0 2.6 2.6h10.8a2.6 2.6 0 0 0 2.6-2.6v-3.4H15.4a3.4 3.4 0 0 1-6.8 0H4z" fill="#F1E7E0"/>
<path d="M12 14.6V4.4M7.8 8.6L12 4.4l4.2 4.2" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 375 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.6 12C5.2 7.6 8.6 5.6 12 5.6s6.8 2 9.4 6.4C18.8 16.4 15.4 18.4 12 18.4S5.2 16.4 2.6 12z" fill="#F1E7E0"/>
<path d="M9.1 12a2.9 2.9 0 1 0 5.8 0a2.9 2.9 0 1 0 -5.8 0z" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 385 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 3h7.4L19 8.6V19a2.2 2.2 0 0 1-2.2 2.2H6A2.2 2.2 0 0 1 3.8 19V5.2A2.2 2.2 0 0 1 6 3z" fill="#F1E7E0"/>
<path d="M13.4 3v5.6H19" fill="none"/>
<path d="M7.6 13h8.8M7.6 16.6h5.4" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 400 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M3.4 17.6V7.4a2.2 2.2 0 0 1 2.2-2.2h3.6a2.2 2.2 0 0 1 1.8.9l.9 1.3h6.5a2.2 2.2 0 0 1 2.2 2.2v1.2" fill="none"/>
<path d="M3.4 18.4l2.3-7h15.1l-2.3 7a2.2 2.2 0 0 1-2.1 1.6H5.5a2.2 2.2 0 0 1-2.1-1.6z" fill="#F1E7E0"/>
</svg>

After

Width:  |  Height:  |  Size: 419 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M3.4 7.4a2.2 2.2 0 0 1 2.2-2.2h3.6a2.2 2.2 0 0 1 1.8.9l.9 1.3h8.5a2.2 2.2 0 0 1 2.2 2.2v8.6a2.2 2.2 0 0 1-2.2 2.2H5.6a2.2 2.2 0 0 1-2.2-2.2z" fill="#F1E7E0"/>
</svg>

After

Width:  |  Height:  |  Size: 358 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#E2914B" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 11h16a8 8 0 0 1-16 0z" fill="#FBE7D0"/>
<path d="M9.2 8.2c1-1.4-.9-2.6.1-4" fill="none"/>
<path d="M13.6 8.2c1-1.4-.9-2.6.1-4" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 351 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#B5794F" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M3.8 11.4L12 4.6l8.2 6.8V19a2.2 2.2 0 0 1-2.2 2.2H6a2.2 2.2 0 0 1-2.2-2.2z" fill="#F0E2D6"/>
<path d="M11.4 14h1.2000000000000002a1.6 1.6 0 0 1 1.6 1.6v4.0a1.6 1.6 0 0 1 -1.6 1.6h-1.2000000000000002a1.6 1.6 0 0 1 -1.6 -1.6v-4.0a1.6 1.6 0 0 1 1.6 -1.6z" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 470 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 4.8h12a3 3 0 0 1 3 3v8.4a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-8.4a3 3 0 0 1 3 -3z" fill="#F1E7E0"/>
<path d="M4.4 16.6l4.6-5 3.4 3.8 3.2-3.4 4.6 4.8" fill="none"/>
<path d="M7.1 9.4a1.5 1.5 0 1 0 3.0 0a1.5 1.5 0 1 0 -3.0 0z" fill="#F1E7E0"/>
</svg>

After

Width:  |  Height:  |  Size: 451 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M5.6 4.6h12.8l2.6 8.2v4.4a2.2 2.2 0 0 1-2.2 2.2H5.2A2.2 2.2 0 0 1 3 17.2v-4.4z" fill="#F1E7E0"/>
<path d="M3 12.8h4.6l1.4 2.6h6l1.4-2.6H21" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 357 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 13.6v3.4a2.6 2.6 0 0 0 2.6 2.6h10.8a2.6 2.6 0 0 0 2.6-2.6v-3.4H15.4a3.4 3.4 0 0 1-6.8 0H4z" fill="#F1E7E0"/>
<path d="M12 4.4v10.2M7.8 10.4L12 14.6l4.2-4.2" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 377 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#4FA98A" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M4.8 14.4h0.40000000000000036a1.4 1.4 0 0 1 1.4 1.4v3.2a1.4 1.4 0 0 1 -1.4 1.4h-0.40000000000000036a1.4 1.4 0 0 1 -1.4 -1.4v-3.2a1.4 1.4 0 0 1 1.4 -1.4z" fill="#DBEFE8"/>
<path d="M11.8 11.4h0.40000000000000036a1.4 1.4 0 0 1 1.4 1.4v6.2a1.4 1.4 0 0 1 -1.4 1.4h-0.40000000000000036a1.4 1.4 0 0 1 -1.4 -1.4v-6.2a1.4 1.4 0 0 1 1.4 -1.4z" fill="#DBEFE8"/>
<path d="M18.799999999999997 8.4h0.40000000000000036a1.4 1.4 0 0 1 1.4 1.4v9.2a1.4 1.4 0 0 1 -1.4 1.4h-0.40000000000000036a1.4 1.4 0 0 1 -1.4 -1.4v-9.2a1.4 1.4 0 0 1 1.4 -1.4z" fill="#DBEFE8"/>
<path d="M4 11.6l5-4 3.4 2.8L19.6 5" fill="none"/>
<path d="M16 5h3.6v3.4" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 850 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M7.0 10.4h10.0a2.6 2.6 0 0 1 2.6 2.6v4.999999999999999a2.6 2.6 0 0 1 -2.6 2.6h-10.0a2.6 2.6 0 0 1 -2.6 -2.6v-4.999999999999999a2.6 2.6 0 0 1 2.6 -2.6z" fill="#F1E7E0"/>
<path d="M8.4 10.4V7.8a3.6 3.6 0 0 1 7.2 0v2.6" fill="none"/>
<path d="M10.8 15.4a1.2 1.2 0 1 0 2.4 0a1.2 1.2 0 1 0 -2.4 0z" fill="#F1E7E0"/>
</svg>

After

Width:  |  Height:  |  Size: 518 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#E0725C" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M8.6 3.6h6.800000000000001a5 5 0 0 1 5 5v6.800000000000001a5 5 0 0 1 -5 5h-6.800000000000001a5 5 0 0 1 -5 -5v-6.800000000000001a5 5 0 0 1 5 -5z" fill="#FAE2DD"/>
<path d="M12 8.4v7.2M8.4 12h7.2" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 412 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#C8956E" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M7.0 3.4h10.0a2.6 2.6 0 0 1 2.6 2.6v12.0a2.6 2.6 0 0 1 -2.6 2.6h-10.0a2.6 2.6 0 0 1 -2.6 -2.6v-12.0a2.6 2.6 0 0 1 2.6 -2.6z" fill="#F6E8DB"/>
<path d="M8.6 3.4v17.2" fill="none"/>
<path d="M11.8 9h5M11.8 12.6h5" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 433 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#9A8C80" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M6.0 6.8h12.0a2.6 2.6 0 0 1 2.6 2.6v8.2a2.6 2.6 0 0 1 -2.6 2.6h-12.0a2.6 2.6 0 0 1 -2.6 -2.6v-8.2a2.6 2.6 0 0 1 2.6 -2.6z" fill="#EBE5E0"/>
<path d="M3.4 11.4h17.2" fill="none"/>
<path d="M12 6.8v4.6" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 422 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8A7BB8" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M5.6 8.4h12.8a2.6 2.6 0 0 1 2.6 2.6v6.999999999999999a2.6 2.6 0 0 1 -2.6 2.6h-12.8a2.6 2.6 0 0 1 -2.6 -2.6v-6.999999999999999a2.6 2.6 0 0 1 2.6 -2.6z" fill="#E8E3F4"/>
<path d="M9 8.4V7a1.6 1.6 0 0 1 1.6-1.6h2.8A1.6 1.6 0 0 1 15 7v1.4" fill="none"/>
<path d="M3 13.2h18" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 492 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#4FA0B0" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.4 2.8h5.199999999999999a3 3 0 0 1 3 3v12.399999999999999a3 3 0 0 1 -3 3h-5.199999999999999a3 3 0 0 1 -3 -3v-12.399999999999999a3 3 0 0 1 3 -3z" fill="#DBEDF1"/>
<path d="M10.6 5.6h2.8" fill="none"/>
<path d="M11 18.4h2" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 444 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M8.8 3.2h6.4l-1.1 5.4 3 2.9v1.6H6.9v-1.6l3-2.9z" fill="#F1E7E0"/>
<path d="M12 13.1v7.6" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 306 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#C8956E" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M5.0 6.4h14.0a2.6 2.6 0 0 1 2.6 2.6v5.999999999999999a2.6 2.6 0 0 1 -2.6 2.6h-14.0a2.6 2.6 0 0 1 -2.6 -2.6v-5.999999999999999a2.6 2.6 0 0 1 2.6 -2.6z" fill="#F6E8DB"/>
<path d="M8.9 12a3.1 3.1 0 1 0 6.2 0a3.1 3.1 0 1 0 -6.2 0z" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 445 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M4.0 10.4a6.4 6.4 0 1 0 12.8 0a6.4 6.4 0 1 0 -12.8 0z" fill="#F1E7E0"/>
<path d="M15.2 15.2L20.6 20.6" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 320 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#D97C93" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M7.8 8.6h8.4a3 3 0 0 1 3 3v5.800000000000001a3 3 0 0 1 -3 3h-8.4a3 3 0 0 1 -3 -3v-5.800000000000001a3 3 0 0 1 3 -3z" fill="#F7E2E8"/>
<path d="M9 8.6V7a3 3 0 0 1 6 0v1.6" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 388 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M3.4000000000000004 12a8.6 8.6 0 1 0 17.2 0a8.6 8.6 0 1 0 -17.2 0z" fill="#F1E7E0"/>
<path d="M7.4 12a4.6 4.6 0 1 0 9.2 0a4.6 4.6 0 1 0 -9.2 0z" fill="none"/>
<path d="M10.5 12a1.5 1.5 0 1 0 3.0 0a1.5 1.5 0 1 0 -3.0 0z" fill="#F1E7E0"/>
</svg>

After

Width:  |  Height:  |  Size: 444 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#5B8DB8" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.6 4.8h4.8a2.6 2.6 0 0 1 2.6 2.6v0.20000000000000018a2.6 2.6 0 0 1 -2.6 2.6h-4.8a2.6 2.6 0 0 1 -2.6 -2.6v-0.20000000000000018a2.6 2.6 0 0 1 2.6 -2.6z" fill="#DCE8F2"/>
<path d="M6.2 9.4h11.600000000000001a3 3 0 0 1 3 3v1.5999999999999996a3 3 0 0 1 -3 3h-11.600000000000001a3 3 0 0 1 -3 -3v-1.5999999999999996a3 3 0 0 1 3 -3z" fill="#DCE8F2"/>
<path d="M6.1 17.2a1.9 1.9 0 1 0 3.8 0a1.9 1.9 0 1 0 -3.8 0z" fill="#DCE8F2"/>
<path d="M14.1 17.2a1.9 1.9 0 1 0 3.8 0a1.9 1.9 0 1 0 -3.8 0z" fill="#DCE8F2"/>
</svg>

After

Width:  |  Height:  |  Size: 715 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#8D6E63" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M8.1 8.4a3.9 3.9 0 1 0 7.8 0a3.9 3.9 0 1 0 -7.8 0z" fill="#F1E7E0"/>
<path d="M4.8 20.4c0-4 3.2-6.6 7.2-6.6s7.2 2.6 7.2 6.6z" fill="#F1E7E0"/>
</svg>

After

Width:  |  Height:  |  Size: 346 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#D9A93C" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2.8a6.4 6.4 0 0 0-3.7 11.6c.8.6 1.3 1.4 1.3 2.3v.6h4.8v-.6c0-.9.5-1.7 1.3-2.3A6.4 6.4 0 0 0 12 2.8z" fill="#F8ECD2"/>
<path d="M9.8 20.2h4.4M10.8 22.2h2.4" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 377 B

+124 -15
View File
@@ -9,20 +9,46 @@
</head>
<body>
<!-- ==================== 登录 ==================== -->
<!-- ==================== 登录 ====================
视觉对齐手机端登录页:暖橙渐变底 + 漂浮圆球 + 圆形熊头 + 金色装饰线标题
+ 毛玻璃卡片 + emoji 前缀输入框。改这里之前先看 App 的 login_page.dart。
-->
<div id="login-view">
<div class="login-box">
<h1>强宝小助手</h1>
<p class="sub">强宝管理小助手</p>
<div class="field">
<label>用户名</label>
<input id="login-username" autocomplete="username" placeholder="用户名">
<!-- 背景装饰圆球(手机端是带缓动的,这里用 CSS 动画近似) -->
<div class="login-balls" aria-hidden="true">
<span class="ball b1"></span>
<span class="ball b2"></span>
<span class="ball b3"></span>
</div>
<div class="login-wrap">
<div class="login-header">
<div class="login-logo">
<img src="/admin/logo.png" alt="强宝小助手">
</div>
<h1 class="login-title">
<i class="deco"></i><span>强宝小助手</span><i class="deco"></i>
</h1>
<p class="login-sub">智能记账,轻松管理</p>
</div>
<div class="field">
<label>密码</label>
<input id="login-password" type="password" autocomplete="current-password" placeholder="密码">
<div class="login-box">
<div class="login-field">
<span class="ico"><img class="ci" src="/admin/icons/user.svg" width="18" height="18" alt=""></span>
<input id="login-username" autocomplete="username" maxlength="20" placeholder="请输入用户名">
</div>
<div class="login-field">
<span class="ico"><img class="ci" src="/admin/icons/lock.svg" width="18" height="18" alt=""></span>
<input id="login-password" type="password" autocomplete="current-password" maxlength="20" placeholder="请输入密码">
<button type="button" class="eye" id="login-eye" title="显示密码">
<img class="ci" src="/admin/icons/eye.svg" width="18" height="18" alt="">
</button>
</div>
<button class="login-btn" id="login-btn">登 录</button>
</div>
<button class="primary" id="login-btn">登录</button>
</div>
</div>
@@ -35,7 +61,7 @@
<div id="app-view" style="display:none">
<header class="topbar">
<div class="brand"><span class="brand-icon">🐻</span>强宝小助手</div>
<div class="brand"><img class="brand-logo" src="/admin/logo.png" alt="">强宝小助手</div>
<div class="spacer"></div>
<span class="who" id="who"></span>
<button id="logout-btn">退出</button>
@@ -45,13 +71,23 @@
<nav class="module-nav">
<button class="module-item active" data-module="notes">
<span class="mi-icon">📝</span>
<span class="mi-icon"><img class="ci" src="/admin/icons/note.svg" width="18" height="18" alt=""></span>
<span class="mi-text">笔记管理</span>
</button>
<!-- 以后的模块(Agent、数据看板…)往这里加 -->
<button class="module-item" data-module="finance">
<span class="mi-icon"><img class="ci" src="/admin/icons/bill.svg" width="18" height="18" alt=""></span>
<span class="mi-text">财务管理</span>
</button>
<button class="module-item" data-module="ai">
<span class="mi-icon"><img class="ci" src="/admin/icons/assistant.svg" width="18" height="18" alt=""></span>
<span class="mi-text">AI 助手</span>
</button>
<!-- 以后的模块(数据看板…)往这里加:这里加一个 button,
下面加一个 data-module-panel 的 section,再在 admin.js 的
switchModule 里按需做初始化就行 -->
</nav>
<section class="module-panel">
<section class="module-panel" data-module-panel="notes">
<div class="toolbar">
<input id="search" placeholder="搜索标题和正文…">
@@ -93,6 +129,77 @@
</div>
</section>
<!-- ==================== 财务管理模块 ====================
纯展示:月度汇总 / 预算进度 / 分类占比 / 账单明细。
记账和改账在 App 上做,这里只读 —— 数据全部来自现成接口
(/statistics/monthly、/bills、/budget、/account)。 -->
<section class="module-panel" data-module-panel="finance" style="display:none">
<div class="toolbar">
<button class="icon-btn" id="fin-prev" title="上一月">‹</button>
<span class="fin-month" id="fin-month">—</span>
<button class="icon-btn" id="fin-next" title="下一月">›</button>
<div class="toolbar-sep"></div>
<select id="fin-type" title="按类型筛选">
<option value="">全部类型</option>
<option value="1">只看支出</option>
<option value="2">只看收入</option>
</select>
<select id="fin-cat" title="按分类筛选"><option value="">全部分类</option></select>
<div class="spacer"></div>
<span class="toolbar-note" id="fin-note"></span>
</div>
<div class="module-body">
<aside class="sidebar">
<div class="side-title">本月概览</div>
<div id="fin-summary"></div>
<div class="side-divider"></div>
<div class="side-title">分类(点一下筛选)</div>
<div id="fin-cats"></div>
</aside>
<main class="content">
<div id="fin-bills"></div>
</main>
</div>
</section>
<!-- ==================== AI 助手模块 ====================
和 App 端共用同一套后端接口(/api/ai/**),历史存在服务端的
chat_session / chat_message 里 —— 所以两边看到的会话是同一批。 -->
<section class="module-panel" data-module-panel="ai" style="display:none">
<div class="toolbar">
<span class="toolbar-title">AI 助手</span>
<span class="toolbar-note" id="ai-hint"></span>
<div class="spacer"></div>
<button id="ai-new">+ 新对话</button>
</div>
<div class="module-body">
<aside class="sidebar">
<div class="side-title">历史对话</div>
<div id="ai-session-list"></div>
</aside>
<main class="ai-main">
<div id="ai-messages" class="ai-messages"></div>
<!-- 工具调用提示(「正在查账单…」),没有在查东西时整行隐藏 -->
<div class="ai-status" id="ai-status" style="display:none"></div>
<div class="ai-composer">
<textarea id="ai-input" rows="1"
placeholder="问点什么,或者说一句…(Enter 发送,Shift+Enter 换行)"></textarea>
<button class="primary" id="ai-send">发送</button>
</div>
</main>
</div>
</section>
</div>
</div>
@@ -131,6 +238,8 @@
<!-- Vditor:本地托管,不依赖任何外部 CDN -->
<script src="/admin/vendor/vditor/dist/index.min.js"></script>
<!-- 图标名清单 + 兼容映射,由 qiangbao_accounting/tools/make_icons.py 生成 -->
<script src="/admin/icons.js"></script>
<script src="/admin/admin.js"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB