AI接入 v1.0
UI更新
@@ -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("搜索服务返回了无法解析的内容");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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` (
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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 |
@@ -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>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 135 KiB |