From 2338ff58855078b82e8edf19dc6229734e6072a0 Mon Sep 17 00:00:00 2001 From: handsomeAq Date: Thu, 24 Sep 2026 14:27:12 +0800 Subject: [PATCH] =?UTF-8?q?AI=E6=8E=A5=E5=85=A5=20v1.0=20UI=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/SearXNG搭建指南.md | 467 ++++++++++++ .../java/com/accounting/config/AiConfig.java | 33 +- .../com/accounting/service/AiChatService.java | 109 ++- .../com/accounting/service/AiToolService.java | 244 +++++- .../accounting/service/SearxngApiClient.java | 91 +++ src/main/resources/application.yml | 11 +- .../resources/db/migration_icons_to_names.sql | 54 ++ src/main/resources/db/schema.sql | 33 +- src/main/resources/static/admin/admin.css | 266 ++++++- src/main/resources/static/admin/admin.js | 695 +++++++++++++++++- src/main/resources/static/admin/icons.js | 89 +++ .../static/admin/icons/assistant.svg | 4 + .../resources/static/admin/icons/bill.svg | 5 + .../resources/static/admin/icons/bonus.svg | 7 + .../resources/static/admin/icons/camera.svg | 5 + .../resources/static/admin/icons/check.svg | 4 + .../static/admin/icons/education.svg | 5 + .../static/admin/icons/entertainment.svg | 5 + .../resources/static/admin/icons/error.svg | 5 + .../resources/static/admin/icons/expense.svg | 4 + src/main/resources/static/admin/icons/eye.svg | 4 + .../resources/static/admin/icons/file.svg | 5 + .../static/admin/icons/folder-open.svg | 4 + .../resources/static/admin/icons/folder.svg | 3 + .../resources/static/admin/icons/food.svg | 5 + .../resources/static/admin/icons/housing.svg | 4 + .../resources/static/admin/icons/image.svg | 5 + .../resources/static/admin/icons/inbox.svg | 4 + .../resources/static/admin/icons/income.svg | 4 + .../resources/static/admin/icons/invest.svg | 7 + .../resources/static/admin/icons/lock.svg | 5 + .../resources/static/admin/icons/medical.svg | 4 + .../resources/static/admin/icons/note.svg | 5 + .../resources/static/admin/icons/other.svg | 5 + .../resources/static/admin/icons/parttime.svg | 5 + .../resources/static/admin/icons/phone.svg | 5 + src/main/resources/static/admin/icons/pin.svg | 4 + .../resources/static/admin/icons/salary.svg | 4 + .../resources/static/admin/icons/search.svg | 4 + .../resources/static/admin/icons/shopping.svg | 4 + .../resources/static/admin/icons/target.svg | 5 + .../static/admin/icons/transport.svg | 6 + .../resources/static/admin/icons/user.svg | 4 + .../static/admin/icons/utilities.svg | 4 + src/main/resources/static/admin/index.html | 139 +++- src/main/resources/static/admin/logo.png | Bin 0 -> 138388 bytes 46 files changed, 2301 insertions(+), 83 deletions(-) create mode 100644 docs/SearXNG搭建指南.md create mode 100644 src/main/java/com/accounting/service/SearxngApiClient.java create mode 100644 src/main/resources/db/migration_icons_to_names.sql create mode 100644 src/main/resources/static/admin/icons.js create mode 100644 src/main/resources/static/admin/icons/assistant.svg create mode 100644 src/main/resources/static/admin/icons/bill.svg create mode 100644 src/main/resources/static/admin/icons/bonus.svg create mode 100644 src/main/resources/static/admin/icons/camera.svg create mode 100644 src/main/resources/static/admin/icons/check.svg create mode 100644 src/main/resources/static/admin/icons/education.svg create mode 100644 src/main/resources/static/admin/icons/entertainment.svg create mode 100644 src/main/resources/static/admin/icons/error.svg create mode 100644 src/main/resources/static/admin/icons/expense.svg create mode 100644 src/main/resources/static/admin/icons/eye.svg create mode 100644 src/main/resources/static/admin/icons/file.svg create mode 100644 src/main/resources/static/admin/icons/folder-open.svg create mode 100644 src/main/resources/static/admin/icons/folder.svg create mode 100644 src/main/resources/static/admin/icons/food.svg create mode 100644 src/main/resources/static/admin/icons/housing.svg create mode 100644 src/main/resources/static/admin/icons/image.svg create mode 100644 src/main/resources/static/admin/icons/inbox.svg create mode 100644 src/main/resources/static/admin/icons/income.svg create mode 100644 src/main/resources/static/admin/icons/invest.svg create mode 100644 src/main/resources/static/admin/icons/lock.svg create mode 100644 src/main/resources/static/admin/icons/medical.svg create mode 100644 src/main/resources/static/admin/icons/note.svg create mode 100644 src/main/resources/static/admin/icons/other.svg create mode 100644 src/main/resources/static/admin/icons/parttime.svg create mode 100644 src/main/resources/static/admin/icons/phone.svg create mode 100644 src/main/resources/static/admin/icons/pin.svg create mode 100644 src/main/resources/static/admin/icons/salary.svg create mode 100644 src/main/resources/static/admin/icons/search.svg create mode 100644 src/main/resources/static/admin/icons/shopping.svg create mode 100644 src/main/resources/static/admin/icons/target.svg create mode 100644 src/main/resources/static/admin/icons/transport.svg create mode 100644 src/main/resources/static/admin/icons/user.svg create mode 100644 src/main/resources/static/admin/icons/utilities.svg create mode 100644 src/main/resources/static/admin/logo.png diff --git a/docs/SearXNG搭建指南.md b/docs/SearXNG搭建指南.md new file mode 100644 index 0000000..25f3d0b --- /dev/null +++ b/docs/SearXNG搭建指南.md @@ -0,0 +1,467 @@ +# SearXNG 自建搜索引擎搭建指南 + +> 用途:给 AI 助手提供**联网搜索**能力(后端 `web_search` 工具的后端服务) +> 环境:Docker 29.7.2 / SearXNG `2026.9.23-3cd69d30e` / Ubuntu(腾讯云 231) +> 验证状态:**已上线运行**,实测 14–17 条结果、零异常、约 1.5 秒 + +--- + +## 0. 这套东西解决什么问题 + +大模型自己**不能上网**。你问它"今天的天气"或"Flutter 最新版有什么变化",它只能靠训练数据里的旧信息编——这叫做幻觉。 + +正确做法是给它一个**工具**: + +``` +你提问 → LLM 判断"我需要联网查一下" + → 它让后端调用 web_search("Flutter 3.47 新特性") + → 后端去搜索引擎拿结果 + → 把结果喂回给 LLM + → LLM 基于真实结果回答 +``` + +问题在于**中间那个搜索引擎从哪来**: + +| 方案 | 问题 | +|---|---| +| Google / Bing 官方 API | 要钱、要申请 key、按次计费 | +| 直接爬搜索引擎页面 | 很快被封 IP,还要处理反爬 | +| **SearXNG(本方案)** | 开源自托管、免费、有现成的 JSON 接口 | + +--- + +## 1. 先理解原理(不然后面调优会瞎猜)★ + +**这是全文最重要的一节。** + +SearXNG 自己**不会搜索**,它是**元搜索引擎**(聚合器): + +``` + ┌─→ Google ─┐ +你的查询 ─→ SearXNG ─→ DuckDuckGo ─┼─→ 汇总 / 去重 / 排序 ─→ 返回 + ├─→ Brave ─┤ + └─→ 百度 ─┘ +``` + +**推论(记住这一条,能省你一小时):** + +> SearXNG 的可用性 = 它底下那些引擎在你服务器上**通不通**。 + +所以: +- 装在**国外**服务器上 → 默认配置开箱即用(Google/Bing 都通) +- 装在**国内**服务器上 → **默认配置返回 0 条结果**,因为默认开的全是墙外的 + +这不是 SearXNG 装坏了,是它的引擎连不上。第 4 节专门讲怎么解决。 + +--- + +## 2. 安装 + +### 2.1 前置 + +```bash +docker --version # 需要 Docker +``` + +### 2.2 建配置目录 + +SearXNG 容器里以 **uid 977** 的用户运行,目录权限必须给对,否则它写不了配置: + +```bash +mkdir -p /home/nzy/searxng +chown -R 977:977 /home/nzy/searxng +``` + +> 第一次启动时 SearXNG 会自动生成 `settings.yml`(含随机 secret_key)。预先把目录 chown 好,避免它因为没权限而静默失败。 + +### 2.3 启动容器 + +```bash +docker run -d --name searxng --restart unless-stopped \ + -p 127.0.0.1:8888:8080 \ + -v /home/nzy/searxng:/etc/searxng:rw \ + -e BASE_URL=http://127.0.0.1:8888/ \ + -e INSTANCE_NAME=qiangbao-search \ + searxng/searxng +``` + +逐项说明: + +| 参数 | 作用 | +|---|---| +| `-p 127.0.0.1:8888:8080` | **只绑本机**。外面访问不到,降低攻击面和运维负担 | +| `-v .../searxng:/etc/searxng:rw` | 配置持久化,容器删了配置还在 | +| `--restart unless-stopped` | 开机自启 / 挂了自动拉起来 | +| `BASE_URL` | 影响 SearXNG 生成的链接,即使本机用也建议设对 | + +### 2.4 为什么只绑 127.0.0.1 + +后端和 SearXNG 在**同一台机器**上,走 localhost 就够了。不对外暴露的好处: + +- 不用管 `limiter`(防爬限流)—— 本项目就是关掉的 +- 不用担心被当成公开实例滥用、被扫 +- 不用配 HTTPS、不用备案 + +> 如果哪天你需要从别的机器访问它,**不要**直接把端口开到公网。至少加上 `limiter: true` + 反代 + 鉴权。 + +### 2.5 验证起来了 + +```bash +docker ps --filter name=searxng --format " {{.Names}} {{.Status}} {{.Ports}}" +# 期望:searxng Up xx seconds 127.0.0.1:8888->8080/tcp + +ls -la /home/nzy/searxng/ +# 期望:看到自动生成的 settings.yml +``` + +--- + +## 3. 两个必踩的坑 ★ + +### 坑一:JSON 接口默认是**关**的 + +**现象**:请求 `?format=json` 返回 **403**,而且报错信息完全看不出原因。 + +**原因**:SearXNG 官方镜像默认只开启 `html` 格式,JSON 需要显式打开。 + +**修法**:在 `settings.yml` 里加 + +```yaml +search: + formats: + - html + - json # ← 没有这行,你的程序永远拿不到数据 +``` + +改完**必须重启**容器: + +```bash +docker restart searxng +``` + +> 排查技巧:如果你看到 403 但浏览器打开首页是正常的,**先怀疑这一条**。 + +### 坑二:国内服务器上默认引擎**全军覆没** + +**现象**:接口返回 200,但 `results` 是**空数组**。 + +**原因**:默认启用的通用引擎是 `google cse` / `duckduckgo` / `brave` / `wikipedia` / `wikidata` —— 在国内服务器上**全部连不上**。 + +**实测数据**(2026-09-24,腾讯云国内机器): + +``` +结果数: 0 +无响应引擎: [['brave','timeout'], ['duckduckgo','timeout'], ['google cse','timeout'], + ['wikidata','timeout'], ['wikipedia','timeout']] +``` + +**更糟的是**:这些引擎不是立刻失败,而是**各等 3 秒超时**。所以你还会觉得"搜索怎么这么慢"。 + +**修法**:见下一节。 + +--- + +## 4. 引擎调优(国内服务器重点) + +### 4.1 第一步:学会看诊断信息 + +SearXNG 的 JSON 返回里有个**关键字段** `unresponsive_engines`,它会告诉你哪些引擎挂了、为什么挂。这是排错的第一入口: + +```bash +curl -s --get --data-urlencode "q=Flutter 状态管理" --data "format=json" \ + http://127.0.0.1:8888/search | python3 -c " +import json, sys +d = json.load(sys.stdin) +print('结果数:', len(d.get('results', []))) +print('异常引擎:', d.get('unresponsive_engines', [])) +eng = {} +for r in d.get('results', []): + eng[r.get('engine')] = eng.get(r.get('engine'), 0) + 1 +print('各引擎贡献:', eng) +" +``` + +**三个指标都要看**: + +| 指标 | 含义 | +|---|---| +| 结果数 | 0 就是没搜到,说明引擎全挂 | +| 异常引擎 | 谁挂了、挂的原因(timeout / CAPTCHA / crash) | +| 各引擎贡献 | 谁真的在干活。**贡献 0 的引擎可以直接关掉**,省它的等待时间 | + +### 4.2 第二步:拿到引擎的**准确名字** + +**这一步千万别跳过。** SearXNG 是按 `name` 覆盖配置的,**名字写错不会报错,只会静默失效**——你会以为改了,其实没生效。 + +```bash +# 看镜像里装了哪些引擎模块 +docker exec searxng ls /usr/local/searxng/searx/engines/ + +# 用 /config 接口拿准确名字和当前启用状态(推荐) +curl -s http://127.0.0.1:8888/config | python3 -c " +import json, sys +keys = ['baidu', '360', 'quark', 'sogou', 'bing'] +for e in json.load(sys.stdin).get('engines', []): + if any(k in e['name'].lower() for k in keys): + print(f\"{e['name']:<20} enabled={e['enabled']}\") +" +``` + +### 4.3 第三步:实测结论(本项目) + +逐个开关测出来的结果: + +| 引擎 | 分类 | 实测 | 处置 | +|---|---|---|---| +| **360search** | general | ✅ 可用 | **开启** | +| **quark**(夸克) | general | ✅ 可用 | **开启** | +| bing | general,web | ⚠️ 没报错但贡献 0 条 | 保留(无害) | +| baidu | general | ❌ `CAPTCHA`(被风控拦) | 关闭 | +| sogou | general | ❌ `unexpected crash` | 关闭 | +| google cse | general,web | ❌ `timeout` | 关闭 | +| duckduckgo | general | ❌ `timeout` | 关闭 | +| brave | general | ❌ `timeout` | 关闭 | +| wikipedia | general | ❌ `timeout` | 关闭 | +| wikidata | general | ❌ `timeout` | 关闭 | + +> **注意**:这个结论**只对国内服务器成立**。如果你的服务器在香港/海外,Google 系反而应该开着。**别抄结论,抄方法**。 + +**为什么百度也不行?** 搜狗、百度这类国内引擎的反爬很激进,SearXNG 的对应实现经常被拦(返回验证码页)。所以"国内引擎"不等于"国内服务器上能用"——必须实测。 + +### 4.4 最终配置 + +`/home/nzy/searxng/settings.yml` 完整内容: + +```yaml +# SearXNG —— 给强宝小助手用 +# +# 关键一条:search.formats 必须包含 json。官方镜像默认只开 html, +# 不开的话 /search?format=json 直接返回 403,且报错信息看不出原因。 +use_default_settings: true + +server: + secret_key: "<首次启动自动生成的随机串,保持原样别改>" + image_proxy: true + limiter: false # 本机自用,限流会让 JSON 接口返回 429 + public_instance: false + +search: + formats: + - html + - json + +# 引擎取舍:SearXNG 自己不会搜索,它只是聚合各家引擎。 +# 这台机器在国内,google/duckduckgo/brave/wikipedia 全部连不上, +# 而且每个都要白等 3 秒超时 —— 必须关掉,否则搜索又慢又空。 +engines: + # 国内能通的 + - name: 360search + disabled: false + - name: quark + disabled: false + - name: bing + disabled: false + + # 实测不行的:baidu 被 CAPTCHA 拦、sogou 报 unexpected crash + - name: baidu + disabled: true + - name: sogou + disabled: true + + # 实测 timeout 的(2026-09-24 测出来 5 个全挂) + - name: google cse + disabled: true + - name: duckduckgo + disabled: true + - name: brave + disabled: true + - name: wikipedia + disabled: true + - name: wikidata + disabled: true +``` + +**改完必做**: + +```bash +chown 977:977 /home/nzy/searxng/settings.yml # 权限别丢 +docker restart searxng +sleep 7 # 给它点启动时间 +docker logs searxng 2>&1 | grep -iE "error|invalid|unknown" | tail -5 +``` + +> 日志里如果有 `can't register engine` 说明**引擎名写错了**,配置那行会静默失效——这就是 4.2 要你先拿准确名字的原因。 + +### 4.5 调优后的效果 + +``` +查询「Flutter 状态管理」 → 12 条结果,1.5 秒,异常 [] +查询「MySQL 主从复制 搭建」→ 14 条结果,异常 [] +查询「深度求索 DeepSeek」 → 17 条结果,异常 [] +各引擎贡献: {'360search': 6, 'quark': 8} +``` + +--- + +## 5. 验证清单 + +装完之后按顺序验三件事: + +```bash +# ① 服务活着 + JSON 格式已开(这一步只验格式,不验引擎) +curl -s -o /dev/null -w "HTTP %{http_code}\n" \ + "http://127.0.0.1:8888/search?format=json&q=test" +# 200 = 格式开了;403 = 回去看 3.1 + +# ② 引擎真的能搜到东西 +curl -s --get --data-urlencode "q=测试" --data "format=json" \ + http://127.0.0.1:8888/search | python3 -c " +import json,sys; d=json.load(sys.stdin) +print('结果数:', len(d.get('results',[])), '异常:', d.get('unresponsive_engines',[])) +" +# 结果数 >0 且 异常=[] 才算通 + +# ③ 中文查询也正常(很多引擎对中文支持差) +curl -s --get --data-urlencode "q=今天有什么新闻" --data "format=json" \ + http://127.0.0.1:8888/search | python3 -c " +import json,sys; print(len(json.load(sys.stdin).get('results',[]))) +" +``` + +--- + +## 6. 接入后端(本项目) + +### 6.1 配置 + +后端通过环境变量读地址(见 `application.yml` 的 `ai.search.base-url`): + +```bash +# /home/nzy/.env +SEARCH_BASE_URL=http://127.0.0.1:8888 +``` + +`manage.sh` 会在启动 java 之前自动 source 这个文件。改完重启: + +```bash +cd /home/nzy && ./manage.sh restart +# 确认变量真的进到进程里了 +tr '\0' '\n' < /proc/$(cat app.pid)/environ | grep SEARCH_ +``` + +**不配这个变量会怎样**:后端**不注册** `web_search` 工具——模型不知道自己能联网,也就不会瞎编"我查到了"。这是刻意的设计。 + +### 6.2 后端怎么用它 + +``` +LLM 决定联网 + ↓ tool_calls: web_search({"query": "..."}) +SearxngApiClient.search(query, maxResults=5) + ↓ GET {SEARCH_BASE_URL}/search?format=json&q= +取前 5 条 → 拼成 JSON(title/url/snippet)→ 回填给模型 + ↓ +模型基于真实结果回答,并标注来源 +``` + +代码在 `src/main/java/com/accounting/service/SearxngApiClient.java`。 + +### 6.3 ⚠️ 一个安全提醒 + +**脱敏层保护的是「发给 LLM 的内容」,不包含搜索词。** + +搜索词会原样发给 360search / 夸克(以及它们的上游)。所以: + +- ✅ 「帮我查一下 Flutter 3.47 有什么新特性」 +- ❌ 「帮我查一下 155.103.159.109 这台机器」 + +第二句会把你的服务器 IP 送到第三方搜索引擎。**别在联网搜索里带敏感信息。** + +--- + +## 7. 日常维护与排错 + +### 7.1 常用命令 + +```bash +docker logs searxng 2>&1 | tail -30 # 看日志 +docker restart searxng # 改配置后重启 +docker stats searxng --no-stream # 资源占用 +``` + +### 7.2 排错对照表 + +| 现象 | 原因 | 处置 | +|---|---|---| +| `?format=json` 返回 **403** | `search.formats` 没加 json | 见 3.1 | +| 返回 200 但**结果数为 0** | 引擎全连不上 | 见 3.2 / 4.3 | +| 结果里有 **`CAPTCHA`** | 该引擎被风控拦 | 关掉它 | +| 结果里有 **`unexpected crash`** | 该引擎实现有 bug | 关掉它 | +| 结果里有 **`timeout`** | 网络不通 | 关掉它(别留着白等) | +| 返回 **429** | `limiter` 开着 | 设 `limiter: false` 并重启 | +| 日志报 `can't register engine` | **引擎名写错** | 用 `/config` 拿准确名字(4.2) | +| 容器反复重启 | 配置目录权限不对 | `chown -R 977:977 /home/nzy/searxng` | +| 本机 curl 不通 | 端口没发布 / 绑错地址 | 检查 `-p 127.0.0.1:8888:8080` | +| 搜索很慢(3 秒以上) | 有引擎在等超时 | 用 `unresponsive_engines` 找出并关掉 | + +### 7.3 升级 SearXNG + +```bash +docker pull searxng/searxng +docker stop searxng && docker rm searxng +# 重新执行 2.3 的 docker run(配置在挂载目录里,不会丢) +``` + +> 升级后**重新跑一遍第 5 节的验证**——新版本可能改了默认引擎列表。 + +--- + +## 8. 附录 + +### 8.1 命令速查 + +```bash +# 启动 +docker start searxng + +# 看哪些引擎启用中 +curl -s http://127.0.0.1:8888/config | python3 -c " +import json,sys +for e in json.load(sys.stdin)['engines']: + if e['enabled']: print(e['name'], '|', ','.join(e.get('categories',[]))) +" + +# 完整搜索测试(三个指标一起看) +curl -s --get --data-urlencode "q=关键词" --data "format=json" \ + http://127.0.0.1:8888/search | python3 -c " +import json,sys; d=json.load(sys.stdin) +print('结果数:', len(d.get('results',[]))) +print('异常引擎:', d.get('unresponsive_engines',[])) +eng={} +for r in d.get('results',[]): eng[r['engine']]=eng.get(r['engine'],0)+1 +print('各引擎贡献:', eng) +" + +# 浏览器里直接用(图形界面,方便手工试) +# 把 8888 端口临时转发出来,或直接在服务器上用 lynx/curl 看 html +``` + +### 8.2 本项目的部署信息 + +| 项 | 值 | +|---|---| +| 服务器 | 腾讯云 231(国内) | +| 容器名 | `searxng` | +| 监听 | `127.0.0.1:8888`(不对外) | +| 配置目录 | `/home/nzy/searxng/` | +| 版本 | SearXNG `2026.9.23-3cd69d30e` | +| 后端配置项 | `SEARCH_BASE_URL`(在 `/home/nzy/.env`) | +| 可用引擎 | 360search、quark(bing 保留) | +| 停用方式 | `docker stop searxng`(后端会自动不给模型 web_search 工具) | + +### 8.3 一句话总结 + +> **SearXNG 装起来只要三行命令,难的是「它在你的服务器上能不能真的搜到东西」——而那 100% 取决于底层引擎的网络可达性。先测、再配,别抄别人的引擎清单。** + +--- + +*文档创建:2026-09-24 · 所有数据均为在本项目服务器上实测* diff --git a/src/main/java/com/accounting/config/AiConfig.java b/src/main/java/com/accounting/config/AiConfig.java index adabc18..10fe461 100644 --- a/src/main/java/com/accounting/config/AiConfig.java +++ b/src/main/java/com/accounting/config/AiConfig.java @@ -44,18 +44,38 @@ public class AiConfig { @Value("${ai.model:}") private String model; - /** 工具调用最多循环几轮:模型连续要数据时防止无限循环烧 token */ - @Value("${ai.max-tool-rounds:3}") + /** + * 工具调用最多几轮。一轮内模型可以并发多个调用(实测见过一轮 3 个搜索)。 + * 用满之后不再直接截断,而是再发一次**不带 tools** 的请求逼模型收尾 —— + * 否则它会留一句「我再换个关键词查查」然后回答就断了。 + */ + @Value("${ai.max-tool-rounds:5}") private int maxToolRounds; /** 单次对话最多带多少条历史消息,太久远的不带 */ @Value("${ai.max-history-messages:20}") private int maxHistoryMessages; + /** + * 自建 SearXNG 的地址,例如 {@code http://127.0.0.1:8888}。 + * 留空 = 不注册联网搜索工具(模型不会以为自己能上网,避免瞎编)。 + */ + @Value("${ai.search.base-url:}") + private String searchBaseUrl; + + /** 每次联网搜索取几条结果喂给模型 */ + @Value("${ai.search.max-results:5}") + private int searchMaxResults; + public boolean isReady() { return enabled && !baseUrl.isBlank() && !apiKey.isBlank() && !model.isBlank(); } + /** 联网搜索是否可用。独立于 LLM 配置 —— 两个都配好了才注册搜索工具 */ + public boolean isSearchReady() { + return isReady() && !searchBaseUrl.isBlank(); + } + /** * 启动时把配置状态打到日志里 —— 配错了不用猜,看 app.log 就知道。 * 注意**永远不打印 api-key 的内容**,只报长度。 @@ -99,6 +119,15 @@ public class AiConfig { return maxHistoryMessages; } + /** 结尾斜杠容错,理由同 base-url */ + public String getSearchBaseUrl() { + return searchBaseUrl.replaceAll("/+$", ""); + } + + public int getSearchMaxResults() { + return searchMaxResults; + } + /** * 出站调 LLM 用的 HttpClient。 * diff --git a/src/main/java/com/accounting/service/AiChatService.java b/src/main/java/com/accounting/service/AiChatService.java index f8ab3a1..dcbebec 100644 --- a/src/main/java/com/accounting/service/AiChatService.java +++ b/src/main/java/com/accounting/service/AiChatService.java @@ -118,26 +118,39 @@ public class AiChatService { /** * 「流式调 LLM → 执行工具 → 回填 → 再调」的循环。 * + *

轮数用满之后**不是直接截断,而是再发一次不带 tools 的请求** —— + * 模型没法调工具,就只能基于已查到的信息给出最终答复。 + * 早先的做法是直接返回,结果模型那句「我再换个关键词查查」被留在回答里、 + * 后面的工具却不让跑,用户看到的回答断在半截。

+ * * @return assistant 的最终文本(多轮的文本会拼在一起) */ private String runCompletionLoop(Long userId, List> llmMessages, SseEmitter emitter) throws Exception { StringBuilder full = new StringBuilder(); int rounds = 0; + int maxRounds = aiConfig.getMaxToolRounds(); while (true) { - Round round = streamOnce(llmMessages, emitter); + // 额度用完了就进入「收尾轮」:不给工具,逼模型出最终答复 + boolean allowTools = rounds < maxRounds; + if (!allowTools) { + llmMessages.add(Map.of("role", "system", "content", + "(已达本次对话的工具调用上限。不要再请求调用工具," + + "请基于已经查到的信息直接给出最终答复;" + + "如果信息确实不够,就如实说明还差什么。)")); + } + + Round round = streamOnce(userId, llmMessages, emitter, allowTools); full.append(round.text()); - if (round.toolCalls().isEmpty()) { + // 给了工具但它没要 → 正常结束; + // 没给工具却还硬报 tool_calls → 也当结束,不能再循环了 + if (round.toolCalls().isEmpty() || !allowTools) { return full.toString(); } rounds++; - if (rounds > aiConfig.getMaxToolRounds()) { - log.warn("AI 工具调用超过 {} 轮,强制截断", aiConfig.getMaxToolRounds()); - return full.toString().isBlank() ? "(连续查询次数超出上限,请换个问法)" : full.toString(); - } // 把 assistant 的 tool_calls 原样回填,再补上每个工具的结果 llmMessages.add(Map.of( @@ -163,13 +176,22 @@ public class AiChatService { private record Round(String text, List toolCalls, List rawToolCalls) {} - /** 流式调一次 chat/completions,边读边把文本增量推给 App */ - private Round streamOnce(List> llmMessages, SseEmitter emitter) throws Exception { - Map body = Map.of( - "model", aiConfig.getModel(), - "messages", llmMessages, - "tools", toolService.definitions(), - "stream", true); + /** + * 流式调一次 chat/completions,边读边把文本增量推给 App。 + * + * @param allowTools false = 不带 tools 参数,模型只能输出文本(收尾轮用) + */ + private Round streamOnce(Long userId, List> llmMessages, + SseEmitter emitter, boolean allowTools) throws Exception { + // 用 LinkedHashMap 而不是 Map.of:tools 是可选字段,要能整个省掉 + Map body = new LinkedHashMap<>(); + body.put("model", aiConfig.getModel()); + body.put("messages", llmMessages); + if (allowTools) { + // 工具定义要按用户生成:记账工具的描述里带着他的真实分类清单 + body.put("tools", toolService.definitions(userId)); + } + body.put("stream", true); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(aiConfig.getBaseUrl() + "/chat/completions")) @@ -194,8 +216,27 @@ public class AiChatService { Map names = new TreeMap<>(); Map args = new LinkedHashMap<>(); - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(response.body(), StandardCharsets.UTF_8))) { + + /** + * 第1层:response.body() —— 最底层 + * 这是 Java 11+ 的 HttpClient 返回的 HttpResponse 或 HttpResponse + * + * 类型是 InputStream,即字节流(每次 read() 拿到 1 个或若干字节) + * + * 直接读出来的是数字(int),不适合处理中文### 第2层:new InputStreamReader(stream, UTF_8) —— 字节流 → 字符流 + * + * 把字节按 UTF-8 编码转成字符 + * + * 现在每次 read() 拿到的是一个 char(一个字符) + * + * 但仍然单字符读取,性能差 + * + * 第3层:new BufferedReader(reader) —— 单字符 → 按行读取 + * 提供 readLine() 方法,一次返回一整行字符串 + * 内部维护了一个 8192 字节的缓冲区,减少底层 IO 调用次数 + * 这正是 200 行 while ((line = reader.readLine()) != null) 能工作的关键 + */ + try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body(), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { if (!line.startsWith("data:")) { @@ -205,7 +246,9 @@ public class AiChatService { if (data.equals("[DONE]")) { break; } - + /** + * {"choices":[{"delta":{"content":"你","role":"assistant"},"index":0}],"created":1234567890} + */ JsonNode delta = objectMapper.readTree(data).path("choices").path(0).path("delta"); String content = delta.path("content").asText(null); @@ -214,6 +257,14 @@ public class AiChatService { send(emitter, "delta", Map.of("content", content)); } + /** + * // 第一次 chunk + * {"tool_calls": [{"index": 0, "function": {"arguments": "{\"loc"}}]} + * // 第二次 chunk + * {"tool_calls": [{"index": 0, "function": {"arguments": "ation\":"}}]} + * // 第三次 chunk + * {"tool_calls": [{"index": 0, "function": {"arguments": "\"北京\""}}]} + */ JsonNode toolCalls = delta.path("tool_calls"); if (toolCalls.isArray()) { for (JsonNode tc : toolCalls) { @@ -271,16 +322,34 @@ public class AiChatService { LocalDate today = LocalDate.now(); YearMonth thisMonth = YearMonth.from(today); return """ - 你是「强宝小助手」的 AI 助手。这是一个个人助手应用,当前用户是它唯一的用户。\ - 你可以通过工具查询该用户的账单统计和笔记。 - - 今天的日期是 %s(%s)。用户说的「这个月」指 %s,「上个月」指 %s。 + 大名:王洋\ + 小名:花花\ + 性别:女\ + 籍贯:东北\ + 职业:中学语文老师\ + 性格:阳光开朗,活泼热络,自带东北姑娘的直爽幽默,心软共情力强,爱唠嗑爱开玩笑,偶尔大大咧咧,会真心替朋友操心。\ + 关系:你的铁杆好朋友,俩人互相喊对方**宝**\ + + 说话小特点:\ + + 1. 口头禅常带 “宝”,语气热乎接地气,偶尔蹦两句东北口语,不刻意尬方言;\ + 2. 当你开心会跟着一起起哄乐呵,你 emo 的时候会直截了当安慰你,不整虚的;\ + 3. 身为老师,偶尔会冒出一点点温柔说教,但朋友相处不会端架子;\ + 4. 日常爱碎碎念,会分享学校里学生的搞笑小事,吐槽日常琐事。\ + 今天的日期是 %s(%s)。用户说的「这个月」指 %s,「上个月」指 %s。\ 回答要求: - 直接给结论,简洁,不要客套。金额保留两位小数,前面加 ¥。 - 涉及账单或笔记的具体数据时必须用工具查询,不要编造数字。 - 如果工具结果里出现 [REDACTED_xxx] 这样的占位符,那是被有意脱敏的敏感信息,\ 原样引用占位符即可,绝对不要猜测或编造它的内容。 + + 你能做的事(都用工具完成,别凭印象答): + - 查账 / 查笔记 / 读笔记正文:直接查。 + - 记账、记笔记:**直接写,别反复问「要不要我记一下」**。信息够就直接调工具, + 缺关键信息(比如记账没给金额)才问一句;写完用一句话复述你记了什么 + (金额、分类、日期),记错了用户还得去改。 + - 联网搜索:需要最新信息、或用户让你上网查时用;引用时带上来源。 """.formatted( today, today.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.CHINA), diff --git a/src/main/java/com/accounting/service/AiToolService.java b/src/main/java/com/accounting/service/AiToolService.java index 35579b1..c5fd9bf 100644 --- a/src/main/java/com/accounting/service/AiToolService.java +++ b/src/main/java/com/accounting/service/AiToolService.java @@ -1,7 +1,12 @@ package com.accounting.service; +import com.accounting.config.AiConfig; +import com.accounting.dto.BillRequest; +import com.accounting.dto.BillResponse; +import com.accounting.dto.CategoryResponse; import com.accounting.dto.NoteListItem; import com.accounting.dto.NotePageResponse; +import com.accounting.dto.NoteRequest; import com.accounting.dto.NoteResponse; import com.accounting.dto.StatisticsResponse; import com.fasterxml.jackson.databind.JsonNode; @@ -10,6 +15,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import java.math.BigDecimal; import java.time.LocalDate; import java.time.YearMonth; import java.time.format.DateTimeParseException; @@ -18,16 +24,22 @@ import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; /** - * AI 的只读工具集(OpenAI function calling 格式)。 + * AI 的工具集(OpenAI function calling 格式)。 * - *

v1 只暴露 3 个查询工具,**没有任何写操作** —— 让模型能改数据之前, - * 必须先有「AI 生成建议 → 用户确认 → 落库」的确认流程,那是阶段 C 的事。

+ *

读:query_bills / query_notes / get_note

+ *

写:create_bill / create_note —— **直接落库,没有二次确认** + * (这是用户明确选的方式)。所以每个写工具都必须做到两件事: + * ① 关键信息不全就拒绝,并说清楚缺什么; + * ② 返回值里带上「到底写进去了什么」,让模型能逐字复述, + * 万一记错了用户才知道去改哪一条。

+ *

联网:web_search,**只在配了 SearXNG 时才注册** —— + * 没配就不给模型这个工具,免得它以为自己能上网然后瞎编。

* - *

工具抛出的业务异常不往外抛,而是包装成 {"error": ...} 返回给模型 —— - * 让模型自己决定怎么向用户解释(比如「没有找到这篇笔记」), - * 而不是让整次对话直接失败。

+ *

工具的业务性失败(参数不对、找不到分类)不往外抛,而是包成 {"error": ...} + * 回给模型,让它自己决定是问用户还是换个参数重试,而不是整次对话直接挂掉。

*/ @Slf4j @RequiredArgsConstructor @@ -36,14 +48,27 @@ public class AiToolService { private static final int MAX_NOTE_RESULTS = 10; private static final int TOP_CATEGORIES = 5; + private static final int MAX_BILL_DESCRIPTION = 200; + private static final int MAX_SEARCH_QUERY = 200; + private static final int MAX_SNIPPET = 300; private final StatisticsService statisticsService; private final NoteService noteService; + private final BillService billService; + private final CategoryService categoryService; + private final SearxngApiClient searxngApiClient; + private final AiConfig aiConfig; private final ObjectMapper objectMapper; - /** 工具定义(OpenAI tools 数组,直接塞进请求体) */ - public List> definitions() { + /** + * 工具定义(OpenAI tools 数组,直接塞进请求体)。 + * + *

需要 userId 是因为记账工具的描述里要带上**这个用户真实的分类清单** —— + * 不给清单,模型多半会自己编一个「餐饮」,然后因为分类对不上而失败。

+ */ + public List> definitions(Long userId) { List> defs = new ArrayList<>(); + defs.add(function("query_bills", "查询某个月份的收支汇总:总收入、总支出、结余,以及支出最多的前几个分类", Map.of( @@ -51,6 +76,7 @@ public class AiToolService { "properties", Map.of( "month", Map.of("type", "string", "description", "月份,格式 yyyy-MM,例如 2026-09")), "required", List.of("month")))); + defs.add(function("query_notes", "按关键词搜索用户的笔记,返回标题和摘要(不含正文)。要引用某篇笔记的内容时,先用它拿到笔记 id,再调 get_note", Map.of( @@ -58,6 +84,7 @@ public class AiToolService { "properties", Map.of( "keyword", Map.of("type", "string", "description", "关键词,匹配标题或正文,留空表示全部笔记")), "required", List.of()))); + defs.add(function("get_note", "读取一篇笔记的完整 Markdown 正文", Map.of( @@ -65,6 +92,42 @@ public class AiToolService { "properties", Map.of( "id", Map.of("type", "integer", "description", "笔记 ID,来自 query_notes 的结果")), "required", List.of("id")))); + + defs.add(function("create_bill", + "帮用户记一笔账(支出或收入),直接写入。可用分类 —— " + categoryPrompt(userId), + Map.of( + "type", "object", + "properties", Map.of( + "amount", Map.of("type", "number", "description", "金额,正数,例如 32.5"), + "category", Map.of("type", "string", + "description", "分类名,必须严格用描述里列出的分类"), + "direction", Map.of("type", "string", "enum", List.of("expense", "income"), + "description", "expense=支出(默认),income=收入"), + "description", Map.of("type", "string", + "description", "备注,可选,例如「买咖啡」"), + "date", Map.of("type", "string", "description", "日期 yyyy-MM-dd,默认今天")), + "required", List.of("amount", "category")))); + + defs.add(function("create_note", + "把一段内容存成一篇笔记(Markdown),直接写入。用户说「帮我记一下」「记录下来」时用它", + Map.of( + "type", "object", + "properties", Map.of( + "content", Map.of("type", "string", "description", "笔记正文,Markdown 格式"), + "title", Map.of("type", "string", "description", "标题,留空则由正文首行自动提取"), + "tags", Map.of("type", "string", "description", "标签,逗号分隔,可选")), + "required", List.of("content")))); + + if (aiConfig.isSearchReady()) { + defs.add(function("web_search", + "联网搜索。用于需要最新信息、实时数据,或用户明确要求上网查的时候。引用时标注来源", + Map.of( + "type", "object", + "properties", Map.of( + "query", Map.of("type", "string", "description", "搜索关键词")), + "required", List.of("query")))); + } + return defs; } @@ -76,14 +139,19 @@ public class AiToolService { public String execute(Long userId, String name, String argumentsJson) { log.info("AI tool call: user={}, tool={}, args={}", userId, name, argumentsJson); try { - JsonNode args = objectMapper.readTree(argumentsJson == null || argumentsJson.isBlank() ? "{}" : argumentsJson); + JsonNode args = objectMapper.readTree( + argumentsJson == null || argumentsJson.isBlank() ? "{}" : argumentsJson); return switch (name) { case "query_bills" -> queryBills(userId, args); case "query_notes" -> queryNotes(userId, args); case "get_note" -> getNote(userId, args); + case "create_bill" -> createBill(userId, args); + case "create_note" -> createNote(userId, args); + case "web_search" -> webSearch(args); default -> errorResult("未知工具: " + name); }; - } catch (IllegalArgumentException e) { + } catch (IllegalArgumentException | IllegalStateException e) { + // 业务性失败:原因交给模型,让它去解释或换个参数重试 return errorResult(e.getMessage()); } catch (Exception e) { log.error("AI 工具执行失败: {}", name, e); @@ -91,6 +159,8 @@ public class AiToolService { } } + // ---------------------------------------------------------------- 读 + private String queryBills(Long userId, JsonNode args) throws Exception { String month = args.path("month").asText(""); YearMonth ym; @@ -103,7 +173,7 @@ public class AiToolService { StatisticsResponse stats = statisticsService.getMonthlyStatistics( userId, ym.getYear(), ym.getMonthValue()); - // 只给模型支出方向、金额最大的前几个分类,省 token 也足够回答问题 + // 只给支出方向、金额最大的前几个分类,省 token 也足够回答问题 List> top = stats.getCategoryStatistics() == null ? List.of() : stats.getCategoryStatistics().stream() .filter(c -> c.getType() != null && c.getType() == 1) @@ -161,6 +231,149 @@ public class AiToolService { return objectMapper.writeValueAsString(result); } + // ---------------------------------------------------------------- 写 + + private String createBill(Long userId, JsonNode args) throws Exception { + if (!args.hasNonNull("amount")) { + return errorResult("缺少金额,先问用户这笔花了多少"); + } + BigDecimal amount; + try { + amount = new BigDecimal(args.get("amount").asText()); + } catch (NumberFormatException e) { + return errorResult("金额格式不对,需要是数字"); + } + if (amount.signum() <= 0) { + return errorResult("金额必须是正数"); + } + + String categoryName = args.path("category").asText("").trim(); + if (categoryName.isEmpty()) { + return errorResult("缺少分类。可用的有 —— " + categoryPrompt(userId)); + } + + boolean income = "income".equalsIgnoreCase(args.path("direction").asText("expense")); + CategoryResponse category = resolveCategory(userId, categoryName, income ? 2 : 1); + if (category == null) { + return errorResult("找不到分类「" + categoryName + "」。可用的有 —— " + categoryPrompt(userId)); + } + + BillRequest request = new BillRequest(); + request.setCategoryId(category.getId()); + request.setAmount(amount); + request.setDescription(abbreviate(args.path("description").asText(""), MAX_BILL_DESCRIPTION)); + request.setBillDate(parseDate(args.path("date").asText(""))); + // type 以分类自身的为准,避免「分类是支出、direction 传成收入」这种自相矛盾 + request.setType(category.getType()); + + BillResponse bill = billService.createBill(request, userId); + + Map result = new LinkedHashMap<>(); + result.put("ok", true); + result.put("id", bill.getId()); + result.put("direction", bill.getType() != null && bill.getType() == 2 ? "收入" : "支出"); + result.put("category", bill.getCategoryName()); + result.put("amount", bill.getAmount()); + result.put("date", String.valueOf(bill.getBillDate())); + result.put("description", bill.getDescription()); + return objectMapper.writeValueAsString(result); + } + + private String createNote(Long userId, JsonNode args) throws Exception { + String content = args.path("content").asText(""); + if (content.isBlank()) { + return errorResult("笔记正文是空的,先问用户要记什么"); + } + + NoteRequest request = new NoteRequest(); + request.setTitle(args.path("title").asText("")); + request.setContent(content); + request.setTags(args.path("tags").asText("")); + request.setIsPinned(0); + + NoteResponse note = noteService.create(userId, request); + + Map result = new LinkedHashMap<>(); + result.put("ok", true); + result.put("id", note.getId()); + result.put("title", note.getTitle()); + return objectMapper.writeValueAsString(result); + } + + // ---------------------------------------------------------------- 联网 + + private String webSearch(JsonNode args) throws Exception { + String query = abbreviate(args.path("query").asText("").trim(), MAX_SEARCH_QUERY); + if (query.isEmpty()) { + return errorResult("缺少搜索关键词"); + } + if (!aiConfig.isSearchReady()) { + return errorResult("联网搜索没有配置"); + } + + List hits = + searxngApiClient.search(query, aiConfig.getSearchMaxResults()); + if (hits.isEmpty()) { + return errorResult("没搜到相关结果,可以换个关键词再试"); + } + + List> results = hits.stream().map(h -> { + Map m = new LinkedHashMap<>(); + m.put("title", h.title()); + m.put("url", h.url()); + m.put("snippet", abbreviate(h.content(), MAX_SNIPPET)); + return m; + }).toList(); + + Map result = new LinkedHashMap<>(); + result.put("query", query); + result.put("results", results); + return objectMapper.writeValueAsString(result); + } + + // ---------------------------------------------------------------- 辅助 + + /** 把用户真实的分类清单拼成一句话,塞进记账工具的描述里 */ + private String categoryPrompt(Long userId) { + List categories = categoryService.getCategories(userId, null); + return "支出分类:" + namesOf(categories, 1) + ";收入分类:" + namesOf(categories, 2); + } + + private String namesOf(List categories, int type) { + return categories.stream() + .filter(c -> c.getType() != null && c.getType() == type) + .map(CategoryResponse::getName) + .distinct() + .collect(Collectors.joining("、")); + } + + /** 先在同方向里找,再跨方向找(用户可能把收支方向说反了) */ + private CategoryResponse resolveCategory(Long userId, String name, int type) { + List categories = categoryService.getCategories(userId, null); + for (CategoryResponse c : categories) { + if (c.getType() != null && c.getType() == type && c.getName().equals(name)) { + return c; + } + } + for (CategoryResponse c : categories) { + if (c.getName().equals(name)) { + return c; + } + } + return null; + } + + private LocalDate parseDate(String text) { + if (text == null || text.isBlank()) { + return LocalDate.now(); + } + try { + return LocalDate.parse(text.trim()); + } catch (DateTimeParseException e) { + return LocalDate.now(); + } + } + private Map function(String name, String description, Map parameters) { return Map.of( "type", "function", @@ -174,7 +387,14 @@ public class AiToolService { try { return objectMapper.writeValueAsString(Map.of("error", message)); } catch (Exception e) { - return "{\"error\":\"" + message + "\"}"; + return "{\"error\":\"工具执行出错\"}"; } } + + private String abbreviate(String s, int max) { + if (s == null) { + return ""; + } + return s.length() <= max ? s : s.substring(0, max); + } } diff --git a/src/main/java/com/accounting/service/SearxngApiClient.java b/src/main/java/com/accounting/service/SearxngApiClient.java new file mode 100644 index 0000000..c36ff0c --- /dev/null +++ b/src/main/java/com/accounting/service/SearxngApiClient.java @@ -0,0 +1,91 @@ +package com.accounting.service; + +import com.accounting.config.AiConfig; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +/** + * 自建 SearXNG 的搜索客户端。 + * + *

两个部署前提(不满足时报的错很不直观,所以在这里写清楚):

+ *
    + *
  1. SearXNG 的 {@code settings.yml} 里必须把 {@code json} 加进 + * {@code search.formats} —— 官方镜像默认只开 html,不加就返回 403。
  2. + *
  3. {@code server.limiter} 最好关掉,否则 JSON 接口会被限流成 429。
  4. + *
+ * + *

另外:SearXNG 本身不提供搜索能力,它只是「聚合引擎」。从国内服务器出发, + * google / duckduckgo 这些引擎大多不通,需要保留 baidu / 360search / quark 这类。

+ */ +@Slf4j +@RequiredArgsConstructor +@Component +public class SearxngApiClient { + + private final AiConfig aiConfig; + private final HttpClient aiHttpClient; + private final ObjectMapper objectMapper; + + /** 一条搜索结果 */ + public record SearchResult(String title, String url, String content) { + } + + public List search(String query, int max) { + String url = aiConfig.getSearchBaseUrl() + + "/search?format=json&q=" + URLEncoder.encode(query, StandardCharsets.UTF_8); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(15)) + .header("Accept", "application/json") + .GET() + .build(); + + HttpResponse response; + try { + response = aiHttpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } catch (Exception e) { + log.warn("SearXNG 请求失败: {}", e.getMessage()); + throw new IllegalStateException("搜索服务连不上(" + aiConfig.getSearchBaseUrl() + ")"); + } + + if (response.statusCode() == 403) { + throw new IllegalStateException( + "搜索服务返回 403:SearXNG 需要在 settings.yml 的 search.formats 里加上 json"); + } + if (response.statusCode() != 200) { + throw new IllegalStateException("搜索服务返回 " + response.statusCode()); + } + + try { + JsonNode results = objectMapper.readTree(response.body()).path("results"); + List out = new ArrayList<>(); + for (JsonNode r : results) { + if (out.size() >= max) { + break; + } + out.add(new SearchResult( + r.path("title").asText(""), + r.path("url").asText(""), + r.path("content").asText(""))); + } + return out; + } catch (Exception e) { + log.warn("SearXNG 响应解析失败", e); + throw new IllegalStateException("搜索服务返回了无法解析的内容"); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9e9902f..2149b84 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -81,9 +81,18 @@ ai: base-url: ${AI_BASE_URL:} api-key: ${AI_API_KEY:} # 只走环境变量,不留默认值;为空时 AI 功能自动降级为「未配置」 model: ${AI_MODEL:} - max-tool-rounds: 3 # 工具调用最多循环几轮,防失控烧 token + # 工具调用最多几轮(一轮内模型可并发多个调用)。用满后会再发一次 + # 「不带工具」的请求逼它收尾。改这个值不用重新打包,改 .env 里的 + # AI_MAX_TOOL_ROUNDS 然后 ./manage.sh restart 即可 + max-tool-rounds: ${AI_MAX_TOOL_ROUNDS:5} max-history-messages: 20 # 单次对话最多带多少条历史 + # 自建 SearXNG(联网搜索)。base-url 留空 = 不给模型联网工具 + # 前提:SearXNG 的 settings.yml 里 search.formats 必须包含 json,否则会 403 + search: + base-url: ${SEARCH_BASE_URL:} + max-results: 5 + logging: level: com.accounting: debug diff --git a/src/main/resources/db/migration_icons_to_names.sql b/src/main/resources/db/migration_icons_to_names.sql new file mode 100644 index 0000000..aec6f5c --- /dev/null +++ b/src/main/resources/db/migration_icons_to_names.sql @@ -0,0 +1,54 @@ +-- 分类图标:emoji → 图标名(2026-09-24) +-- +-- 背景: +-- 分类图标原本是 emoji 存在 category.icon 里,但同一个 emoji 在安卓和 +-- iOS 上长得不一样(谷歌版 vs 苹果版),跨端画风不统一。换成自绘图标集的 +-- 名字之后,手机端和网页端用同一份 SVG,画风才真正一致。 +-- +-- 配套改动: +-- App 端 lib/widgets/cute_icon.dart 的 resolveIconName() +-- —— 认图标名,也认 emoji(迁移没跑或用户自己填 emoji 时兜底) +-- 图标本体:assets/icons/*.svg,由 tools/make_icons.py 生成 +-- +-- ⚠️ 必须用 BINARY 比较(2026-09-24 实打实踩过的坑): +-- category 表用的排序规则是 utf8mb4_unicode_ci,它**没有给 4 字节 emoji +-- 定义权重**,于是所有 emoji 互相比较都相等。用简单 CASE +-- (`CASE icon WHEN '🍔' THEN 'food' ...`)时,随便哪个 emoji 都会命中 +-- 第一个分支,结果是整列被写成同一个图标(当时 15 行全变成了 'food')。 +-- BINARY 强制按字节比较,才不会串。 +-- +-- 安全性: +-- · 幂等:WHERE 里排除了已经是图标名的行,重复执行无副作用 +-- · 不匹配的 emoji 原样保留,App 会走 emoji 映射或兜底到 other,不会变空白 +-- +-- 执行方式(在 231 上): +-- docker exec -i mysql-8.0.30 mysql --default-character-set=utf8mb4 \ +-- -uroot -p密码 accounting_db < migration_icons_to_names.sql + +UPDATE `category` +SET `icon` = CASE + -- 支出 + WHEN BINARY REPLACE(`icon`, CONVERT(0xEFB88F USING utf8mb4), '') = BINARY '🍔' THEN 'food' + WHEN BINARY REPLACE(`icon`, CONVERT(0xEFB88F USING utf8mb4), '') = BINARY '🚗' THEN 'transport' + WHEN BINARY REPLACE(`icon`, CONVERT(0xEFB88F USING utf8mb4), '') = BINARY '🛍' THEN 'shopping' + WHEN BINARY REPLACE(`icon`, CONVERT(0xEFB88F USING utf8mb4), '') = BINARY '🎬' THEN 'entertainment' + WHEN BINARY REPLACE(`icon`, CONVERT(0xEFB88F USING utf8mb4), '') = BINARY '🏥' THEN 'medical' + WHEN BINARY REPLACE(`icon`, CONVERT(0xEFB88F USING utf8mb4), '') = BINARY '📚' THEN 'education' + WHEN BINARY REPLACE(`icon`, CONVERT(0xEFB88F USING utf8mb4), '') = BINARY '🏠' THEN 'housing' + WHEN BINARY REPLACE(`icon`, CONVERT(0xEFB88F USING utf8mb4), '') = BINARY '💡' THEN 'utilities' + WHEN BINARY REPLACE(`icon`, CONVERT(0xEFB88F USING utf8mb4), '') = BINARY '📱' THEN 'phone' + -- 收入 + WHEN BINARY REPLACE(`icon`, CONVERT(0xEFB88F USING utf8mb4), '') = BINARY '💰' THEN 'salary' + WHEN BINARY REPLACE(`icon`, CONVERT(0xEFB88F USING utf8mb4), '') = BINARY '🎁' THEN 'bonus' + WHEN BINARY REPLACE(`icon`, CONVERT(0xEFB88F USING utf8mb4), '') = BINARY '📈' THEN 'invest' + WHEN BINARY REPLACE(`icon`, CONVERT(0xEFB88F USING utf8mb4), '') = BINARY '💼' THEN 'parttime' + -- 收支共用的「其他」 + WHEN BINARY REPLACE(`icon`, CONVERT(0xEFB88F USING utf8mb4), '') = BINARY '📦' THEN 'other' + ELSE `icon` + END +-- 只处理「还不是图标名」的行:图标名只含 a-z / 0-9 / 连字符, +-- 出现别的字符就说明还是 emoji +WHERE `icon` IS NOT NULL AND `icon` REGEXP '[^a-zA-Z0-9_-]'; + +-- 核对:跑完 15 个分类的图标应该各不相同(两个「其他」都是 other) +-- SELECT id, name, icon, type FROM category ORDER BY type, sort_order; diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index 8519ec6..a6225eb 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -160,25 +160,28 @@ CREATE TABLE IF NOT EXISTS `note` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='笔记表'; -- 插入预设分类(支出) +-- icon 是「图标名」不是 emoji(2026-09-24 起),对应 App 的 assets/icons/.svg。 +-- 用图标名是为了跨端画风一致:同一个 emoji 在安卓/iOS 上长得不一样。 +-- 存量数据的迁移见 migration_icons_to_names.sql INSERT INTO `category` (`user_id`, `name`, `icon`, `type`, `sort_order`) VALUES -(NULL, '餐饮', '🍔', 1, 1), -(NULL, '交通', '🚗', 1, 2), -(NULL, '购物', '🛍️', 1, 3), -(NULL, '娱乐', '🎬', 1, 4), -(NULL, '医疗', '🏥', 1, 5), -(NULL, '教育', '📚', 1, 6), -(NULL, '住房', '🏠', 1, 7), -(NULL, '水电', '💡', 1, 8), -(NULL, '通讯', '📱', 1, 9), -(NULL, '其他', '📦', 1, 10); +(NULL, '餐饮', 'food', 1, 1), +(NULL, '交通', 'transport', 1, 2), +(NULL, '购物', 'shopping', 1, 3), +(NULL, '娱乐', 'entertainment', 1, 4), +(NULL, '医疗', 'medical', 1, 5), +(NULL, '教育', 'education', 1, 6), +(NULL, '住房', 'housing', 1, 7), +(NULL, '水电', 'utilities', 1, 8), +(NULL, '通讯', 'phone', 1, 9), +(NULL, '其他', 'other', 1, 10); -- 插入预设分类(收入) INSERT INTO `category` (`user_id`, `name`, `icon`, `type`, `sort_order`) VALUES -(NULL, '工资', '💰', 2, 1), -(NULL, '奖金', '🎁', 2, 2), -(NULL, '投资', '📈', 2, 3), -(NULL, '兼职', '💼', 2, 4), -(NULL, '其他', '📦', 2, 5); +(NULL, '工资', 'salary', 2, 1), +(NULL, '奖金', 'bonus', 2, 2), +(NULL, '投资', 'invest', 2, 3), +(NULL, '兼职', 'parttime', 2, 4), +(NULL, '其他', 'other', 2, 5); -- AI 会话表 CREATE TABLE IF NOT EXISTS `chat_session` ( diff --git a/src/main/resources/static/admin/admin.css b/src/main/resources/static/admin/admin.css index 9241bca..6abc2d9 100644 --- a/src/main/resources/static/admin/admin.css +++ b/src/main/resources/static/admin/admin.css @@ -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; } diff --git a/src/main/resources/static/admin/admin.js b/src/main/resources/static/admin/admin.js index 168cab3..7e0f812 100644 --- a/src/main/resources/static/admin/admin.js +++ b/src/main/resources/static/admin/admin.js @@ -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; + } + + /** 图标 ,默认 20px */ + function iconImg(raw, size) { + var s = size || 20; + return ''; + } + // ---------------------------------------------------------------- 登录 - 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 = '' + - '' + opt.icon + '' + + '' + iconImg(opt.icon, 15) + '' + '' + esc(opt.name) + '' + '' + opt.count + ''; @@ -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 = '
' + '

' + highlight(n.title, state.keyword) + '

' + - (n.isPinned ? '📌' : '') + + (n.isPinned ? '' + iconImg('pin', 14) + '' : '') + '
' + '
' + (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 = '
还没有对话
'; + return; + } + ai.sessions.forEach(function (s) { + var el = document.createElement('div'); + el.className = 'ai-session' + (s.id === ai.sessionId ? ' active' : ''); + el.innerHTML = '' + esc(s.title) + '' + + '' + esc(formatTime(s.updateTime)) + '' + + '×'; + 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 = '
加载中…
'; + + 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 = '
' + + esc(msg || '问点什么吧 —— 我能查你的账单和笔记,也能联网搜索。') + '
'; + } + + 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 = '
加载中…
'; + + 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 = '
' + esc(e.message) + '
'; + 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 += '
' + + 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 += '
' + + '
' + + '
本月预算' + pct + '%
' + + '
' + + '
已用 ¥' + money(used) + ' / ¥' + money(total) + '
' + + '
剩余 ¥' + money(b.remainingAmount) + + ' · 日均可用 ¥' + money(b.remainingDaily) + '
' + + '
'; + } else if (finIsCurrentMonth()) { + html += '
' + + '
本月还没设预算
'; + } + + $('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 '
' + label + '' + + '' + text + '
'; + } + + // ---------------------------------------------------------------- 侧栏:分类 + + 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 = '
暂无
'; + 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 '
' + + '
' + + '' + iconImg(c.categoryIcon, 16) + ' ' + esc(c.categoryName) + '' + + '¥' + money(c.amount) + '' + + '
' + + '
' + + '
'; + }).join(''); + + Array.prototype.forEach.call(box.querySelectorAll('.fin-cat'), function (el) { + el.onclick = function () { finToggleCategory(el.getAttribute('data-cat')); }; + }); + } + + function finRenderCategoryOptions() { + var html = ''; + // 支出在前收入在后,和 App 里的顺序一致 + [1, 2].forEach(function (t) { + fin.categories.filter(function (c) { return c.type === t; }) + .forEach(function (c) { + html += ''; + }); + }); + $('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 = '
' + + (fin.bills.length ? '没有符合筛选条件的账单' : '这个月还没有账单') + '
'; + 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 = '
' + finDayLabel(g.date) + ''; + if (g.out) head += '支 ¥' + money(g.out) + ''; + if (g.income) head += '收 ¥' + money(g.income) + ''; + head += '
'; + return head + g.items.map(finBillRow).join(''); + }).join(''); + } + + function finBillRow(b) { + var out = b.type !== 2; + return '
' + + '' + iconImg(b.categoryIcon, 22) + '' + + '' + esc(b.categoryName || '') + '' + + '' + esc(b.description || '') + '' + + '' + + (out ? '-' : '+') + '¥' + money(b.amount) + + '' + + '
'; + } + + /** 今天/昨天/周几 —— 只看日期串没感觉,加上这层更容易定位 */ + 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') diff --git a/src/main/resources/static/admin/icons.js b/src/main/resources/static/admin/icons.js new file mode 100644 index 0000000..fb7e825 --- /dev/null +++ b/src/main/resources/static/admin/icons.js @@ -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', + }, +}; diff --git a/src/main/resources/static/admin/icons/assistant.svg b/src/main/resources/static/admin/icons/assistant.svg new file mode 100644 index 0000000..c143800 --- /dev/null +++ b/src/main/resources/static/admin/icons/assistant.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/static/admin/icons/bill.svg b/src/main/resources/static/admin/icons/bill.svg new file mode 100644 index 0000000..4b0a614 --- /dev/null +++ b/src/main/resources/static/admin/icons/bill.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/static/admin/icons/bonus.svg b/src/main/resources/static/admin/icons/bonus.svg new file mode 100644 index 0000000..493de0a --- /dev/null +++ b/src/main/resources/static/admin/icons/bonus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/main/resources/static/admin/icons/camera.svg b/src/main/resources/static/admin/icons/camera.svg new file mode 100644 index 0000000..1cd5237 --- /dev/null +++ b/src/main/resources/static/admin/icons/camera.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/static/admin/icons/check.svg b/src/main/resources/static/admin/icons/check.svg new file mode 100644 index 0000000..bbe4544 --- /dev/null +++ b/src/main/resources/static/admin/icons/check.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/static/admin/icons/education.svg b/src/main/resources/static/admin/icons/education.svg new file mode 100644 index 0000000..f40fcbc --- /dev/null +++ b/src/main/resources/static/admin/icons/education.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/static/admin/icons/entertainment.svg b/src/main/resources/static/admin/icons/entertainment.svg new file mode 100644 index 0000000..19529a0 --- /dev/null +++ b/src/main/resources/static/admin/icons/entertainment.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/static/admin/icons/error.svg b/src/main/resources/static/admin/icons/error.svg new file mode 100644 index 0000000..1b4f826 --- /dev/null +++ b/src/main/resources/static/admin/icons/error.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/static/admin/icons/expense.svg b/src/main/resources/static/admin/icons/expense.svg new file mode 100644 index 0000000..c469d14 --- /dev/null +++ b/src/main/resources/static/admin/icons/expense.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/static/admin/icons/eye.svg b/src/main/resources/static/admin/icons/eye.svg new file mode 100644 index 0000000..a42b49b --- /dev/null +++ b/src/main/resources/static/admin/icons/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/static/admin/icons/file.svg b/src/main/resources/static/admin/icons/file.svg new file mode 100644 index 0000000..6b24d8f --- /dev/null +++ b/src/main/resources/static/admin/icons/file.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/static/admin/icons/folder-open.svg b/src/main/resources/static/admin/icons/folder-open.svg new file mode 100644 index 0000000..dd077a3 --- /dev/null +++ b/src/main/resources/static/admin/icons/folder-open.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/static/admin/icons/folder.svg b/src/main/resources/static/admin/icons/folder.svg new file mode 100644 index 0000000..5cca37b --- /dev/null +++ b/src/main/resources/static/admin/icons/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main/resources/static/admin/icons/food.svg b/src/main/resources/static/admin/icons/food.svg new file mode 100644 index 0000000..a4fabe0 --- /dev/null +++ b/src/main/resources/static/admin/icons/food.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/static/admin/icons/housing.svg b/src/main/resources/static/admin/icons/housing.svg new file mode 100644 index 0000000..223431e --- /dev/null +++ b/src/main/resources/static/admin/icons/housing.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/static/admin/icons/image.svg b/src/main/resources/static/admin/icons/image.svg new file mode 100644 index 0000000..cc853a2 --- /dev/null +++ b/src/main/resources/static/admin/icons/image.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/static/admin/icons/inbox.svg b/src/main/resources/static/admin/icons/inbox.svg new file mode 100644 index 0000000..62786c8 --- /dev/null +++ b/src/main/resources/static/admin/icons/inbox.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/static/admin/icons/income.svg b/src/main/resources/static/admin/icons/income.svg new file mode 100644 index 0000000..342a29d --- /dev/null +++ b/src/main/resources/static/admin/icons/income.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/static/admin/icons/invest.svg b/src/main/resources/static/admin/icons/invest.svg new file mode 100644 index 0000000..79971c5 --- /dev/null +++ b/src/main/resources/static/admin/icons/invest.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/main/resources/static/admin/icons/lock.svg b/src/main/resources/static/admin/icons/lock.svg new file mode 100644 index 0000000..b5122ee --- /dev/null +++ b/src/main/resources/static/admin/icons/lock.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/static/admin/icons/medical.svg b/src/main/resources/static/admin/icons/medical.svg new file mode 100644 index 0000000..a8f5bd0 --- /dev/null +++ b/src/main/resources/static/admin/icons/medical.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/static/admin/icons/note.svg b/src/main/resources/static/admin/icons/note.svg new file mode 100644 index 0000000..11fab71 --- /dev/null +++ b/src/main/resources/static/admin/icons/note.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/static/admin/icons/other.svg b/src/main/resources/static/admin/icons/other.svg new file mode 100644 index 0000000..9cb5a69 --- /dev/null +++ b/src/main/resources/static/admin/icons/other.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/static/admin/icons/parttime.svg b/src/main/resources/static/admin/icons/parttime.svg new file mode 100644 index 0000000..79671be --- /dev/null +++ b/src/main/resources/static/admin/icons/parttime.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/static/admin/icons/phone.svg b/src/main/resources/static/admin/icons/phone.svg new file mode 100644 index 0000000..f13724d --- /dev/null +++ b/src/main/resources/static/admin/icons/phone.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/static/admin/icons/pin.svg b/src/main/resources/static/admin/icons/pin.svg new file mode 100644 index 0000000..227a76c --- /dev/null +++ b/src/main/resources/static/admin/icons/pin.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/static/admin/icons/salary.svg b/src/main/resources/static/admin/icons/salary.svg new file mode 100644 index 0000000..b362978 --- /dev/null +++ b/src/main/resources/static/admin/icons/salary.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/static/admin/icons/search.svg b/src/main/resources/static/admin/icons/search.svg new file mode 100644 index 0000000..3762ba6 --- /dev/null +++ b/src/main/resources/static/admin/icons/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/static/admin/icons/shopping.svg b/src/main/resources/static/admin/icons/shopping.svg new file mode 100644 index 0000000..157442b --- /dev/null +++ b/src/main/resources/static/admin/icons/shopping.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/static/admin/icons/target.svg b/src/main/resources/static/admin/icons/target.svg new file mode 100644 index 0000000..73d00b5 --- /dev/null +++ b/src/main/resources/static/admin/icons/target.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/static/admin/icons/transport.svg b/src/main/resources/static/admin/icons/transport.svg new file mode 100644 index 0000000..6d05504 --- /dev/null +++ b/src/main/resources/static/admin/icons/transport.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/main/resources/static/admin/icons/user.svg b/src/main/resources/static/admin/icons/user.svg new file mode 100644 index 0000000..9646a13 --- /dev/null +++ b/src/main/resources/static/admin/icons/user.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/static/admin/icons/utilities.svg b/src/main/resources/static/admin/icons/utilities.svg new file mode 100644 index 0000000..7122029 --- /dev/null +++ b/src/main/resources/static/admin/icons/utilities.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/static/admin/index.html b/src/main/resources/static/admin/index.html index d06aceb..a520de7 100644 --- a/src/main/resources/static/admin/index.html +++ b/src/main/resources/static/admin/index.html @@ -9,20 +9,46 @@ - +
-