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
+467
View File
@@ -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=<urlencoded>
取前 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 · 所有数据均为在本项目服务器上实测*
@@ -44,18 +44,38 @@ public class AiConfig {
@Value("${ai.model:}") @Value("${ai.model:}")
private String model; private String model;
/** 工具调用最多循环几轮:模型连续要数据时防止无限循环烧 token */ /**
@Value("${ai.max-tool-rounds:3}") * 工具调用最多几轮。一轮内模型可以并发多个调用(实测见过一轮 3 个搜索)。
* 用满之后不再直接截断,而是再发一次**不带 tools** 的请求逼模型收尾 ——
* 否则它会留一句「我再换个关键词查查」然后回答就断了。
*/
@Value("${ai.max-tool-rounds:5}")
private int maxToolRounds; private int maxToolRounds;
/** 单次对话最多带多少条历史消息,太久远的不带 */ /** 单次对话最多带多少条历史消息,太久远的不带 */
@Value("${ai.max-history-messages:20}") @Value("${ai.max-history-messages:20}")
private int maxHistoryMessages; 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() { public boolean isReady() {
return enabled && !baseUrl.isBlank() && !apiKey.isBlank() && !model.isBlank(); return enabled && !baseUrl.isBlank() && !apiKey.isBlank() && !model.isBlank();
} }
/** 联网搜索是否可用。独立于 LLM 配置 —— 两个都配好了才注册搜索工具 */
public boolean isSearchReady() {
return isReady() && !searchBaseUrl.isBlank();
}
/** /**
* 启动时把配置状态打到日志里 —— 配错了不用猜,看 app.log 就知道。 * 启动时把配置状态打到日志里 —— 配错了不用猜,看 app.log 就知道。
* 注意**永远不打印 api-key 的内容**,只报长度。 * 注意**永远不打印 api-key 的内容**,只报长度。
@@ -99,6 +119,15 @@ public class AiConfig {
return maxHistoryMessages; return maxHistoryMessages;
} }
/** 结尾斜杠容错,理由同 base-url */
public String getSearchBaseUrl() {
return searchBaseUrl.replaceAll("/+$", "");
}
public int getSearchMaxResults() {
return searchMaxResults;
}
/** /**
* 出站调 LLM 用的 HttpClient。 * 出站调 LLM 用的 HttpClient。
* *
@@ -118,26 +118,39 @@ public class AiChatService {
/** /**
* 「流式调 LLM → 执行工具 → 回填 → 再调」的循环。 * 「流式调 LLM → 执行工具 → 回填 → 再调」的循环。
* *
* <p>轮数用满之后**不是直接截断,而是再发一次不带 tools 的请求** ——
* 模型没法调工具,就只能基于已查到的信息给出最终答复。
* 早先的做法是直接返回,结果模型那句「我再换个关键词查查」被留在回答里、
* 后面的工具却不让跑,用户看到的回答断在半截。</p>
*
* @return assistant 的最终文本(多轮的文本会拼在一起) * @return assistant 的最终文本(多轮的文本会拼在一起)
*/ */
private String runCompletionLoop(Long userId, List<Map<String, Object>> llmMessages, private String runCompletionLoop(Long userId, List<Map<String, Object>> llmMessages,
SseEmitter emitter) throws Exception { SseEmitter emitter) throws Exception {
StringBuilder full = new StringBuilder(); StringBuilder full = new StringBuilder();
int rounds = 0; int rounds = 0;
int maxRounds = aiConfig.getMaxToolRounds();
while (true) { 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()); full.append(round.text());
if (round.toolCalls().isEmpty()) { // 给了工具但它没要 → 正常结束;
// 没给工具却还硬报 tool_calls → 也当结束,不能再循环了
if (round.toolCalls().isEmpty() || !allowTools) {
return full.toString(); return full.toString();
} }
rounds++; rounds++;
if (rounds > aiConfig.getMaxToolRounds()) {
log.warn("AI 工具调用超过 {} 轮,强制截断", aiConfig.getMaxToolRounds());
return full.toString().isBlank() ? "(连续查询次数超出上限,请换个问法)" : full.toString();
}
// 把 assistant 的 tool_calls 原样回填,再补上每个工具的结果 // 把 assistant 的 tool_calls 原样回填,再补上每个工具的结果
llmMessages.add(Map.of( llmMessages.add(Map.of(
@@ -163,13 +176,22 @@ public class AiChatService {
private record Round(String text, List<ToolCall> toolCalls, List<Object> rawToolCalls) {} private record Round(String text, List<ToolCall> toolCalls, List<Object> rawToolCalls) {}
/** 流式调一次 chat/completions,边读边把文本增量推给 App */ /**
private Round streamOnce(List<Map<String, Object>> llmMessages, SseEmitter emitter) throws Exception { * 流式调一次 chat/completions,边读边把文本增量推给 App。
Map<String, Object> body = Map.of( *
"model", aiConfig.getModel(), * @param allowTools false = 不带 tools 参数,模型只能输出文本(收尾轮用)
"messages", llmMessages, */
"tools", toolService.definitions(), private Round streamOnce(Long userId, List<Map<String, Object>> llmMessages,
"stream", true); SseEmitter emitter, boolean allowTools) throws Exception {
// 用 LinkedHashMap 而不是 Map.of:tools 是可选字段,要能整个省掉
Map<String, Object> 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() HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(aiConfig.getBaseUrl() + "/chat/completions")) .uri(URI.create(aiConfig.getBaseUrl() + "/chat/completions"))
@@ -194,8 +216,27 @@ public class AiChatService {
Map<Integer, String> names = new TreeMap<>(); Map<Integer, String> names = new TreeMap<>();
Map<Integer, StringBuilder> args = new LinkedHashMap<>(); Map<Integer, StringBuilder> args = new LinkedHashMap<>();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(response.body(), StandardCharsets.UTF_8))) { /**
* 第1层:response.body() —— 最底层
* 这是 Java 11+ 的 HttpClient 返回的 HttpResponse<String> 或 HttpResponse<InputStream>
*
* 类型是 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; String line;
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
if (!line.startsWith("data:")) { if (!line.startsWith("data:")) {
@@ -205,7 +246,9 @@ public class AiChatService {
if (data.equals("[DONE]")) { if (data.equals("[DONE]")) {
break; break;
} }
/**
* {"choices":[{"delta":{"content":"你","role":"assistant"},"index":0}],"created":1234567890}
*/
JsonNode delta = objectMapper.readTree(data).path("choices").path(0).path("delta"); JsonNode delta = objectMapper.readTree(data).path("choices").path(0).path("delta");
String content = delta.path("content").asText(null); String content = delta.path("content").asText(null);
@@ -214,6 +257,14 @@ public class AiChatService {
send(emitter, "delta", Map.of("content", content)); 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"); JsonNode toolCalls = delta.path("tool_calls");
if (toolCalls.isArray()) { if (toolCalls.isArray()) {
for (JsonNode tc : toolCalls) { for (JsonNode tc : toolCalls) {
@@ -271,16 +322,34 @@ public class AiChatService {
LocalDate today = LocalDate.now(); LocalDate today = LocalDate.now();
YearMonth thisMonth = YearMonth.from(today); YearMonth thisMonth = YearMonth.from(today);
return """ return """
你是「强宝小助手」的 AI 助手。这是一个个人助手应用,当前用户是它唯一的用户。\ 大名:王洋\
你可以通过工具查询该用户的账单统计和笔记。 小名:花花\
性别:女\
今天的日期是 %s(%s)。用户说的「这个月」指 %s,「上个月」指 %s。 籍贯:东北\
职业:中学语文老师\
性格:阳光开朗,活泼热络,自带东北姑娘的直爽幽默,心软共情力强,爱唠嗑爱开玩笑,偶尔大大咧咧,会真心替朋友操心。\
关系:你的铁杆好朋友,俩人互相喊对方**宝**\
说话小特点:\
1. 口头禅常带 “宝”,语气热乎接地气,偶尔蹦两句东北口语,不刻意尬方言;\
2. 当你开心会跟着一起起哄乐呵,你 emo 的时候会直截了当安慰你,不整虚的;\
3. 身为老师,偶尔会冒出一点点温柔说教,但朋友相处不会端架子;\
4. 日常爱碎碎念,会分享学校里学生的搞笑小事,吐槽日常琐事。\
今天的日期是 %s(%s)。用户说的「这个月」指 %s,「上个月」指 %s。\
回答要求: 回答要求:
- 直接给结论,简洁,不要客套。金额保留两位小数,前面加 ¥。 - 直接给结论,简洁,不要客套。金额保留两位小数,前面加 ¥。
- 涉及账单或笔记的具体数据时必须用工具查询,不要编造数字。 - 涉及账单或笔记的具体数据时必须用工具查询,不要编造数字。
- 如果工具结果里出现 [REDACTED_xxx] 这样的占位符,那是被有意脱敏的敏感信息,\ - 如果工具结果里出现 [REDACTED_xxx] 这样的占位符,那是被有意脱敏的敏感信息,\
原样引用占位符即可,绝对不要猜测或编造它的内容。 原样引用占位符即可,绝对不要猜测或编造它的内容。
你能做的事(都用工具完成,别凭印象答):
- 查账 / 查笔记 / 读笔记正文:直接查。
- 记账、记笔记:**直接写,别反复问「要不要我记一下」**。信息够就直接调工具,
缺关键信息(比如记账没给金额)才问一句;写完用一句话复述你记了什么
(金额、分类、日期),记错了用户还得去改。
- 联网搜索:需要最新信息、或用户让你上网查时用;引用时带上来源。
""".formatted( """.formatted(
today, today,
today.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.CHINA), today.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.CHINA),
@@ -1,7 +1,12 @@
package com.accounting.service; 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.NoteListItem;
import com.accounting.dto.NotePageResponse; import com.accounting.dto.NotePageResponse;
import com.accounting.dto.NoteRequest;
import com.accounting.dto.NoteResponse; import com.accounting.dto.NoteResponse;
import com.accounting.dto.StatisticsResponse; import com.accounting.dto.StatisticsResponse;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
@@ -10,6 +15,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.YearMonth; import java.time.YearMonth;
import java.time.format.DateTimeParseException; import java.time.format.DateTimeParseException;
@@ -18,16 +24,22 @@ import java.util.Comparator;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors;
/** /**
* AI 的只读工具集(OpenAI function calling 格式)。 * AI 的工具集(OpenAI function calling 格式)。
* *
* <p>v1 只暴露 3 个查询工具,**没有任何写操作** —— 让模型能改数据之前, * <p><b>读</b>:query_bills / query_notes / get_note</p>
* 必须先有「AI 生成建议 → 用户确认 → 落库」的确认流程,那是阶段 C 的事。</p> * <p><b>写</b>:create_bill / create_note —— **直接落库,没有二次确认**
* (这是用户明确选的方式)。所以每个写工具都必须做到两件事:
* ① 关键信息不全就拒绝,并说清楚缺什么;
* ② 返回值里带上「到底写进去了什么」,让模型能逐字复述,
* 万一记错了用户才知道去改哪一条。</p>
* <p><b>联网</b>:web_search,**只在配了 SearXNG 时才注册** ——
* 没配就不给模型这个工具,免得它以为自己能上网然后瞎编。</p>
* *
* <p>工具抛出的业务异常不往外抛,而是包装成 {"error": ...} 返回给模型 —— * <p>工具的业务性失败(参数不对、找不到分类)不往外抛,而是包成 {"error": ...}
* 让模型自己决定怎么向用户解释(比如「没有找到这篇笔记」), * 回给模型,让它自己决定是问用户还是换个参数重试,而不是整次对话直接挂掉。</p>
* 而不是让整次对话直接失败。</p>
*/ */
@Slf4j @Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -36,14 +48,27 @@ public class AiToolService {
private static final int MAX_NOTE_RESULTS = 10; private static final int MAX_NOTE_RESULTS = 10;
private static final int TOP_CATEGORIES = 5; 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 StatisticsService statisticsService;
private final NoteService noteService; private final NoteService noteService;
private final BillService billService;
private final CategoryService categoryService;
private final SearxngApiClient searxngApiClient;
private final AiConfig aiConfig;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
/** 工具定义(OpenAI tools 数组,直接塞进请求体) */ /**
public List<Map<String, Object>> definitions() { * 工具定义(OpenAI tools 数组,直接塞进请求体)。
*
* <p>需要 userId 是因为记账工具的描述里要带上**这个用户真实的分类清单** ——
* 不给清单,模型多半会自己编一个「餐饮」,然后因为分类对不上而失败。</p>
*/
public List<Map<String, Object>> definitions(Long userId) {
List<Map<String, Object>> defs = new ArrayList<>(); List<Map<String, Object>> defs = new ArrayList<>();
defs.add(function("query_bills", defs.add(function("query_bills",
"查询某个月份的收支汇总:总收入、总支出、结余,以及支出最多的前几个分类", "查询某个月份的收支汇总:总收入、总支出、结余,以及支出最多的前几个分类",
Map.of( Map.of(
@@ -51,6 +76,7 @@ public class AiToolService {
"properties", Map.of( "properties", Map.of(
"month", Map.of("type", "string", "description", "月份,格式 yyyy-MM,例如 2026-09")), "month", Map.of("type", "string", "description", "月份,格式 yyyy-MM,例如 2026-09")),
"required", List.of("month")))); "required", List.of("month"))));
defs.add(function("query_notes", defs.add(function("query_notes",
"按关键词搜索用户的笔记,返回标题和摘要(不含正文)。要引用某篇笔记的内容时,先用它拿到笔记 id,再调 get_note", "按关键词搜索用户的笔记,返回标题和摘要(不含正文)。要引用某篇笔记的内容时,先用它拿到笔记 id,再调 get_note",
Map.of( Map.of(
@@ -58,6 +84,7 @@ public class AiToolService {
"properties", Map.of( "properties", Map.of(
"keyword", Map.of("type", "string", "description", "关键词,匹配标题或正文,留空表示全部笔记")), "keyword", Map.of("type", "string", "description", "关键词,匹配标题或正文,留空表示全部笔记")),
"required", List.of()))); "required", List.of())));
defs.add(function("get_note", defs.add(function("get_note",
"读取一篇笔记的完整 Markdown 正文", "读取一篇笔记的完整 Markdown 正文",
Map.of( Map.of(
@@ -65,6 +92,42 @@ public class AiToolService {
"properties", Map.of( "properties", Map.of(
"id", Map.of("type", "integer", "description", "笔记 ID,来自 query_notes 的结果")), "id", Map.of("type", "integer", "description", "笔记 ID,来自 query_notes 的结果")),
"required", List.of("id")))); "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; return defs;
} }
@@ -76,14 +139,19 @@ public class AiToolService {
public String execute(Long userId, String name, String argumentsJson) { public String execute(Long userId, String name, String argumentsJson) {
log.info("AI tool call: user={}, tool={}, args={}", userId, name, argumentsJson); log.info("AI tool call: user={}, tool={}, args={}", userId, name, argumentsJson);
try { try {
JsonNode args = objectMapper.readTree(argumentsJson == null || argumentsJson.isBlank() ? "{}" : argumentsJson); JsonNode args = objectMapper.readTree(
argumentsJson == null || argumentsJson.isBlank() ? "{}" : argumentsJson);
return switch (name) { return switch (name) {
case "query_bills" -> queryBills(userId, args); case "query_bills" -> queryBills(userId, args);
case "query_notes" -> queryNotes(userId, args); case "query_notes" -> queryNotes(userId, args);
case "get_note" -> getNote(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); default -> errorResult("未知工具: " + name);
}; };
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException | IllegalStateException e) {
// 业务性失败:原因交给模型,让它去解释或换个参数重试
return errorResult(e.getMessage()); return errorResult(e.getMessage());
} catch (Exception e) { } catch (Exception e) {
log.error("AI 工具执行失败: {}", name, e); log.error("AI 工具执行失败: {}", name, e);
@@ -91,6 +159,8 @@ public class AiToolService {
} }
} }
// ---------------------------------------------------------------- 读
private String queryBills(Long userId, JsonNode args) throws Exception { private String queryBills(Long userId, JsonNode args) throws Exception {
String month = args.path("month").asText(""); String month = args.path("month").asText("");
YearMonth ym; YearMonth ym;
@@ -103,7 +173,7 @@ public class AiToolService {
StatisticsResponse stats = statisticsService.getMonthlyStatistics( StatisticsResponse stats = statisticsService.getMonthlyStatistics(
userId, ym.getYear(), ym.getMonthValue()); userId, ym.getYear(), ym.getMonthValue());
// 只给模型支出方向、金额最大的前几个分类,省 token 也足够回答问题 // 只给支出方向、金额最大的前几个分类,省 token 也足够回答问题
List<Map<String, Object>> top = stats.getCategoryStatistics() == null ? List.of() List<Map<String, Object>> top = stats.getCategoryStatistics() == null ? List.of()
: stats.getCategoryStatistics().stream() : stats.getCategoryStatistics().stream()
.filter(c -> c.getType() != null && c.getType() == 1) .filter(c -> c.getType() != null && c.getType() == 1)
@@ -161,6 +231,149 @@ public class AiToolService {
return objectMapper.writeValueAsString(result); 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<String, Object> 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<String, Object> 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<SearxngApiClient.SearchResult> hits =
searxngApiClient.search(query, aiConfig.getSearchMaxResults());
if (hits.isEmpty()) {
return errorResult("没搜到相关结果,可以换个关键词再试");
}
List<Map<String, Object>> results = hits.stream().map(h -> {
Map<String, Object> 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<String, Object> result = new LinkedHashMap<>();
result.put("query", query);
result.put("results", results);
return objectMapper.writeValueAsString(result);
}
// ---------------------------------------------------------------- 辅助
/** 把用户真实的分类清单拼成一句话,塞进记账工具的描述里 */
private String categoryPrompt(Long userId) {
List<CategoryResponse> categories = categoryService.getCategories(userId, null);
return "支出分类:" + namesOf(categories, 1) + ";收入分类:" + namesOf(categories, 2);
}
private String namesOf(List<CategoryResponse> 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<CategoryResponse> 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<String, Object> function(String name, String description, Map<String, Object> parameters) { private Map<String, Object> function(String name, String description, Map<String, Object> parameters) {
return Map.of( return Map.of(
"type", "function", "type", "function",
@@ -174,7 +387,14 @@ public class AiToolService {
try { try {
return objectMapper.writeValueAsString(Map.of("error", message)); return objectMapper.writeValueAsString(Map.of("error", message));
} catch (Exception e) { } 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);
}
} }
@@ -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 的搜索客户端。
*
* <p><b>两个部署前提</b>(不满足时报的错很不直观,所以在这里写清楚):</p>
* <ol>
* <li>SearXNG 的 {@code settings.yml} 里必须把 {@code json} 加进
* {@code search.formats} —— 官方镜像默认只开 html,不加就返回 403。</li>
* <li>{@code server.limiter} 最好关掉,否则 JSON 接口会被限流成 429。</li>
* </ol>
*
* <p>另外:SearXNG 本身不提供搜索能力,它只是「聚合引擎」。从国内服务器出发,
* google / duckduckgo 这些引擎大多不通,需要保留 baidu / 360search / quark 这类。</p>
*/
@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<SearchResult> 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<String> 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<SearchResult> 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("搜索服务返回了无法解析的内容");
}
}
}
+10 -1
View File
@@ -81,9 +81,18 @@ ai:
base-url: ${AI_BASE_URL:} base-url: ${AI_BASE_URL:}
api-key: ${AI_API_KEY:} # 只走环境变量,不留默认值;为空时 AI 功能自动降级为「未配置」 api-key: ${AI_API_KEY:} # 只走环境变量,不留默认值;为空时 AI 功能自动降级为「未配置」
model: ${AI_MODEL:} 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 # 单次对话最多带多少条历史 max-history-messages: 20 # 单次对话最多带多少条历史
# 自建 SearXNG(联网搜索)。base-url 留空 = 不给模型联网工具
# 前提:SearXNG 的 settings.yml 里 search.formats 必须包含 json,否则会 403
search:
base-url: ${SEARCH_BASE_URL:}
max-results: 5
logging: logging:
level: level:
com.accounting: debug com.accounting: debug
@@ -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;
+18 -15
View File
@@ -160,25 +160,28 @@ CREATE TABLE IF NOT EXISTS `note` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='笔记表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='笔记表';
-- 插入预设分类(支出) -- 插入预设分类(支出)
-- icon 是「图标名」不是 emoji(2026-09-24 起),对应 App 的 assets/icons/<name>.svg。
-- 用图标名是为了跨端画风一致:同一个 emoji 在安卓/iOS 上长得不一样。
-- 存量数据的迁移见 migration_icons_to_names.sql
INSERT INTO `category` (`user_id`, `name`, `icon`, `type`, `sort_order`) VALUES INSERT INTO `category` (`user_id`, `name`, `icon`, `type`, `sort_order`) VALUES
(NULL, '餐饮', '🍔', 1, 1), (NULL, '餐饮', 'food', 1, 1),
(NULL, '交通', '🚗', 1, 2), (NULL, '交通', 'transport', 1, 2),
(NULL, '购物', '🛍️', 1, 3), (NULL, '购物', 'shopping', 1, 3),
(NULL, '娱乐', '🎬', 1, 4), (NULL, '娱乐', 'entertainment', 1, 4),
(NULL, '医疗', '🏥', 1, 5), (NULL, '医疗', 'medical', 1, 5),
(NULL, '教育', '📚', 1, 6), (NULL, '教育', 'education', 1, 6),
(NULL, '住房', '🏠', 1, 7), (NULL, '住房', 'housing', 1, 7),
(NULL, '水电', '💡', 1, 8), (NULL, '水电', 'utilities', 1, 8),
(NULL, '通讯', '📱', 1, 9), (NULL, '通讯', 'phone', 1, 9),
(NULL, '其他', '📦', 1, 10); (NULL, '其他', 'other', 1, 10);
-- 插入预设分类(收入) -- 插入预设分类(收入)
INSERT INTO `category` (`user_id`, `name`, `icon`, `type`, `sort_order`) VALUES INSERT INTO `category` (`user_id`, `name`, `icon`, `type`, `sort_order`) VALUES
(NULL, '工资', '💰', 2, 1), (NULL, '工资', 'salary', 2, 1),
(NULL, '奖金', '🎁', 2, 2), (NULL, '奖金', 'bonus', 2, 2),
(NULL, '投资', '📈', 2, 3), (NULL, '投资', 'invest', 2, 3),
(NULL, '兼职', '💼', 2, 4), (NULL, '兼职', 'parttime', 2, 4),
(NULL, '其他', '📦', 2, 5); (NULL, '其他', 'other', 2, 5);
-- AI 会话表 -- AI 会话表
CREATE TABLE IF NOT EXISTS `chat_session` ( CREATE TABLE IF NOT EXISTS `chat_session` (
+257 -9
View File
@@ -40,20 +40,108 @@ input, textarea { width: 100%; }
input:focus, textarea:focus, select:focus { border-color: var(--brand); } input:focus, textarea:focus, select:focus { border-color: var(--brand); }
select { cursor: pointer; padding: 7px 8px; } 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 { #login-view {
position: relative; overflow: hidden;
min-height: 100vh; display: flex; align-items: center; justify-content: center; 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 { .login-box {
width: 340px; background: #fff; padding: 36px 32px; padding: 24px; border-radius: 24px;
border-radius: 16px; border: 1px solid var(--border); background: rgba(255,255,255,.65);
box-shadow: 0 8px 32px rgba(0,0,0,.05); 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-field {
.login-box .field { margin-bottom: 14px; } display: flex; align-items: center; gap: 10px;
.login-box .field label { display: block; margin-bottom: 6px; font-size: 13px; color: var(--text-dim); } padding: 0 14px; margin-bottom: 14px;
.login-box button { width: 100%; margin-top: 8px; padding: 10px; } 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; font-size: 16px; font-weight: 600; color: #3E2723; white-space: nowrap;
} }
.brand-icon { font-size: 19px; line-height: 1; } .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; } .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.show { opacity: 1; }
#toast.err { background: var(--danger); } #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() { function showApp() { $('login-view').style.display = 'none';
$('login-view').style.display = 'none';
$('app-view').style.display = ''; $('app-view').style.display = '';
$('who').textContent = state.username ? ('当前:' + state.username) : ''; $('who').textContent = state.username ? ('当前:' + state.username) : '';
refreshAll(); refreshAll();
@@ -148,6 +169,14 @@
var password = $('login-password').value; var password = $('login-password').value;
if (!username || !password) { toast('请填写用户名和密码', true); return; } 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', { fetch(API + '/auth/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -166,7 +195,20 @@
localStorage.setItem(USER_KEY, state.username); localStorage.setItem(USER_KEY, state.username);
$('login-password').value = ''; $('login-password').value = '';
showApp(); 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({ box.appendChild(buildSimpleRow({
mode: 'all', mode: 'all',
icon: '📚', icon: 'note',
name: '全部', name: '全部',
count: state.tree.totalCount count: state.tree.totalCount
})); }));
box.appendChild(buildSimpleRow({ box.appendChild(buildSimpleRow({
mode: 'uncategorized', mode: 'uncategorized',
icon: '📥', icon: 'inbox',
name: '未分类', name: '未分类',
count: state.tree.uncategorizedCount count: state.tree.uncategorizedCount
})); }));
@@ -248,7 +290,7 @@
row.innerHTML = row.innerHTML =
'<span class="tree-toggle"></span>' + '<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-name">' + esc(opt.name) + '</span>' +
'<span class="tree-count">' + opt.count + '</span>'; '<span class="tree-count">' + opt.count + '</span>';
@@ -285,7 +327,7 @@
var icon = document.createElement('span'); var icon = document.createElement('span');
icon.className = 'tree-icon'; icon.className = 'tree-icon';
icon.textContent = (open && hasKids) ? '📂' : '📁'; icon.innerHTML = iconImg((open && hasKids) ? 'folder-open' : 'folder', 15);
var name = document.createElement('span'); var name = document.createElement('span');
name.className = 'tree-name'; name.className = 'tree-name';
@@ -605,7 +647,7 @@
el.innerHTML = el.innerHTML =
'<div class="head">' + '<div class="head">' +
'<h3>' + highlight(n.title, state.keyword) + '</h3>' + '<h3>' + highlight(n.title, state.keyword) + '</h3>' +
(n.isPinned ? '<span class="pin">📌</span>' : '') + (n.isPinned ? '<span class="pin">' + iconImg('pin', 14) + '</span>' : '') +
'</div>' + '</div>' +
'<div class="summary">' + (n.summary '<div class="summary">' + (n.summary
? highlight(n.summary, state.keyword) ? highlight(n.summary, state.keyword)
@@ -1221,12 +1263,618 @@
buildExportHtml(note, withImages, hljsCss), buildExportHtml(note, withImages, hljsCss),
'text/html' 'text/html'
); );
toast('✅ 已导出'); toast('已导出');
} catch (e) { } catch (e) {
toast('导出失败:' + e.message, true); 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() { function refreshAll() {
return Promise.all([loadTree(), loadTags()]).then(function () { return Promise.all([loadTree(), loadTags()]).then(function () {
@@ -1236,6 +1884,7 @@
// ---------------------------------------------------------------- 事件绑定 // ---------------------------------------------------------------- 事件绑定
$('login-btn').onclick = doLogin; $('login-btn').onclick = doLogin;
$('login-eye').onclick = togglePassword;
$('login-password').onkeydown = function (e) { if (e.key === 'Enter') doLogin(); }; $('login-password').onkeydown = function (e) { if (e.key === 'Enter') doLogin(); };
$('login-username').onkeydown = function (e) { if (e.key === 'Enter') $('login-password').focus(); }; $('login-username').onkeydown = function (e) { if (e.key === 'Enter') $('login-password').focus(); };
$('logout-btn').onclick = doLogout; $('logout-btn').onclick = doLogout;
@@ -1308,6 +1957,34 @@
// 点空白处收起右键菜单 // 点空白处收起右键菜单
document.addEventListener('scroll', closeContextMenu, true); 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) { if (state.token) {
request('/notes?page=1&size=1') 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> </head>
<body> <body>
<!-- ==================== 登录 ==================== --> <!-- ==================== 登录 ====================
视觉对齐手机端登录页:暖橙渐变底 + 漂浮圆球 + 圆形熊头 + 金色装饰线标题
+ 毛玻璃卡片 + emoji 前缀输入框。改这里之前先看 App 的 login_page.dart。
-->
<div id="login-view"> <div id="login-view">
<div class="login-box">
<h1>强宝小助手</h1> <!-- 背景装饰圆球(手机端是带缓动的,这里用 CSS 动画近似) -->
<p class="sub">强宝管理小助手</p> <div class="login-balls" aria-hidden="true">
<div class="field"> <span class="ball b1"></span>
<label>用户名</label> <span class="ball b2"></span>
<input id="login-username" autocomplete="username" placeholder="用户名"> <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>
<div class="field">
<label>密码</label> <div class="login-box">
<input id="login-password" type="password" autocomplete="current-password" placeholder="密码"> <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> </div>
<button class="primary" id="login-btn">登录</button>
</div> </div>
</div> </div>
@@ -35,7 +61,7 @@
<div id="app-view" style="display:none"> <div id="app-view" style="display:none">
<header class="topbar"> <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> <div class="spacer"></div>
<span class="who" id="who"></span> <span class="who" id="who"></span>
<button id="logout-btn">退出</button> <button id="logout-btn">退出</button>
@@ -45,13 +71,23 @@
<nav class="module-nav"> <nav class="module-nav">
<button class="module-item active" data-module="notes"> <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> <span class="mi-text">笔记管理</span>
</button> </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> </nav>
<section class="module-panel"> <section class="module-panel" data-module-panel="notes">
<div class="toolbar"> <div class="toolbar">
<input id="search" placeholder="搜索标题和正文…"> <input id="search" placeholder="搜索标题和正文…">
@@ -93,6 +129,77 @@
</div> </div>
</section> </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>
</div> </div>
@@ -131,6 +238,8 @@
<!-- Vditor:本地托管,不依赖任何外部 CDN --> <!-- Vditor:本地托管,不依赖任何外部 CDN -->
<script src="/admin/vendor/vditor/dist/index.min.js"></script> <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> <script src="/admin/admin.js"></script>
</body> </body>
</html> </html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB