Compare commits

..

22 Commits
master ... dev

Author SHA1 Message Date
Lee
8f5a35395d refactor: rename TraceCD project to Trace
All checks were successful
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Successful in 9m30s
2026-08-19 19:27:13 +08:00
Lee
04c1503d4a ops: serve TraceCD and Gitea over shared HTTPS 2026-08-19 19:07:38 +08:00
Lee
ee39e0b1c6 限制 Gitea 端口仅本机访问 2026-08-19 18:16:40 +08:00
Lee
a6f5ea721d 修复 HK-1 Actions 容器网络
All checks were successful
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Successful in 8m6s
2026-08-19 18:04:33 +08:00
Lee
4bff3b8a2f 迁移 TraceCD 部署到 HK-1
Some checks failed
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Failing after 1m9s
2026-08-19 17:57:55 +08:00
Lee
fe95fce777 优化页面
All checks were successful
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Successful in 1m20s
2026-06-08 23:37:55 +08:00
Lee
172c2a098f 优化页面
All checks were successful
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Successful in 1m22s
2026-06-08 23:20:38 +08:00
Lee
0476b72624 初始化项目
All checks were successful
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Successful in 29s
2026-06-08 17:07:27 +08:00
Lee
68f7aff914 初始化项目
Some checks failed
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Failing after 22s
2026-06-08 17:05:41 +08:00
Lee
2f35415bb9 初始化项目
Some checks failed
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Failing after 13s
2026-06-08 17:01:16 +08:00
Lee
a964b9d1ff 初始化项目
Some checks failed
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Has been cancelled
2026-06-08 16:59:25 +08:00
Lee
d1e4f89abf 初始化项目
Some checks failed
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Failing after 13s
2026-06-08 16:52:59 +08:00
Lee
d953a09a07 初始化项目
Some checks failed
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Failing after 13s
2026-06-08 16:52:24 +08:00
Lee
dd8d7ce6cd 初始化项目
Some checks failed
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Failing after 1m1s
2026-06-08 16:45:55 +08:00
Lee
7843a3e3a0 初始化项目
Some checks failed
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Failing after 13s
2026-06-08 16:35:47 +08:00
Lee
a2ba8005c3 初始化项目
Some checks failed
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Failing after 12s
2026-06-08 16:27:37 +08:00
Lee
e0bc320cd3 初始化项目
Some checks failed
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Failing after 13s
2026-06-08 16:23:09 +08:00
Lee
85ac8274f9 初始化项目
Some checks failed
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Failing after 1m4s
2026-06-08 15:56:28 +08:00
Lee
b40c8a3ee9 初始化项目
Some checks failed
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Failing after 35s
2026-06-08 15:46:38 +08:00
Lee
dd62e5d9ad 初始化项目
Some checks failed
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Failing after 3s
2026-06-08 15:43:38 +08:00
Lee
f846013228 初始化项目
Some checks failed
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Failing after 3s
2026-06-08 15:42:52 +08:00
Lee
00fd9c81bb 初始化项目
Some checks failed
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Failing after 3s
2026-06-08 15:40:42 +08:00
52 changed files with 1541 additions and 795 deletions

View File

@ -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

6
.gitignore vendored
View File

@ -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/

4
.idea/dataSources.xml generated
View File

@ -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&amp;characterEncoding=utf-8&amp;serverTimezone=Asia/Shanghai&amp;createDatabaseIfNotExist=true</jdbc-url>
<jdbc-url>jdbc:mysql://192.168.2.5:3306/trace?useUnicode=true&amp;characterEncoding=utf-8&amp;serverTimezone=Asia/Shanghai&amp;createDatabaseIfNotExist=true</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>

View File

@ -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`

View File

@ -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

View File

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View File

@ -12,7 +12,7 @@
</parent>
<groupId>com.l</groupId>
<artifactId>tracecd</artifactId>
<artifactId>trace</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>

View File

@ -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);
}
}

View File

@ -1,4 +1,4 @@
package com.l.tracecd.config;
package com.l.trace.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

View File

@ -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;

View File

@ -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;

View File

@ -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");
}
}

View File

@ -1,4 +1,4 @@
package com.l.tracecd.config;
package com.l.trace.config;
import org.springframework.context.annotation.Configuration;

View File

@ -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;

View File

@ -1,4 +1,4 @@
package com.l.tracecd.constant;
package com.l.trace.constant;
/**
* 系统常量定义避免魔法值

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -1,4 +1,4 @@
package com.l.tracecd.dto;
package com.l.trace.dto;
import java.util.List;

View File

@ -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;

View File

@ -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;

View File

@ -1,4 +1,4 @@
package com.l.tracecd.dto;
package com.l.trace.dto;
import java.util.List;

View File

@ -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; }
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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;
/**

View File

@ -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;
/**

View File

@ -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;
/**

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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,12 +139,7 @@ public class LlmService {
## 查询意图处理
当用户意图是查询时**必须调用 query_daily_records 函数**执行 SQL 查询
- 人物日期内容金额相关字段查询
- 查询完成后根据返回数据生成回复
* 多条数据用 markdown 表格展示表格上方展示金额汇总
* 一条数据直接描述
* 无数据告知用户未找到
* 回复要友好自然
- 查询结果的总结和格式化由后续系统自动处理你只需调用函数即可
## 聊天意图处理
当用户输入与记账无关时直接友好回复

View File

@ -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;

View File

@ -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;

View File

@ -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": "没有找到匹配的记录哦"}
""";
}

View File

@ -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}

View File

@ -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,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -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>