Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f5a35395d | |||
| 04c1503d4a | |||
| ee39e0b1c6 | |||
| a6f5ea721d | |||
| 4bff3b8a2f | |||
| fe95fce777 | |||
| 172c2a098f | |||
| 0476b72624 | |||
| 68f7aff914 | |||
| 2f35415bb9 | |||
| a964b9d1ff | |||
| d1e4f89abf | |||
| d953a09a07 | |||
| dd8d7ce6cd | |||
| 7843a3e3a0 | |||
| a2ba8005c3 | |||
| e0bc320cd3 | |||
| 85ac8274f9 | |||
| b40c8a3ee9 | |||
| dd62e5d9ad | |||
| f846013228 | |||
| 00fd9c81bb |
@ -3,7 +3,7 @@ name: Java Maven 3.9.9 & JDK 26 CI/CD Pipeline
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev # 只有推送到 main 分支时才触发自动部署
|
||||
- dev # 只有推送到 dev 分支时才触发自动部署
|
||||
|
||||
env:
|
||||
IMAGE_NAME: trace-img
|
||||
@ -16,16 +16,25 @@ jobs:
|
||||
runs-on: centos-env
|
||||
|
||||
steps:
|
||||
- name: 1. 拉取最新的仓库代码
|
||||
uses: actions/checkout@v4
|
||||
# 替换为以下纯命令形式(docker:cli 镜像中默认自带 git):
|
||||
- name: 1. 拉取最新的仓库代码 (纯 Git 命令行模式)
|
||||
shell: sh
|
||||
run: |
|
||||
# 清空当前工作目录,防止历史残留
|
||||
rm -rf ./*
|
||||
# 利用 Gitea 自动注入的内置变量,动态克隆当前仓库、当前分支、当前提交
|
||||
git clone --depth 1 -b ${{ gitea.ref_name }} http://gitea:3000/${{ gitea.repository }} .
|
||||
git checkout ${{ gitea.sha }}
|
||||
|
||||
- name: 2. 触发 Docker 容器内编译与镜像打包
|
||||
shell: sh
|
||||
run: |
|
||||
echo "🚀 开始构建 JDK 26 镜像: ${IMAGE_NAME}:${{ gitea.sha }}"
|
||||
docker build -t ${IMAGE_NAME}:${{ gitea.sha }} .
|
||||
DOCKER_BUILDKIT=0 docker build --memory=700m -t ${IMAGE_NAME}:${{ gitea.sha }} .
|
||||
docker tag ${IMAGE_NAME}:${{ gitea.sha }} ${IMAGE_NAME}:latest
|
||||
|
||||
- name: 3. 旧容器清理与新容器平滑重启
|
||||
shell: sh
|
||||
run: |
|
||||
echo "🧹 正在清理旧版本的容器..."
|
||||
docker stop ${CONTAINER_NAME} || true
|
||||
@ -34,28 +43,38 @@ jobs:
|
||||
echo "🔄 正在启动全新 JDK 26 容器环境..."
|
||||
docker run -d \
|
||||
--name ${CONTAINER_NAME} \
|
||||
-p ${APP_PORT}:${APP_PORT} \
|
||||
--restart no \
|
||||
-m 512m \
|
||||
-e JAVA_TOOL_OPTIONS="-Xms128m -Xmx384m -XX:+UseG1GC" \
|
||||
--network trace-net \
|
||||
-p 127.0.0.1:${APP_PORT}:${APP_PORT} \
|
||||
--restart unless-stopped \
|
||||
--memory 384m \
|
||||
--memory-swap 768m \
|
||||
--cpus 0.75 \
|
||||
--pids-limit 256 \
|
||||
--env-file /opt/trace/app.env \
|
||||
-e JAVA_TOOL_OPTIONS="-Xms64m -Xmx256m -XX:+UseSerialGC" \
|
||||
${IMAGE_NAME}:latest
|
||||
|
||||
- name: 4. 服务健康检查 (HTTP 探活)
|
||||
shell: sh
|
||||
run: |
|
||||
echo "🔍 开始对端口 ${APP_PORT} 进行健康状态探活..."
|
||||
|
||||
# 循环探活:每隔 5 秒检测一次,最多尝试 15 次(总计 75 秒,给高版本 JVM 充足的预热时间)
|
||||
for i in {1..15}; do
|
||||
for i in 1 2 3 4 5 6; do
|
||||
# 发送轻量级请求,只要服务响应了(无论是200还是Spring默认的404/无路由),都证明 Tomcat/Netty 已拉起
|
||||
if curl -s -f http://localhost:${APP_PORT}/hello > /dev/null; then
|
||||
echo "✅ [SUCCESS] 探活成功!Java 26 服务已成功拉起,并开始提供服务。"
|
||||
exit 0
|
||||
fi
|
||||
echo "⏳ [WAITING] 服务仍在启动中,等待 5 秒后重试 ($i/15)..."
|
||||
if wget -q --spider http://${CONTAINER_NAME}:${APP_PORT}/; then
|
||||
echo "✅ [SUCCESS] 探活成功!Java 26 服务已成功拉起。"
|
||||
echo "🧹 开始安全地清理历史无用镜像及过期构建缓存..."
|
||||
docker image prune -f
|
||||
docker builder prune --filter "until=24h" -f
|
||||
|
||||
exit 0
|
||||
fi
|
||||
echo "⏳ [WAITING] 服务仍在启动中,等待 5 秒后重试 ($i/6)..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo "❌ [ERROR] 探活失败!服务在 75 秒内未能建立连接,可能发生了启动闪退。"
|
||||
echo "❌ [ERROR] 探活失败!服务在 30 秒内未能建立连接,可能发生了启动闪退。"
|
||||
echo "===== 以下为容器崩溃前的实时日志 ====="
|
||||
docker logs ${CONTAINER_NAME}
|
||||
exit 1
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@ -1,4 +1,10 @@
|
||||
target/
|
||||
deploy/.env
|
||||
deploy/stack.env
|
||||
deploy/app.env
|
||||
deploy/runner-token
|
||||
deploy/data/
|
||||
.migration-private/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
!**/src/main/**/target/
|
||||
!**/src/test/**/target/
|
||||
@ -36,4 +42,4 @@ build/
|
||||
.vscode/
|
||||
|
||||
### Mac OS ###
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
|
||||
4
.idea/dataSources.xml
generated
4
.idea/dataSources.xml
generated
@ -1,12 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="tracecd@192.168.2.5" uuid="911f82d8-654e-4293-bc95-e53e9a4e6769">
|
||||
<data-source source="LOCAL" name="trace@192.168.2.5" uuid="911f82d8-654e-4293-bc95-e53e9a4e6769">
|
||||
<driver-ref>mysql.8</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<imported>true</imported>
|
||||
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:mysql://192.168.2.5:3306/tracecd?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true</jdbc-url>
|
||||
<jdbc-url>jdbc:mysql://192.168.2.5:3306/trace?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
|
||||
@ -36,4 +36,4 @@ Standard Maven layout:
|
||||
- `src/main/resources/` — resources bundled with the app
|
||||
- `src/test/java/` — test source
|
||||
|
||||
Group ID: `com.l` | Artifact ID: `tracecd` | Version: `1.0-SNAPSHOT`
|
||||
Group ID: `com.l` | Artifact ID: `trace` | Version: `1.0-SNAPSHOT`
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
# === 第一阶段:使用 Maven 3.9.9 + JDK 26 进行编译打包 ===
|
||||
FROM maven:3.9.9-eclipse-temurin-26 AS builder
|
||||
FROM maven:3.9.16-eclipse-temurin-26 AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# 1C/1G 部署机上限制 Maven 峰值内存,避免构建挤占运行中的服务。
|
||||
ENV MAVEN_OPTS="-Xms64m -Xmx384m -XX:+UseSerialGC"
|
||||
|
||||
# 1. 配置阿里云 Maven 镜像加速下载(可选,国内服务器强烈推荐)
|
||||
RUN mkdir -p /root/.m2
|
||||
RUN echo '<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd"><mirrors><mirror><id>aliyunmaven</id><mirrorOf>*</mirrorOf><name>阿里云公共仓库</name><url>https://maven.aliyun.com/repository/public</url></mirror></mirrors></settings>' > /root/.m2/settings.xml
|
||||
|
||||
20
README.md
20
README.md
@ -1,4 +1,4 @@
|
||||
# TraceCD — 语音智能记账助手
|
||||
# Trace — 语音智能记账助手
|
||||
|
||||
基于语音识别的智能日常记账应用。按住说话即可完成事项录入与查询,由 DeepSeek 大模型理解意图并自动生成 SQL,实现"说话即记账"的流畅体验。
|
||||
|
||||
@ -41,8 +41,8 @@
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/main/java/com/l/tracecd/
|
||||
├── TracecdApplication.java # 应用主入口
|
||||
src/main/java/com/l/trace/
|
||||
├── TraceApplication.java # 应用主入口
|
||||
├── config/
|
||||
│ ├── DatabaseInitializer.java # 启动自动建表
|
||||
│ ├── DeepSeekConfig.java # DeepSeek API 配置
|
||||
@ -108,7 +108,7 @@ src/main/java/com/l/tracecd/
|
||||
创建 MySQL 数据库(应用会自动建表):
|
||||
|
||||
```sql
|
||||
CREATE DATABASE IF NOT EXISTS tracecd DEFAULT CHARSET utf8mb4;
|
||||
CREATE DATABASE IF NOT EXISTS trace DEFAULT CHARSET utf8mb4;
|
||||
```
|
||||
|
||||
修改 `src/main/resources/application.yml` 中的数据库连接信息:
|
||||
@ -116,7 +116,7 @@ CREATE DATABASE IF NOT EXISTS tracecd DEFAULT CHARSET utf8mb4;
|
||||
```yaml
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:mysql://your-host:3306/tracecd?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true
|
||||
url: jdbc:mysql://your-host:3306/trace?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true
|
||||
username: your-username
|
||||
password: your-password
|
||||
```
|
||||
@ -141,7 +141,7 @@ mvn spring-boot:run
|
||||
|
||||
# 或先打包再运行
|
||||
mvn clean package -DskipTests
|
||||
java -jar target/tracecd-1.0-SNAPSHOT.jar
|
||||
java -jar target/trace-1.0-SNAPSHOT.jar
|
||||
```
|
||||
|
||||
### 4. 访问
|
||||
@ -155,18 +155,18 @@ java -jar target/tracecd-1.0-SNAPSHOT.jar
|
||||
|
||||
```bash
|
||||
# 构建镜像
|
||||
docker build -t tracecd:latest .
|
||||
docker build -t trace:latest .
|
||||
|
||||
# 运行容器
|
||||
docker run -d \
|
||||
-p 8080:8080 \
|
||||
-e DEEPSEEK_API_KEY=sk-your-key \
|
||||
-e MIMO_API_KEY=sk-your-key \
|
||||
-e SPRING_DATASOURCE_URL=jdbc:mysql://your-host:3306/tracecd?... \
|
||||
-e SPRING_DATASOURCE_URL=jdbc:mysql://your-host:3306/trace?... \
|
||||
-e SPRING_DATASOURCE_USERNAME=root \
|
||||
-e SPRING_DATASOURCE_PASSWORD=root \
|
||||
--name tracecd \
|
||||
tracecd:latest
|
||||
--name trace \
|
||||
trace:latest
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
51
deploy/README.md
Normal file
51
deploy/README.md
Normal file
@ -0,0 +1,51 @@
|
||||
# Trace deployment on HK-1
|
||||
|
||||
The production stack lives at `/opt/trace` on `156.238.122.140` and is sized for a 1-core, 1-GiB host.
|
||||
|
||||
## Services
|
||||
|
||||
- MySQL 8.0.40: Docker-only `trace-net`; no host port is published.
|
||||
- Gitea 1.22.6: HTTPS at `git.1633292.cn`; host ports 3000 and 2222 remain bound to loopback.
|
||||
- Gitea Actions runner: capacity 1, label `centos-env`, Docker socket access.
|
||||
- Trace: built by Gitea Actions, bound to `127.0.0.1:8080` and served by host Nginx.
|
||||
- Nginx: HTTPS for `t.1633292.cn` and `git.1633292.cn`. Its stream module owns public port 443 and routes these two SNI names to local TLS virtual hosts; all other SNI traffic passes unchanged to Xray on `127.0.0.1:7443`.
|
||||
|
||||
## Bootstrap
|
||||
|
||||
Copy `compose.yaml`, `runner-config.yaml`, `nginx-trace.conf`, `nginx-sni.conf`, and `bootstrap-env.sh` to `/opt/trace`. Install `nginx-mod-stream` and `certbot`, issue certificates for both hostnames, move Xray's listener to `127.0.0.1:7443`, and add this top-level block to `/etc/nginx/nginx.conf`:
|
||||
|
||||
```nginx
|
||||
stream {
|
||||
include /etc/nginx/stream.d/*.conf;
|
||||
}
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
cd /opt/trace
|
||||
chmod 700 bootstrap-env.sh
|
||||
./bootstrap-env.sh
|
||||
install -m 644 nginx-trace.conf /etc/nginx/conf.d/trace.conf
|
||||
install -D -m 644 nginx-sni.conf /etc/nginx/stream.d/sni.conf
|
||||
nginx -t && systemctl reload nginx
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`bootstrap-env.sh` creates random database and bootstrap passwords in `stack.env` and `app.env`, both mode 0600. Add `DEEPSEEK_API_KEY` and `MIMO_API_KEY` to `app.env` on the server; never commit either file.
|
||||
|
||||
## Operations
|
||||
|
||||
```bash
|
||||
cd /opt/trace
|
||||
docker compose ps
|
||||
docker compose logs --tail 100 mysql gitea runner
|
||||
docker stats --no-stream
|
||||
curl -I https://t.1633292.cn/login
|
||||
curl -I https://git.1633292.cn/user/login
|
||||
```
|
||||
|
||||
Certbot's packaged systemd timer renews the certificates. A successful renewal must be followed by `systemctl reload nginx`; `/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh` provides that hook on the server.
|
||||
|
||||
The workflow in `.gitea/workflows/deploy.yaml` builds on pushes to `dev`, joins the application to `trace-net`, loads `/opt/trace/app.env`, and replaces only `trace-container`.
|
||||
6
deploy/app.env.example
Normal file
6
deploy/app.env.example
Normal file
@ -0,0 +1,6 @@
|
||||
SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/trace?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true
|
||||
SPRING_DATASOURCE_USERNAME=trace
|
||||
SPRING_DATASOURCE_PASSWORD=replace-with-stack-env-mysql-password
|
||||
DEEPSEEK_API_KEY=replace-with-secret
|
||||
MIMO_API_KEY=replace-with-secret
|
||||
APP_DEFAULT_PASSWORD=replace-with-secret
|
||||
33
deploy/bootstrap-env.sh
Normal file
33
deploy/bootstrap-env.sh
Normal file
@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd /opt/trace
|
||||
umask 077
|
||||
|
||||
if [[ -e stack.env || -e app.env ]]; then
|
||||
echo "Refusing to overwrite existing secret environment files." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mysql_root_password="$(openssl rand -hex 24)"
|
||||
mysql_app_password="$(openssl rand -hex 24)"
|
||||
app_default_password="$(openssl rand -hex 16)"
|
||||
|
||||
{
|
||||
printf 'MYSQL_ROOT_PASSWORD=%s\n' "$mysql_root_password"
|
||||
printf 'MYSQL_PASSWORD=%s\n' "$mysql_app_password"
|
||||
printf 'GITEA_HOST=git.1633292.cn\n'
|
||||
} > stack.env
|
||||
|
||||
{
|
||||
printf '%s\n' 'SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/trace?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true'
|
||||
printf 'SPRING_DATASOURCE_USERNAME=trace\n'
|
||||
printf 'SPRING_DATASOURCE_PASSWORD=%s\n' "$mysql_app_password"
|
||||
printf 'DEEPSEEK_API_KEY=\n'
|
||||
printf 'MIMO_API_KEY=\n'
|
||||
printf 'APP_DEFAULT_PASSWORD=%s\n' "$app_default_password"
|
||||
} > app.env
|
||||
|
||||
chmod 600 stack.env app.env
|
||||
ln -s stack.env .env
|
||||
echo "Created protected environment files."
|
||||
96
deploy/compose.yaml
Normal file
96
deploy/compose.yaml
Normal file
@ -0,0 +1,96 @@
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0.40
|
||||
container_name: trace-mysql
|
||||
restart: unless-stopped
|
||||
env_file: stack.env
|
||||
environment:
|
||||
MYSQL_DATABASE: trace
|
||||
MYSQL_USER: trace
|
||||
command:
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_0900_ai_ci
|
||||
- --performance-schema=OFF
|
||||
- --innodb-buffer-pool-size=128M
|
||||
- --innodb-log-buffer-size=8M
|
||||
- --max-connections=20
|
||||
- --table-open-cache=200
|
||||
- --skip-log-bin
|
||||
volumes:
|
||||
- ./data/mysql:/var/lib/mysql
|
||||
networks:
|
||||
- trace-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD --silent"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
mem_limit: 320m
|
||||
memswap_limit: 640m
|
||||
cpus: 0.55
|
||||
pids_limit: 256
|
||||
|
||||
gitea:
|
||||
image: gitea/gitea:1.22.6
|
||||
container_name: gitea
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
env_file: stack.env
|
||||
environment:
|
||||
USER_UID: 1000
|
||||
USER_GID: 1000
|
||||
GITEA__database__DB_TYPE: mysql
|
||||
GITEA__database__HOST: mysql:3306
|
||||
GITEA__database__NAME: gitea
|
||||
GITEA__database__USER: root
|
||||
GITEA__database__PASSWD: ${MYSQL_ROOT_PASSWORD}
|
||||
GITEA__server__DOMAIN: ${GITEA_HOST}
|
||||
GITEA__server__SSH_DOMAIN: ${GITEA_HOST}
|
||||
GITEA__server__ROOT_URL: https://${GITEA_HOST}/
|
||||
GITEA__server__LOCAL_ROOT_URL: http://gitea:3000/
|
||||
GITEA__server__SSH_PORT: 2222
|
||||
GITEA__service__DISABLE_REGISTRATION: "true"
|
||||
GITEA__actions__ENABLED: "true"
|
||||
volumes:
|
||||
- ./data/gitea:/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
- "127.0.0.1:2222:22"
|
||||
networks:
|
||||
- trace-net
|
||||
mem_limit: 256m
|
||||
memswap_limit: 512m
|
||||
cpus: 0.35
|
||||
pids_limit: 256
|
||||
|
||||
runner:
|
||||
image: gitea/act_runner:latest
|
||||
container_name: gitea-runner
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- gitea
|
||||
env_file: stack.env
|
||||
environment:
|
||||
GITEA_INSTANCE_URL: http://gitea:3000
|
||||
GITEA_RUNNER_NAME: hk1-low-memory-runner
|
||||
GITEA_RUNNER_LABELS: centos-env:docker://docker:cli
|
||||
GITEA_RUNNER_REGISTRATION_TOKEN_FILE: /run/secrets/runner_token
|
||||
CONFIG_FILE: /data/config.yaml
|
||||
volumes:
|
||||
- ./data/runner:/data
|
||||
- ./runner-config.yaml:/data/config.yaml:ro
|
||||
- ./runner-token:/run/secrets/runner_token:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
networks:
|
||||
- trace-net
|
||||
mem_limit: 128m
|
||||
memswap_limit: 256m
|
||||
cpus: 0.25
|
||||
pids_limit: 128
|
||||
|
||||
networks:
|
||||
trace-net:
|
||||
name: trace-net
|
||||
14
deploy/nginx-sni.conf
Normal file
14
deploy/nginx-sni.conf
Normal file
@ -0,0 +1,14 @@
|
||||
map $ssl_preread_server_name $sni_backend {
|
||||
t.1633292.cn 127.0.0.1:8443;
|
||||
git.1633292.cn 127.0.0.1:8444;
|
||||
default 127.0.0.1:7443;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443;
|
||||
listen [::]:443;
|
||||
proxy_pass $sni_backend;
|
||||
ssl_preread on;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_timeout 1h;
|
||||
}
|
||||
64
deploy/nginx-trace.conf
Normal file
64
deploy/nginx-trace.conf
Normal file
@ -0,0 +1,64 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name t.1633292.cn git.1633292.cn;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /var/lib/letsencrypt;
|
||||
default_type text/plain;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 127.0.0.1:8443 ssl;
|
||||
server_name t.1633292.cn;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/t.1633292.cn/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/t.1633292.cn/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_session_cache shared:trace_tls:1m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
client_max_body_size 10m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 127.0.0.1:8444 ssl;
|
||||
server_name git.1633292.cn;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/git.1633292.cn/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/git.1633292.cn/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_session_cache shared:gitea_tls:1m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
client_max_body_size 100m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
}
|
||||
35
deploy/runner-config.yaml
Normal file
35
deploy/runner-config.yaml
Normal file
@ -0,0 +1,35 @@
|
||||
log:
|
||||
level: info
|
||||
|
||||
runner:
|
||||
file: .runner
|
||||
capacity: 1
|
||||
timeout: 3h
|
||||
shutdown_timeout: 30s
|
||||
fetch_timeout: 5s
|
||||
fetch_interval: 2s
|
||||
fetch_interval_max: 10s
|
||||
labels:
|
||||
- "centos-env:docker://docker:cli"
|
||||
|
||||
cache:
|
||||
enabled: false
|
||||
|
||||
container:
|
||||
network: trace-net
|
||||
privileged: false
|
||||
options: "-v /opt/trace/app.env:/opt/trace/app.env:ro"
|
||||
valid_volumes:
|
||||
- /opt/trace/app.env
|
||||
docker_host: ""
|
||||
force_pull: false
|
||||
force_rebuild: false
|
||||
require_docker: true
|
||||
docker_timeout: 30s
|
||||
bind_workdir: false
|
||||
|
||||
host:
|
||||
workdir_parent: ""
|
||||
|
||||
metrics:
|
||||
enabled: false
|
||||
4
deploy/stack.env.example
Normal file
4
deploy/stack.env.example
Normal file
@ -0,0 +1,4 @@
|
||||
# Copy to stack.env on the server and keep mode 0600.
|
||||
MYSQL_ROOT_PASSWORD=replace-with-a-random-value
|
||||
MYSQL_PASSWORD=replace-with-a-different-random-value
|
||||
GITEA_HOST=git.1633292.cn
|
||||
2
pom.xml
2
pom.xml
@ -12,7 +12,7 @@
|
||||
</parent>
|
||||
|
||||
<groupId>com.l</groupId>
|
||||
<artifactId>tracecd</artifactId>
|
||||
<artifactId>trace</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
|
||||
<properties>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.tracecd;
|
||||
package com.l.trace;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
@ -8,9 +8,9 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
* 应用主入口
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class TracecdApplication {
|
||||
public class TraceApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(TracecdApplication.class, args);
|
||||
SpringApplication.run(TraceApplication.class, args);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.tracecd.config;
|
||||
package com.l.trace.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.tracecd.config;
|
||||
package com.l.trace.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.tracecd.config;
|
||||
package com.l.trace.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@ -1,6 +1,6 @@
|
||||
package com.l.tracecd.config;
|
||||
package com.l.trace.config;
|
||||
|
||||
import com.l.tracecd.interceptor.AuthInterceptor;
|
||||
import com.l.trace.interceptor.AuthInterceptor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
@ -15,6 +15,6 @@ public class MvcConfig implements WebMvcConfigurer {
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(new AuthInterceptor())
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns("/login", "/css/**", "/js/**", "/error", "/favicon.ico");
|
||||
.excludePathPatterns("/login", "/css/**", "/js/**", "/error", "/favicon.ico","/hello");
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.tracecd.config;
|
||||
package com.l.trace.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
package com.l.tracecd.config;
|
||||
package com.l.trace.config;
|
||||
|
||||
import com.l.tracecd.constant.Constants;
|
||||
import com.l.trace.constant.Constants;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.session.MapSession;
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.tracecd.constant;
|
||||
package com.l.trace.constant;
|
||||
|
||||
/**
|
||||
* 系统常量定义,避免魔法值
|
||||
@ -1,8 +1,8 @@
|
||||
package com.l.tracecd.controller;
|
||||
package com.l.trace.controller;
|
||||
|
||||
import com.l.tracecd.constant.Constants;
|
||||
import com.l.tracecd.entity.User;
|
||||
import com.l.tracecd.service.AuthService;
|
||||
import com.l.trace.constant.Constants;
|
||||
import com.l.trace.entity.User;
|
||||
import com.l.trace.service.AuthService;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@ -1,8 +1,8 @@
|
||||
package com.l.tracecd.controller;
|
||||
package com.l.trace.controller;
|
||||
|
||||
import com.l.tracecd.dto.BrowseQuery;
|
||||
import com.l.tracecd.entity.DailyRecord;
|
||||
import com.l.tracecd.service.RecordService;
|
||||
import com.l.trace.dto.BrowseQuery;
|
||||
import com.l.trace.entity.DailyRecord;
|
||||
import com.l.trace.service.RecordService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.tracecd.controller;
|
||||
package com.l.trace.controller;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
@ -1,8 +1,8 @@
|
||||
package com.l.tracecd.controller;
|
||||
package com.l.trace.controller;
|
||||
|
||||
import com.l.tracecd.constant.Constants;
|
||||
import com.l.tracecd.dto.FilterOption;
|
||||
import com.l.tracecd.service.DistinctValueService;
|
||||
import com.l.trace.constant.Constants;
|
||||
import com.l.trace.dto.FilterOption;
|
||||
import com.l.trace.service.DistinctValueService;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.tracecd.controller;
|
||||
package com.l.trace.controller;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@ -1,7 +1,7 @@
|
||||
package com.l.tracecd.controller;
|
||||
package com.l.trace.controller;
|
||||
|
||||
import com.l.tracecd.dto.VoiceResponse;
|
||||
import com.l.tracecd.service.VoiceService;
|
||||
import com.l.trace.dto.VoiceResponse;
|
||||
import com.l.trace.service.VoiceService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.lang.NonNull;
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.tracecd.dto;
|
||||
package com.l.trace.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.tracecd.dto;
|
||||
package com.l.trace.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.tracecd.dto;
|
||||
package com.l.trace.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.tracecd.dto;
|
||||
package com.l.trace.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
package com.l.tracecd.dto;
|
||||
package com.l.trace.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 语音处理响应 DTO
|
||||
@ -21,6 +24,9 @@ public class VoiceResponse {
|
||||
/** 错误信息 */
|
||||
private String error;
|
||||
|
||||
/** 查询结果记录列表 */
|
||||
private List<Map<String, Object>> records;
|
||||
|
||||
public static VoiceResponse recordSuccess(String category) {
|
||||
VoiceResponse r = new VoiceResponse();
|
||||
r.type = "RECORD";
|
||||
@ -30,11 +36,12 @@ public class VoiceResponse {
|
||||
return r;
|
||||
}
|
||||
|
||||
public static VoiceResponse queryResult(String message) {
|
||||
public static VoiceResponse queryResult(String summary, List<Map<String, Object>> records) {
|
||||
VoiceResponse r = new VoiceResponse();
|
||||
r.type = "QUERY";
|
||||
r.success = true;
|
||||
r.message = message;
|
||||
r.message = summary;
|
||||
r.records = records;
|
||||
return r;
|
||||
}
|
||||
|
||||
@ -55,43 +62,21 @@ public class VoiceResponse {
|
||||
return r;
|
||||
}
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
public String getType() { return type; }
|
||||
public void setType(String type) { this.type = type; }
|
||||
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
public String getMessage() { return message; }
|
||||
public void setMessage(String message) { this.message = message; }
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
public String getCategory() { return category; }
|
||||
public void setCategory(String category) { this.category = category; }
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
public boolean isSuccess() { return success; }
|
||||
public void setSuccess(boolean success) { this.success = success; }
|
||||
|
||||
public String getCategory() {
|
||||
return category;
|
||||
}
|
||||
public String getError() { return error; }
|
||||
public void setError(String error) { this.error = error; }
|
||||
|
||||
public void setCategory(String category) {
|
||||
this.category = category;
|
||||
}
|
||||
|
||||
public boolean isSuccess() {
|
||||
return success;
|
||||
}
|
||||
|
||||
public void setSuccess(boolean success) {
|
||||
this.success = success;
|
||||
}
|
||||
|
||||
public String getError() {
|
||||
return error;
|
||||
}
|
||||
|
||||
public void setError(String error) {
|
||||
this.error = error;
|
||||
}
|
||||
public List<Map<String, Object>> getRecords() { return records; }
|
||||
public void setRecords(List<Map<String, Object>> records) { this.records = records; }
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.tracecd.entity;
|
||||
package com.l.trace.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.tracecd.entity;
|
||||
package com.l.trace.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.tracecd.entity;
|
||||
package com.l.trace.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
@ -1,6 +1,6 @@
|
||||
package com.l.tracecd.interceptor;
|
||||
package com.l.trace.interceptor;
|
||||
|
||||
import com.l.tracecd.constant.Constants;
|
||||
import com.l.trace.constant.Constants;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
@ -21,7 +21,8 @@ public class AuthInterceptor implements HandlerInterceptor {
|
||||
"/login",
|
||||
"/css/",
|
||||
"/js/",
|
||||
"/error"
|
||||
"/error",
|
||||
"hello"
|
||||
};
|
||||
|
||||
@Override
|
||||
@ -1,7 +1,7 @@
|
||||
package com.l.tracecd.mapper;
|
||||
package com.l.trace.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.l.tracecd.entity.DailyRecord;
|
||||
import com.l.trace.entity.DailyRecord;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
@ -1,7 +1,7 @@
|
||||
package com.l.tracecd.mapper;
|
||||
package com.l.trace.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.l.tracecd.entity.DistinctValue;
|
||||
import com.l.trace.entity.DistinctValue;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
@ -1,7 +1,7 @@
|
||||
package com.l.tracecd.mapper;
|
||||
package com.l.trace.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.l.tracecd.entity.User;
|
||||
import com.l.trace.entity.User;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
@ -1,8 +1,8 @@
|
||||
package com.l.tracecd.service;
|
||||
package com.l.trace.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.l.tracecd.config.MimoAsrConfig;
|
||||
import com.l.trace.config.MimoAsrConfig;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@ -1,9 +1,9 @@
|
||||
package com.l.tracecd.service;
|
||||
package com.l.trace.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.l.tracecd.constant.Constants;
|
||||
import com.l.tracecd.entity.User;
|
||||
import com.l.tracecd.mapper.UserMapper;
|
||||
import com.l.trace.constant.Constants;
|
||||
import com.l.trace.entity.User;
|
||||
import com.l.trace.mapper.UserMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
@ -1,10 +1,10 @@
|
||||
package com.l.tracecd.service;
|
||||
package com.l.trace.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.l.tracecd.constant.Constants;
|
||||
import com.l.tracecd.entity.DailyRecord;
|
||||
import com.l.tracecd.entity.DistinctValue;
|
||||
import com.l.tracecd.mapper.DistinctValueMapper;
|
||||
import com.l.trace.constant.Constants;
|
||||
import com.l.trace.entity.DailyRecord;
|
||||
import com.l.trace.entity.DistinctValue;
|
||||
import com.l.trace.mapper.DistinctValueMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
@ -1,11 +1,11 @@
|
||||
package com.l.tracecd.service;
|
||||
package com.l.trace.service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.l.tracecd.config.DeepSeekConfig;
|
||||
import com.l.tracecd.constant.Constants;
|
||||
import com.l.tracecd.dto.DeepSeekRequest;
|
||||
import com.l.tracecd.dto.DeepSeekResponse;
|
||||
import com.l.trace.config.DeepSeekConfig;
|
||||
import com.l.trace.constant.Constants;
|
||||
import com.l.trace.dto.DeepSeekRequest;
|
||||
import com.l.trace.dto.DeepSeekResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@ -139,13 +139,8 @@ public class LlmService {
|
||||
|
||||
## 查询意图处理
|
||||
当用户意图是查询时,**必须调用 query_daily_records 函数**执行 SQL 查询。
|
||||
- 人物、日期、内容、金额相关字段查询
|
||||
- 查询完成后根据返回数据生成回复:
|
||||
* 多条数据用 markdown 表格展示,表格上方展示金额汇总
|
||||
* 一条数据直接描述
|
||||
* 无数据告知用户未找到
|
||||
* 回复要友好自然
|
||||
|
||||
- 查询结果的总结和格式化由后续系统自动处理,你只需调用函数即可
|
||||
|
||||
## 聊天意图处理
|
||||
当用户输入与记账无关时,直接友好回复。
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
package com.l.tracecd.service;
|
||||
package com.l.trace.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.l.tracecd.dto.BrowseQuery;
|
||||
import com.l.tracecd.entity.DailyRecord;
|
||||
import com.l.tracecd.mapper.DailyRecordMapper;
|
||||
import com.l.trace.dto.BrowseQuery;
|
||||
import com.l.trace.entity.DailyRecord;
|
||||
import com.l.trace.mapper.DailyRecordMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@ -1,6 +1,6 @@
|
||||
package com.l.tracecd.service;
|
||||
package com.l.trace.service;
|
||||
|
||||
import com.l.tracecd.constant.Constants;
|
||||
import com.l.trace.constant.Constants;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@ -1,18 +1,22 @@
|
||||
package com.l.tracecd.service;
|
||||
package com.l.trace.service;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.l.tracecd.constant.Constants;
|
||||
import com.l.tracecd.dto.DeepSeekRequest;
|
||||
import com.l.tracecd.dto.DeepSeekResponse;
|
||||
import com.l.tracecd.dto.VoiceResponse;
|
||||
import com.l.tracecd.entity.DailyRecord;
|
||||
import com.l.trace.constant.Constants;
|
||||
import com.l.trace.dto.DeepSeekRequest;
|
||||
import com.l.trace.dto.DeepSeekResponse;
|
||||
import com.l.trace.dto.VoiceResponse;
|
||||
import com.l.trace.entity.DailyRecord;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@ -129,7 +133,7 @@ public class VoiceService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理查询意图:执行 function call → 返回结果给 LLM → 获取格式化回复
|
||||
* 处理查询意图:执行 function call → 返回结果给 LLM 生成总结 → 构造结构化响应
|
||||
*/
|
||||
private VoiceResponse handleQueryIntent(DeepSeekResponse.ResponseMessage response,
|
||||
String originalText) throws Exception {
|
||||
@ -151,15 +155,45 @@ public class VoiceService {
|
||||
String validatedSql = sqlValidationService.validateSelect(sql);
|
||||
String queryResultJson = executeSelectSql(validatedSql);
|
||||
|
||||
// 构建对话历史发送回 LLM
|
||||
// 解析查询结果为结构化数据,并格式化日期字段
|
||||
List<Map<String, Object>> records = parseQueryResult(queryResultJson);
|
||||
|
||||
// 将查询结果发给 LLM,只生成一句总结
|
||||
List<DeepSeekRequest.Message> messages = buildToolResultMessages(originalText, response, toolCall, queryResultJson);
|
||||
DeepSeekResponse.ResponseMessage finalResponse = llmService.continueWithToolResult(messages);
|
||||
|
||||
String replyContent = finalResponse.getContent();
|
||||
String summary;
|
||||
if (replyContent == null || replyContent.isBlank()) {
|
||||
replyContent = "查询完成,但未能生成回复";
|
||||
summary = "查询完成";
|
||||
} else {
|
||||
// 尝试从 LLM 回复中提取 JSON
|
||||
summary = extractJsonField(replyContent, "summary");
|
||||
if (summary == null) {
|
||||
summary = replyContent; // 降级:直接使用原始回复
|
||||
}
|
||||
}
|
||||
|
||||
return VoiceResponse.queryResult(summary, records);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 LLM 返回的 JSON 字符串中提取指定字段
|
||||
*/
|
||||
private String extractJsonField(String content, String field) {
|
||||
try {
|
||||
// 尝试直接解析
|
||||
String trimmed = content.trim();
|
||||
// 去掉可能的 markdown 代码块包裹
|
||||
trimmed = trimmed.replaceAll("^```json\\s*", "").replaceAll("^```\\s*", "").replaceAll("```$", "").trim();
|
||||
Map<String, Object> map = objectMapper.readValue(trimmed,
|
||||
new TypeReference<Map<String, Object>>() {});
|
||||
Object value = map.get(field);
|
||||
return value != null ? value.toString() : null;
|
||||
} catch (Exception e) {
|
||||
log.debug("解析 LLM JSON 回复失败,将使用原始文本: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
return VoiceResponse.queryResult(replyContent);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -189,6 +223,39 @@ public class VoiceService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析查询结果 JSON,格式化日期字段为 M-d HH:mm
|
||||
*/
|
||||
private List<Map<String, Object>> parseQueryResult(String queryResultJson) throws Exception {
|
||||
List<Map<String, Object>> raw = objectMapper.readValue(queryResultJson,
|
||||
new TypeReference<List<Map<String, Object>>>() {});
|
||||
DateTimeFormatter outFmt = DateTimeFormatter.ofPattern("M-d HH:mm");
|
||||
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (Map<String, Object> row : raw) {
|
||||
LinkedHashMap<String, Object> formatted = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Object> entry : row.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
// 保留全部字段(包括 category,前端用于分类着色)
|
||||
// 格式化时间字段
|
||||
if (value instanceof String && (key.toLowerCase().contains("time") || key.toLowerCase().contains("record"))) {
|
||||
try {
|
||||
LocalDateTime dt = LocalDateTime.parse((String) value);
|
||||
value = dt.format(outFmt);
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
// 金额字段转为数字
|
||||
if (value instanceof BigDecimal) {
|
||||
value = ((BigDecimal) value).doubleValue();
|
||||
}
|
||||
formatted.put(key, value);
|
||||
}
|
||||
result.add(formatted);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 function call 的完整对话历史
|
||||
*/
|
||||
@ -199,8 +266,7 @@ public class VoiceService {
|
||||
List<DeepSeekRequest.Message> messages = new ArrayList<>();
|
||||
|
||||
// system
|
||||
messages.add(new DeepSeekRequest.Message("system",
|
||||
"你是一个日常记账助手。请根据查询结果友好地回答用户的问题。多条数据请用 markdown 表格展示,表格上方展示金额汇总。"));
|
||||
messages.add(new DeepSeekRequest.Message("system", CONTENT_TEMPLATE));
|
||||
|
||||
// user original
|
||||
messages.add(new DeepSeekRequest.Message("user", originalText));
|
||||
@ -218,4 +284,16 @@ public class VoiceService {
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
private static final String CONTENT_TEMPLATE = """
|
||||
你是一个日常记账助手。根据用户问题和查询结果,生成一句友好的总结回复。
|
||||
|
||||
请严格只返回以下 JSON 格式,不要包含任何其他内容(不要 markdown 代码块):
|
||||
{"summary": "你的总结,包含记录条数(如共X条)和金额汇总(合计 ¥XXX.XX),语气友好自然"}
|
||||
|
||||
示例返回:
|
||||
{"summary": "共3条记录,合计 ¥156.00,"}
|
||||
{"summary": "只找到1条记录,金额 ¥20.00"}
|
||||
{"summary": "没有找到匹配的记录哦"}
|
||||
""";
|
||||
}
|
||||
@ -6,7 +6,7 @@ server:
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: tracecd
|
||||
name: trace
|
||||
main:
|
||||
banner-mode: off # 关闭 Spring Boot banner
|
||||
|
||||
@ -24,9 +24,9 @@ spring:
|
||||
|
||||
# MySQL 数据源
|
||||
datasource:
|
||||
url: jdbc:mysql://192.168.2.5:3306/tracecd?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true
|
||||
username: root
|
||||
password: root
|
||||
url: ${SPRING_DATASOURCE_URL:jdbc:mysql://mysql:3306/trace?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true}
|
||||
username: ${SPRING_DATASOURCE_USERNAME:trace}
|
||||
password: ${SPRING_DATASOURCE_PASSWORD}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
|
||||
# Session 存储方式
|
||||
@ -53,7 +53,7 @@ mybatis-plus:
|
||||
logging:
|
||||
level:
|
||||
root: WARN
|
||||
com.l.tracecd: DEBUG
|
||||
com.l.trace: DEBUG
|
||||
com.baomidou.mybatisplus: WARN
|
||||
org.apache.ibatis: WARN
|
||||
pattern:
|
||||
@ -61,13 +61,13 @@ logging:
|
||||
|
||||
# DeepSeek API
|
||||
deepseek:
|
||||
api-key: ${DEEPSEEK_API_KEY:sk-732e5f5f1f07454492022401f0a2bf40}
|
||||
api-key: ${DEEPSEEK_API_KEY}
|
||||
base-url: https://api.deepseek.com/chat/completions
|
||||
model: deepseek-v4-pro
|
||||
model: deepseek-v4-flash
|
||||
|
||||
# MIMO ASR API
|
||||
mimo:
|
||||
api-key: ${MIMO_API_KEY:sk-cqttr117tzllfolonxztu4qa5208liota2llfut458ixx2m1}
|
||||
api-key: ${MIMO_API_KEY}
|
||||
base-url: https://api.xiaomimimo.com/v1/chat/completions
|
||||
model: mimo-v2.5-asr
|
||||
|
||||
@ -75,4 +75,4 @@ mimo:
|
||||
app:
|
||||
sql-max-retries: 5 # SQL 生成最大重试次数
|
||||
default-username: admin # 默认用户名
|
||||
default-password: admin123 # 默认密码(首次启动自动创建)
|
||||
default-password: ${APP_DEFAULT_PASSWORD}
|
||||
|
||||
@ -2,167 +2,275 @@
|
||||
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>浏览事项 - TraceCD</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<title>浏览事项 - Trace</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-left: env(safe-area-inset-left, 0px);
|
||||
--safe-right: env(safe-area-inset-right, 0px);
|
||||
--nav-height: 56px;
|
||||
--color-primary: #6366f1;
|
||||
--color-primary-light: #818cf8;
|
||||
--color-primary-dark: #4f46e5;
|
||||
--color-bg: #f8fafc;
|
||||
--color-surface: #ffffff;
|
||||
--color-text: #1e293b;
|
||||
--color-text-secondary: #64748b;
|
||||
--color-text-muted: #94a3b8;
|
||||
--color-border: #e2e8f0;
|
||||
--radius-xl: 24px;
|
||||
--radius-lg: 18px;
|
||||
--radius-md: 14px;
|
||||
--radius-sm: 10px;
|
||||
--radius-full: 999px;
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.03);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04);
|
||||
--font-xs: 13px;
|
||||
--font-sm: 14px;
|
||||
--font-base: 16px;
|
||||
--font-lg: 18px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
background: var(--color-bg); min-height: 100vh; min-height: 100dvh;
|
||||
margin: 0; padding: 0; -webkit-font-smoothing: antialiased; overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ==================== 导航 ==================== */
|
||||
.navbar {
|
||||
background: white;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: rgba(255,255,255,0.78);
|
||||
border-bottom: 1px solid rgba(226,232,240,0.6);
|
||||
height: var(--nav-height); padding: 0 var(--safe-left) 0 var(--safe-right);
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
backdrop-filter: blur(24px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||
}
|
||||
.navbar .brand { font-size: 18px; font-weight: 600; color: #1e293b; }
|
||||
.navbar .brand {
|
||||
font-size: 18px; font-weight: 800; color: var(--color-text);
|
||||
letter-spacing: -0.4px; padding-left: 20px;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.navbar .brand-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--color-primary), var(--color-primary-light));
|
||||
}
|
||||
.navbar .nav-links { padding-right: 16px; display: flex; gap: 4px; }
|
||||
.navbar .nav-links a {
|
||||
color: #64748b; text-decoration: none; margin-left: 20px;
|
||||
font-size: 14px; transition: color 0.2s;
|
||||
color: var(--color-text-secondary); text-decoration: none; font-size: var(--font-sm);
|
||||
padding: 8px 14px; border-radius: var(--radius-sm); transition: all 0.2s;
|
||||
font-weight: 500; white-space: nowrap;
|
||||
}
|
||||
.navbar .nav-links a:hover { color: #4f46e5; }
|
||||
.navbar .nav-links a:hover { background: rgba(99,102,241,0.06); color: var(--color-primary); }
|
||||
|
||||
/* ==================== 页面容器 ==================== */
|
||||
.page-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 30px 20px;
|
||||
max-width: 720px; margin: 0 auto;
|
||||
padding: 16px 12px calc(24px + var(--safe-bottom));
|
||||
}
|
||||
|
||||
/* 筛选栏 */
|
||||
/* ==================== 筛选栏 ==================== */
|
||||
.filter-bar {
|
||||
background: var(--color-surface); border-radius: var(--radius-xl);
|
||||
padding: 18px 18px 14px;
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid rgba(226,232,240,0.5);
|
||||
margin-bottom: 14px;
|
||||
animation: fadeInUp 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.filter-section { margin-bottom: 14px; }
|
||||
.filter-section:last-of-type { margin-bottom: 14px; }
|
||||
.filter-section-title {
|
||||
font-size: 11px; font-weight: 700; color: var(--color-text-muted);
|
||||
text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 8px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
/* 日期输入 */
|
||||
.date-row {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 10px;
|
||||
}
|
||||
.date-row input[type="date"] {
|
||||
border: 1.5px solid var(--color-border); border-radius: var(--radius-sm);
|
||||
font-size: var(--font-sm); padding: 0 12px;
|
||||
transition: all 0.2s; background: #f8fafc;
|
||||
width: 100%; min-width: 0; height: 42px; min-height: 42px;
|
||||
-webkit-appearance: none; line-height: 42px; color: var(--color-text);
|
||||
}
|
||||
.date-row input[type="date"]:focus {
|
||||
border-color: var(--color-primary); outline: none;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 0 0 3px rgba(99,102,241,0.08);
|
||||
}
|
||||
.filter-bar .row { margin-bottom: 12px; }
|
||||
.filter-bar label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
|
||||
/* Chip 多选标签组 */
|
||||
.chip-group {
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
max-height: 96px; overflow-y: auto; -webkit-overflow-scrolling: touch;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.filter-bar .form-select,
|
||||
.filter-bar .form-control {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
.chip-group::-webkit-scrollbar { width: 3px; }
|
||||
.chip-group::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 3px; }
|
||||
|
||||
.chip-group .chip {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 6px 12px; border-radius: var(--radius-full);
|
||||
border: 1.5px solid var(--color-border); font-size: var(--font-xs);
|
||||
cursor: pointer; user-select: none; -webkit-user-select: none;
|
||||
transition: all 0.2s ease; background: #fafbfc;
|
||||
color: var(--color-text-secondary); white-space: nowrap; min-height: 32px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.chip-group .chip:active { transform: scale(0.95); }
|
||||
.chip-group .chip input[type="checkbox"] {
|
||||
width: 14px; height: 14px; accent-color: var(--color-primary); margin: 0; flex-shrink: 0;
|
||||
}
|
||||
.chip-group .chip.selected {
|
||||
background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%);
|
||||
border-color: var(--color-primary); color: var(--color-primary); font-weight: 600;
|
||||
box-shadow: 0 2px 8px rgba(99,102,241,0.1);
|
||||
}
|
||||
|
||||
/* 按钮行 */
|
||||
.btn-group-row { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.btn-reset {
|
||||
background: var(--color-surface); color: var(--color-text-secondary);
|
||||
border: 1.5px solid var(--color-border); padding: 11px 20px; border-radius: var(--radius-sm);
|
||||
font-weight: 600; font-size: var(--font-sm); transition: all 0.2s;
|
||||
display: flex; align-items: center; justify-content: center; gap: 5px;
|
||||
white-space: nowrap; cursor: pointer;
|
||||
}
|
||||
.btn-reset:hover { border-color: var(--color-text-muted); color: var(--color-text); }
|
||||
.btn-reset:active { background: #f1f5f9; transform: scale(0.98); }
|
||||
|
||||
.btn-search {
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
flex: 1; background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%);
|
||||
color: white; border: none; padding: 11px 20px; border-radius: var(--radius-sm);
|
||||
font-weight: 700; font-size: var(--font-sm);
|
||||
transition: all 0.2s; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; gap: 5px;
|
||||
box-shadow: 0 4px 14px rgba(99,102,241,0.25);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.btn-search:hover { background: #4338ca; }
|
||||
.btn-reset {
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
margin-left: 8px;
|
||||
transition: all 0.2s;
|
||||
.btn-search::after {
|
||||
content: ''; position: absolute; inset: 0;
|
||||
background: linear-gradient(135deg, transparent 0%, rgba(255,255,255,0.12) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.btn-reset:hover { background: #f1f5f9; }
|
||||
.btn-search:hover { box-shadow: 0 6px 20px rgba(99,102,241,0.35); transform: translateY(-1px); }
|
||||
.btn-search:active { transform: translateY(0) scale(0.98); box-shadow: 0 2px 8px rgba(99,102,241,0.25); }
|
||||
|
||||
/* 汇总 */
|
||||
/* ==================== 汇总栏 ==================== */
|
||||
.summary-bar {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 16px 24px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
display: none;
|
||||
background: var(--color-surface); border-radius: var(--radius-lg);
|
||||
padding: 14px 18px; margin-bottom: 12px; display: none;
|
||||
align-items: center; justify-content: space-between;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid rgba(226,232,240,0.5);
|
||||
animation: fadeInUp 0.35s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.summary-bar.visible { display: flex; }
|
||||
.summary-amount {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #4f46e5;
|
||||
font-size: var(--font-lg); font-weight: 800;
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.summary-count {
|
||||
font-size: var(--font-xs); color: var(--color-text-secondary); font-weight: 500;
|
||||
background: #f1f5f9; padding: 4px 10px; border-radius: var(--radius-full);
|
||||
}
|
||||
.summary-count { font-size: 14px; color: #64748b; }
|
||||
|
||||
/* 表格 */
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ==================== 表格 ==================== */
|
||||
.table-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
.table-card table {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
background: var(--color-surface); border-radius: var(--radius-xl);
|
||||
overflow: hidden; box-shadow: var(--shadow-md);
|
||||
border: 1px solid rgba(226,232,240,0.5);
|
||||
animation: fadeInUp 0.45s cubic-bezier(0.16, 1, 0.3, 1) 0.1s both;
|
||||
}
|
||||
.table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
.table-card table { width: 100%; min-width: 520px; margin: 0; border-collapse: collapse; }
|
||||
.table-card table thead th {
|
||||
background: #f8fafc;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
font-size: 11px; font-weight: 700; color: var(--color-text-secondary);
|
||||
text-transform: uppercase; letter-spacing: 0.6px; padding: 12px 14px;
|
||||
border-bottom: 2px solid var(--color-border); white-space: nowrap;
|
||||
position: sticky; top: 0;
|
||||
}
|
||||
.table-card table thead th:first-child { padding-left: 18px; }
|
||||
.table-card table thead th:last-child { padding-right: 18px; text-align: right; }
|
||||
|
||||
.table-card table tbody td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
vertical-align: middle;
|
||||
padding: 12px 14px; border-bottom: 1px solid #f1f5f9;
|
||||
font-size: var(--font-sm); color: var(--color-text); vertical-align: middle;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.table-card table tbody td:first-child { padding-left: 18px; font-weight: 500; }
|
||||
.table-card table tbody td:last-child { padding-right: 18px; }
|
||||
.table-card table tbody tr:last-child td { border-bottom: none; }
|
||||
.table-card table tbody tr:hover { background: #f8fafc; }
|
||||
.table-card table tbody tr:active { background: #f1f5f9; }
|
||||
|
||||
.category-badge {
|
||||
display: inline-block;
|
||||
background: #eef2ff;
|
||||
color: #4f46e5;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%);
|
||||
color: var(--color-primary);
|
||||
padding: 3px 10px; border-radius: var(--radius-full);
|
||||
font-size: 11px; font-weight: 600; white-space: nowrap;
|
||||
}
|
||||
.amount-cell {
|
||||
font-weight: 600;
|
||||
color: #ef4444;
|
||||
text-align: right;
|
||||
font-weight: 700; color: #ef4444; text-align: right;
|
||||
font-variant-numeric: tabular-nums; font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.empty-msg {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #94a3b8;
|
||||
font-size: 15px;
|
||||
text-align: center; padding: 48px 20px; color: var(--color-text-muted);
|
||||
font-size: var(--font-sm); display: flex; flex-direction: column;
|
||||
align-items: center; gap: 8px;
|
||||
}
|
||||
.empty-msg i { font-size: 32px; opacity: 0.4; }
|
||||
|
||||
/* 加载 */
|
||||
.loading-bar {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
display: none;
|
||||
text-align: center; padding: 40px 20px; display: none;
|
||||
}
|
||||
.loading-bar.show { display: block; }
|
||||
.loading-bar .spinner-border {
|
||||
width: 28px; height: 28px; border-width: 3px; color: var(--color-primary);
|
||||
}
|
||||
.loading-bar p {
|
||||
margin-top: 12px; color: var(--color-text-muted); font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
/* ==================== 响应式 ==================== */
|
||||
@media (min-width: 768px) {
|
||||
.page-container { padding: 24px 20px 32px; }
|
||||
.filter-bar { padding: 20px 22px 16px; }
|
||||
.table-card table thead th { padding: 14px 16px; }
|
||||
.table-card table tbody td { padding: 14px 16px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar">
|
||||
<span class="brand">📝 TraceCD</span>
|
||||
<span class="brand"><span class="brand-dot"></span> Trace</span>
|
||||
<div class="nav-links">
|
||||
<a href="/">🎤 语音录入</a>
|
||||
<a href="/"><i class="bi bi-mic"></i> 语音</a>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</nav>
|
||||
@ -170,50 +278,45 @@
|
||||
<div class="page-container">
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<label>人物</label>
|
||||
<select class="form-select" id="filterPerson" multiple size="3">
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label>开始时间</label>
|
||||
<input type="date" class="form-control" id="filterStartTime">
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label>结束时间</label>
|
||||
<input type="date" class="form-control" id="filterEndTime">
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label>地点</label>
|
||||
<select class="form-select" id="filterLocation" multiple size="3">
|
||||
</select>
|
||||
<!-- 人物 -->
|
||||
<div class="filter-section">
|
||||
<div class="filter-section-title">人物</div>
|
||||
<div class="chip-group" id="chipPerson"></div>
|
||||
</div>
|
||||
<!-- 分类 -->
|
||||
<div class="filter-section">
|
||||
<div class="filter-section-title">分类</div>
|
||||
<div class="chip-group" id="chipCategory"></div>
|
||||
</div>
|
||||
<!-- 地点 -->
|
||||
<div class="filter-section">
|
||||
<div class="filter-section-title">地点</div>
|
||||
<div class="chip-group" id="chipLocation"></div>
|
||||
</div>
|
||||
<!-- 日期 -->
|
||||
<div class="filter-section">
|
||||
<div class="filter-section-title">时间范围</div>
|
||||
<div class="date-row">
|
||||
<input type="date" id="filterStartTime" placeholder="开始">
|
||||
<input type="date" id="filterEndTime" placeholder="结束">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<label>分类</label>
|
||||
<select class="form-select" id="filterCategory" multiple size="3">
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-9 mb-3 d-flex align-items-end">
|
||||
<button class="btn-search" onclick="doQuery()">
|
||||
<i class="bi bi-search"></i> 查询
|
||||
</button>
|
||||
<button class="btn-reset" onclick="resetFilters()">
|
||||
<i class="bi bi-arrow-clockwise"></i> 重置
|
||||
</button>
|
||||
</div>
|
||||
<!-- 按钮 -->
|
||||
<div class="btn-group-row">
|
||||
<button class="btn-reset" onclick="resetFilters()">
|
||||
<i class="bi bi-arrow-clockwise"></i> 重置
|
||||
</button>
|
||||
<button class="btn-search" onclick="doQuery()">
|
||||
<i class="bi bi-search"></i> 查询
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 汇总 -->
|
||||
<div class="summary-bar" id="summaryBar">
|
||||
<span class="summary-count" id="summaryCount"></span>
|
||||
<div>
|
||||
<span class="summary-count" id="summaryCount"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="color: #64748b;">合计:</span>
|
||||
<span style="color:var(--color-text-secondary);font-size:var(--font-xs);margin-right:4px;">合计</span>
|
||||
<span class="summary-amount" id="summaryAmount"></span>
|
||||
</div>
|
||||
</div>
|
||||
@ -221,55 +324,61 @@
|
||||
<!-- 表格 -->
|
||||
<div class="table-card">
|
||||
<div class="loading-bar" id="loadingBar">
|
||||
<div class="spinner-border text-secondary" role="status"></div>
|
||||
<p class="mt-2 text-muted">查询中...</p>
|
||||
<div class="spinner-border" role="status"></div>
|
||||
<p>查询中...</p>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table" id="resultTable" style="display:none;">
|
||||
<div class="table-scroll">
|
||||
<table id="resultTable" style="display:none;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>人物</th>
|
||||
<th>时间</th>
|
||||
<th>地点</th>
|
||||
<th>内容</th>
|
||||
<th>分类</th>
|
||||
<th style="text-align:right;">金额</th>
|
||||
<th>人物</th><th>时间</th><th>地点</th><th>内容</th><th>分类</th><th style="text-align:right;">金额</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="resultTbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="empty-msg" id="emptyMsg">点击"查询"按钮查看事项数据</div>
|
||||
<div class="empty-msg" id="emptyMsg">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<span>点击「查询」查看事项数据</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script th:inline="javascript">
|
||||
// ==================== 初始化 ====================
|
||||
// ==================== 初始化 Chip 选择器 ====================
|
||||
document.addEventListener('DOMContentLoaded', loadFilterOptions);
|
||||
|
||||
const chipMap = {
|
||||
person: 'chipPerson',
|
||||
location: 'chipLocation',
|
||||
category: 'chipCategory'
|
||||
};
|
||||
|
||||
async function loadFilterOptions() {
|
||||
try {
|
||||
const response = await fetch('/api/filter/options');
|
||||
if (response.status === 401) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) { window.location.href = '/login'; return; }
|
||||
const options = await response.json();
|
||||
|
||||
options.forEach(opt => {
|
||||
let selectEl;
|
||||
switch (opt.fieldName) {
|
||||
case 'person': selectEl = document.getElementById('filterPerson'); break;
|
||||
case 'location': selectEl = document.getElementById('filterLocation'); break;
|
||||
case 'category': selectEl = document.getElementById('filterCategory'); break;
|
||||
default: return;
|
||||
}
|
||||
const containerId = chipMap[opt.fieldName];
|
||||
if (!containerId) return;
|
||||
const container = document.getElementById(containerId);
|
||||
const fragment = document.createDocumentFragment();
|
||||
opt.values.forEach(v => {
|
||||
const option = document.createElement('option');
|
||||
option.value = v;
|
||||
option.textContent = v;
|
||||
selectEl.appendChild(option);
|
||||
const label = document.createElement('label');
|
||||
label.className = 'chip';
|
||||
label.innerHTML = '<input type="checkbox" value="' + escAttr(v) + '"> ' + escHtml(v);
|
||||
label.addEventListener('change', function(e) {
|
||||
if (e.target.checked) {
|
||||
label.classList.add('selected');
|
||||
} else {
|
||||
label.classList.remove('selected');
|
||||
}
|
||||
});
|
||||
fragment.appendChild(label);
|
||||
});
|
||||
container.appendChild(fragment);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('加载筛选选项失败:', err);
|
||||
@ -279,21 +388,13 @@
|
||||
// ==================== 查询 ====================
|
||||
async function doQuery() {
|
||||
const query = {
|
||||
persons: getSelectedValues('filterPerson'),
|
||||
startTime: document.getElementById('filterStartTime').value || null,
|
||||
endTime: document.getElementById('filterEndTime').value || null,
|
||||
locations: getSelectedValues('filterLocation'),
|
||||
categories: getSelectedValues('filterCategory')
|
||||
persons: getCheckedValues('chipPerson'),
|
||||
startTime: buildDateTime('filterStartTime', false),
|
||||
endTime: buildDateTime('filterEndTime', true),
|
||||
locations: getCheckedValues('chipLocation'),
|
||||
categories: getCheckedValues('chipCategory')
|
||||
};
|
||||
|
||||
// 如果 endTime 有值,附加时间
|
||||
if (query.endTime) {
|
||||
query.endTime += 'T23:59:59';
|
||||
}
|
||||
if (query.startTime) {
|
||||
query.startTime += 'T00:00:00';
|
||||
}
|
||||
|
||||
const loadingBar = document.getElementById('loadingBar');
|
||||
const resultTable = document.getElementById('resultTable');
|
||||
const emptyMsg = document.getElementById('emptyMsg');
|
||||
@ -310,23 +411,24 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(query)
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 401) { window.location.href = '/login'; return; }
|
||||
const data = await response.json();
|
||||
renderResults(data);
|
||||
} catch (err) {
|
||||
console.error('查询失败:', err);
|
||||
emptyMsg.textContent = '查询失败,请重试';
|
||||
emptyMsg.style.display = 'block';
|
||||
emptyMsg.innerHTML = '<i class="bi bi-exclamation-triangle"></i><span>查询失败,请重试</span>';
|
||||
emptyMsg.style.display = 'flex';
|
||||
} finally {
|
||||
loadingBar.classList.remove('show');
|
||||
}
|
||||
}
|
||||
|
||||
function buildDateTime(dateInputId, isEndOfDay) {
|
||||
const val = document.getElementById(dateInputId).value;
|
||||
if (!val) return null;
|
||||
return isEndOfDay ? val + 'T23:59:59' : val + 'T00:00:00';
|
||||
}
|
||||
|
||||
function renderResults(data) {
|
||||
const tbody = document.getElementById('resultTbody');
|
||||
const resultTable = document.getElementById('resultTable');
|
||||
@ -335,63 +437,68 @@
|
||||
|
||||
if (!data.records || data.records.length === 0) {
|
||||
resultTable.style.display = 'none';
|
||||
emptyMsg.textContent = '未找到匹配的事项记录';
|
||||
emptyMsg.style.display = 'block';
|
||||
emptyMsg.innerHTML = '<i class="bi bi-inbox"></i><span>未找到匹配的事项记录</span>';
|
||||
emptyMsg.style.display = 'flex';
|
||||
summaryBar.classList.remove('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
// 渲染表格
|
||||
tbody.innerHTML = data.records.map(r => `
|
||||
<tr>
|
||||
<td>${esc(r.person)}</td>
|
||||
<td>${formatTime(r.recordTime)}</td>
|
||||
<td>${esc(r.location)}</td>
|
||||
<td>${esc(r.content)}</td>
|
||||
<td><span class="category-badge">${esc(r.category)}</span></td>
|
||||
<td class="amount-cell">¥${formatAmount(r.amount)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
const fragment = document.createDocumentFragment();
|
||||
data.records.forEach(r => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
'<td>' + escHtml(r.person) + '</td>' +
|
||||
'<td>' + formatTime(r.recordTime) + '</td>' +
|
||||
'<td>' + escHtml(r.location) + '</td>' +
|
||||
'<td>' + escHtml(r.content) + '</td>' +
|
||||
'<td><span class="category-badge">' + escHtml(r.category) + '</span></td>' +
|
||||
'<td class="amount-cell">¥' + formatAmount(r.amount) + '</td>';
|
||||
fragment.appendChild(tr);
|
||||
});
|
||||
tbody.innerHTML = '';
|
||||
tbody.appendChild(fragment);
|
||||
|
||||
resultTable.style.display = 'table';
|
||||
emptyMsg.style.display = 'none';
|
||||
|
||||
// 汇总
|
||||
document.getElementById('summaryCount').textContent = `共 ${data.count} 条记录`;
|
||||
document.getElementById('summaryAmount').textContent = `¥${formatAmount(data.totalAmount)}`;
|
||||
document.getElementById('summaryCount').textContent = '共 ' + data.count + ' 条记录';
|
||||
document.getElementById('summaryAmount').textContent = '¥' + formatAmount(data.totalAmount);
|
||||
summaryBar.classList.add('visible');
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
document.getElementById('filterPerson').selectedIndex = -1;
|
||||
['chipPerson', 'chipLocation', 'chipCategory'].forEach(id => {
|
||||
const container = document.getElementById(id);
|
||||
if (!container) return;
|
||||
container.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
||||
cb.checked = false;
|
||||
cb.parentElement.classList.remove('selected');
|
||||
});
|
||||
});
|
||||
document.getElementById('filterStartTime').value = '';
|
||||
document.getElementById('filterEndTime').value = '';
|
||||
document.getElementById('filterLocation').selectedIndex = -1;
|
||||
document.getElementById('filterCategory').selectedIndex = -1;
|
||||
|
||||
document.getElementById('resultTable').style.display = 'none';
|
||||
document.getElementById('emptyMsg').style.display = 'block';
|
||||
document.getElementById('emptyMsg').textContent = '点击"查询"按钮查看事项数据';
|
||||
const emptyMsg = document.getElementById('emptyMsg');
|
||||
emptyMsg.innerHTML = '<i class="bi bi-inbox"></i><span>点击「查询」查看事项数据</span>';
|
||||
emptyMsg.style.display = 'flex';
|
||||
document.getElementById('summaryBar').classList.remove('visible');
|
||||
}
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
function getSelectedValues(selectId) {
|
||||
const select = document.getElementById(selectId);
|
||||
function getCheckedValues(containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return null;
|
||||
const values = [];
|
||||
for (const opt of select.options) {
|
||||
if (opt.selected) values.push(opt.value);
|
||||
}
|
||||
container.querySelectorAll('input[type="checkbox"]:checked').forEach(cb => values.push(cb.value));
|
||||
return values.length > 0 ? values : null;
|
||||
}
|
||||
|
||||
function formatTime(timeStr) {
|
||||
if (!timeStr) return '';
|
||||
// 格式化 ISO 时间为可读格式
|
||||
const d = new Date(timeStr);
|
||||
if (isNaN(d.getTime())) return timeStr;
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
return (d.getMonth()+1) + '-' + d.getDate() + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||
}
|
||||
|
||||
function formatAmount(amount) {
|
||||
@ -399,12 +506,16 @@
|
||||
return Number(amount).toFixed(2);
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
function escHtml(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function escAttr(str) {
|
||||
return str.replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -2,48 +2,245 @@
|
||||
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 - TraceCD</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<title>登录 - Trace</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--color-primary: #6366f1;
|
||||
--color-primary-dark: #4f46e5;
|
||||
--color-primary-light: #818cf8;
|
||||
--color-bg: #f8fafc;
|
||||
--color-surface: rgba(255, 255, 255, 0.72);
|
||||
--color-text: #1e293b;
|
||||
--color-text-secondary: #64748b;
|
||||
--color-text-muted: #94a3b8;
|
||||
--color-border: rgba(226, 232, 240, 0.6);
|
||||
--radius-xl: 28px;
|
||||
--radius-lg: 18px;
|
||||
--radius-md: 14px;
|
||||
--radius-sm: 10px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
background: var(--color-bg);
|
||||
min-height: 100vh; min-height: 100dvh;
|
||||
margin: 0; display: flex; align-items: center; justify-content: center;
|
||||
padding: 24px; -webkit-font-smoothing: antialiased;
|
||||
overflow: hidden; position: relative;
|
||||
}
|
||||
|
||||
/* ==================== 渐变背景 ==================== */
|
||||
.bg-gradient {
|
||||
position: fixed; inset: 0; z-index: -2;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 20% 10%, rgba(99,102,241,0.12) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 60% 50% at 80% 20%, rgba(168,85,247,0.10) 0%, transparent 50%),
|
||||
radial-gradient(ellipse 70% 40% at 50% 90%, rgba(59,130,246,0.08) 0%, transparent 50%),
|
||||
linear-gradient(160deg, #f0f4ff 0%, #f8fafc 40%, #faf5ff 70%, #f0f9ff 100%);
|
||||
}
|
||||
|
||||
/* 浮动光斑 */
|
||||
.bg-orb {
|
||||
position: fixed; border-radius: 50%; filter: blur(80px); z-index: -1;
|
||||
animation: orbFloat 20s ease-in-out infinite;
|
||||
}
|
||||
.bg-orb-1 { width: 400px; height: 400px; top: -100px; left: -80px; background: rgba(99,102,241,0.12); }
|
||||
.bg-orb-2 { width: 300px; height: 300px; bottom: -60px; right: -60px; background: rgba(168,85,247,0.10); animation-delay: -7s; }
|
||||
.bg-orb-3 { width: 250px; height: 250px; top: 40%; left: 60%; background: rgba(59,130,246,0.08); animation-delay: -14s; }
|
||||
@keyframes orbFloat {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
33% { transform: translate(30px, -20px) scale(1.05); }
|
||||
66% { transform: translate(-20px, 15px) scale(0.95); }
|
||||
}
|
||||
|
||||
/* ==================== 登录卡片 ==================== */
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--color-surface);
|
||||
backdrop-filter: blur(24px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||
border: 1px solid rgba(255,255,255,0.6);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 40px 28px 32px;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0,0,0,0.06),
|
||||
0 2px 8px rgba(0,0,0,0.04),
|
||||
inset 0 1px 0 rgba(255,255,255,0.5);
|
||||
width: 100%; max-width: 400px;
|
||||
animation: cardEnter 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.login-card h1 {
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
color: #333;
|
||||
@keyframes cardEnter {
|
||||
from { opacity: 0; transform: translateY(24px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* Logo 区域 */
|
||||
.login-header { text-align: center; margin-bottom: 32px; }
|
||||
.login-header .app-icon {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 72px; height: 72px; border-radius: 20px; font-size: 36px;
|
||||
background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%);
|
||||
box-shadow: 0 4px 12px rgba(99,102,241,0.15);
|
||||
margin-bottom: 16px; animation: iconPop 0.7s cubic-bezier(0.16, 1, 0.3, 1) 0.15s both;
|
||||
}
|
||||
@keyframes iconPop {
|
||||
from { opacity: 0; transform: scale(0.6); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.login-header h1 {
|
||||
font-size: 24px; font-weight: 800; color: var(--color-text);
|
||||
margin: 0 0 4px; letter-spacing: -0.5px;
|
||||
}
|
||||
.login-header .subtitle {
|
||||
font-size: 14px; color: var(--color-text-muted); margin: 0;
|
||||
font-weight: 400; letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
/* ==================== 输入框 ==================== */
|
||||
.input-group-custom {
|
||||
position: relative; margin-bottom: 20px;
|
||||
animation: fadeInUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0.25s both;
|
||||
}
|
||||
.input-group-custom:last-of-type { animation-delay: 0.32s; }
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.input-group-custom label {
|
||||
display: block; font-size: 13px; font-weight: 600;
|
||||
color: var(--color-text-secondary); margin-bottom: 6px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative; display: flex; align-items: center;
|
||||
}
|
||||
.input-wrapper .input-icon {
|
||||
position: absolute; left: 14px; font-size: 18px;
|
||||
color: var(--color-text-muted); pointer-events: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.input-wrapper input {
|
||||
width: 100%; border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md); font-size: 15px;
|
||||
padding: 13px 14px 13px 42px;
|
||||
background: rgba(248,250,252,0.6);
|
||||
transition: all 0.25s ease;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.input-wrapper input::placeholder { color: var(--color-text-muted); font-weight: 400; }
|
||||
.input-wrapper input:focus {
|
||||
outline: none; border-color: var(--color-primary);
|
||||
background: white;
|
||||
box-shadow: 0 0 0 4px rgba(99,102,241,0.08), 0 2px 8px rgba(99,102,241,0.06);
|
||||
}
|
||||
.input-wrapper input:focus ~ .input-icon,
|
||||
.input-wrapper input:focus + .input-icon { color: var(--color-primary); }
|
||||
|
||||
/* ==================== 按钮 ==================== */
|
||||
.btn-login {
|
||||
width: 100%; border: none; padding: 14px;
|
||||
border-radius: var(--radius-md); font-size: 15px;
|
||||
font-weight: 700; cursor: pointer; letter-spacing: 0.5px;
|
||||
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%);
|
||||
color: white; position: relative; overflow: hidden;
|
||||
box-shadow: 0 4px 16px rgba(99,102,241,0.3);
|
||||
transition: all 0.25s ease;
|
||||
animation: fadeInUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0.4s both;
|
||||
}
|
||||
.btn-login:hover {
|
||||
box-shadow: 0 6px 24px rgba(99,102,241,0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn-login:active {
|
||||
transform: translateY(0) scale(0.98);
|
||||
box-shadow: 0 2px 8px rgba(99,102,241,0.3);
|
||||
}
|
||||
.btn-login::after {
|
||||
content: ''; position: absolute; inset: 0;
|
||||
background: linear-gradient(135deg, transparent 0%, rgba(255,255,255,0.15) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ==================== 错误提示 ==================== */
|
||||
.alert-custom {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
background: linear-gradient(135deg, #fef2f2 0%, #fff1f2 100%);
|
||||
border: 1px solid rgba(239,68,68,0.15);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 16px; margin-bottom: 20px;
|
||||
font-size: 14px; color: #dc2626; font-weight: 500;
|
||||
animation: shakeIn 0.4s ease;
|
||||
}
|
||||
.alert-custom i { font-size: 18px; flex-shrink: 0; }
|
||||
@keyframes shakeIn {
|
||||
0% { opacity: 0; transform: translateX(-8px); }
|
||||
40% { transform: translateX(4px); }
|
||||
70% { transform: translateX(-2px); }
|
||||
100% { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* ==================== 底部装饰 ==================== */
|
||||
.login-footer {
|
||||
text-align: center; margin-top: 24px;
|
||||
font-size: 12px; color: var(--color-text-muted);
|
||||
animation: fadeInUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0.5s both;
|
||||
}
|
||||
|
||||
/* ==================== 响应式 ==================== */
|
||||
@media (min-width: 768px) {
|
||||
.login-card { padding: 48px 36px 36px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card">
|
||||
<h1>TraceCD 日常记录</h1>
|
||||
<div th:if="${error}" class="alert alert-danger" th:text="${error}"></div>
|
||||
<form method="post" action="/login">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">用户名</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
||||
<div class="bg-gradient"></div>
|
||||
<div class="bg-orb bg-orb-1"></div>
|
||||
<div class="bg-orb bg-orb-2"></div>
|
||||
<div class="bg-orb bg-orb-3"></div>
|
||||
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="app-icon">📝</div>
|
||||
<h1>Trace</h1>
|
||||
<p class="subtitle">语音智能记账助手</p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">密码</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
|
||||
<div th:if="${error}" class="alert-custom">
|
||||
<i class="bi bi-exclamation-circle-fill"></i>
|
||||
<span th:text="${error}"></span>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">登 录</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/login" autocomplete="off">
|
||||
<div class="input-group-custom">
|
||||
<label for="username">用户名</label>
|
||||
<div class="input-wrapper">
|
||||
<i class="bi bi-person input-icon"></i>
|
||||
<input type="text" id="username" name="username"
|
||||
placeholder="请输入用户名" required autofocus autocomplete="username">
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group-custom" style="margin-bottom:28px;">
|
||||
<label for="password">密码</label>
|
||||
<div class="input-wrapper">
|
||||
<i class="bi bi-lock input-icon"></i>
|
||||
<input type="password" id="password" name="password"
|
||||
placeholder="请输入密码" required autocomplete="current-password">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn-login">登 录</button>
|
||||
</form>
|
||||
|
||||
<div class="login-footer">语音记录 · 智能分析 · 轻松管理</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user