Compare commits
No commits in common. "dev" and "master" have entirely different histories.
@ -3,7 +3,7 @@ name: Java Maven 3.9.9 & JDK 26 CI/CD Pipeline
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev # 只有推送到 dev 分支时才触发自动部署
|
||||
- dev # 只有推送到 main 分支时才触发自动部署
|
||||
|
||||
env:
|
||||
IMAGE_NAME: trace-img
|
||||
@ -16,25 +16,16 @@ jobs:
|
||||
runs-on: centos-env
|
||||
|
||||
steps:
|
||||
# 替换为以下纯命令形式(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: 1. 拉取最新的仓库代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 2. 触发 Docker 容器内编译与镜像打包
|
||||
shell: sh
|
||||
run: |
|
||||
echo "🚀 开始构建 JDK 26 镜像: ${IMAGE_NAME}:${{ gitea.sha }}"
|
||||
DOCKER_BUILDKIT=0 docker build --memory=700m -t ${IMAGE_NAME}:${{ gitea.sha }} .
|
||||
docker build -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
|
||||
@ -43,38 +34,28 @@ jobs:
|
||||
echo "🔄 正在启动全新 JDK 26 容器环境..."
|
||||
docker run -d \
|
||||
--name ${CONTAINER_NAME} \
|
||||
--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" \
|
||||
-p ${APP_PORT}:${APP_PORT} \
|
||||
--restart no \
|
||||
-m 512m \
|
||||
-e JAVA_TOOL_OPTIONS="-Xms128m -Xmx384m -XX:+UseG1GC" \
|
||||
${IMAGE_NAME}:latest
|
||||
|
||||
- name: 4. 服务健康检查 (HTTP 探活)
|
||||
shell: sh
|
||||
run: |
|
||||
echo "🔍 开始对端口 ${APP_PORT} 进行健康状态探活..."
|
||||
|
||||
# 循环探活:每隔 5 秒检测一次,最多尝试 15 次(总计 75 秒,给高版本 JVM 充足的预热时间)
|
||||
for i in 1 2 3 4 5 6; do
|
||||
for i in {1..15}; do
|
||||
# 发送轻量级请求,只要服务响应了(无论是200还是Spring默认的404/无路由),都证明 Tomcat/Netty 已拉起
|
||||
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
|
||||
|
||||
if curl -s -f http://localhost:${APP_PORT}/hello > /dev/null; then
|
||||
echo "✅ [SUCCESS] 探活成功!Java 26 服务已成功拉起,并开始提供服务。"
|
||||
exit 0
|
||||
fi
|
||||
echo "⏳ [WAITING] 服务仍在启动中,等待 5 秒后重试 ($i/6)..."
|
||||
echo "⏳ [WAITING] 服务仍在启动中,等待 5 秒后重试 ($i/15)..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo "❌ [ERROR] 探活失败!服务在 30 秒内未能建立连接,可能发生了启动闪退。"
|
||||
echo "❌ [ERROR] 探活失败!服务在 75 秒内未能建立连接,可能发生了启动闪退。"
|
||||
echo "===== 以下为容器崩溃前的实时日志 ====="
|
||||
docker logs ${CONTAINER_NAME}
|
||||
exit 1
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -1,10 +1,4 @@
|
||||
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
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="trace@192.168.2.5" uuid="911f82d8-654e-4293-bc95-e53e9a4e6769">
|
||||
<data-source source="LOCAL" name="tracecd@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/trace?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true</jdbc-url>
|
||||
<jdbc-url>jdbc:mysql://192.168.2.5:3306/tracecd?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: `trace` | Version: `1.0-SNAPSHOT`
|
||||
Group ID: `com.l` | Artifact ID: `tracecd` | Version: `1.0-SNAPSHOT`
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
# === 第一阶段:使用 Maven 3.9.9 + JDK 26 进行编译打包 ===
|
||||
FROM maven:3.9.16-eclipse-temurin-26 AS builder
|
||||
FROM maven:3.9.9-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 @@
|
||||
# Trace — 语音智能记账助手
|
||||
# TraceCD — 语音智能记账助手
|
||||
|
||||
基于语音识别的智能日常记账应用。按住说话即可完成事项录入与查询,由 DeepSeek 大模型理解意图并自动生成 SQL,实现"说话即记账"的流畅体验。
|
||||
|
||||
@ -41,8 +41,8 @@
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/main/java/com/l/trace/
|
||||
├── TraceApplication.java # 应用主入口
|
||||
src/main/java/com/l/tracecd/
|
||||
├── TracecdApplication.java # 应用主入口
|
||||
├── config/
|
||||
│ ├── DatabaseInitializer.java # 启动自动建表
|
||||
│ ├── DeepSeekConfig.java # DeepSeek API 配置
|
||||
@ -108,7 +108,7 @@ src/main/java/com/l/trace/
|
||||
创建 MySQL 数据库(应用会自动建表):
|
||||
|
||||
```sql
|
||||
CREATE DATABASE IF NOT EXISTS trace DEFAULT CHARSET utf8mb4;
|
||||
CREATE DATABASE IF NOT EXISTS tracecd DEFAULT CHARSET utf8mb4;
|
||||
```
|
||||
|
||||
修改 `src/main/resources/application.yml` 中的数据库连接信息:
|
||||
@ -116,7 +116,7 @@ CREATE DATABASE IF NOT EXISTS trace DEFAULT CHARSET utf8mb4;
|
||||
```yaml
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:mysql://your-host:3306/trace?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true
|
||||
url: jdbc:mysql://your-host:3306/tracecd?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/trace-1.0-SNAPSHOT.jar
|
||||
java -jar target/tracecd-1.0-SNAPSHOT.jar
|
||||
```
|
||||
|
||||
### 4. 访问
|
||||
@ -155,18 +155,18 @@ java -jar target/trace-1.0-SNAPSHOT.jar
|
||||
|
||||
```bash
|
||||
# 构建镜像
|
||||
docker build -t trace:latest .
|
||||
docker build -t tracecd: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/trace?... \
|
||||
-e SPRING_DATASOURCE_URL=jdbc:mysql://your-host:3306/tracecd?... \
|
||||
-e SPRING_DATASOURCE_USERNAME=root \
|
||||
-e SPRING_DATASOURCE_PASSWORD=root \
|
||||
--name trace \
|
||||
trace:latest
|
||||
--name tracecd \
|
||||
tracecd:latest
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
@ -1,51 +0,0 @@
|
||||
# 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`.
|
||||
@ -1,6 +0,0 @@
|
||||
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
|
||||
@ -1,33 +0,0 @@
|
||||
#!/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."
|
||||
@ -1,96 +0,0 @@
|
||||
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
|
||||
@ -1,14 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
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
|
||||
@ -1,4 +0,0 @@
|
||||
# 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>trace</artifactId>
|
||||
<artifactId>tracecd</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
|
||||
<properties>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.trace;
|
||||
package com.l.tracecd;
|
||||
|
||||
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 TraceApplication {
|
||||
public class TracecdApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(TraceApplication.class, args);
|
||||
SpringApplication.run(TracecdApplication.class, args);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.trace.config;
|
||||
package com.l.tracecd.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.trace.config;
|
||||
package com.l.tracecd.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.trace.config;
|
||||
package com.l.tracecd.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@ -1,6 +1,6 @@
|
||||
package com.l.trace.config;
|
||||
package com.l.tracecd.config;
|
||||
|
||||
import com.l.trace.interceptor.AuthInterceptor;
|
||||
import com.l.tracecd.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","/hello");
|
||||
.excludePathPatterns("/login", "/css/**", "/js/**", "/error", "/favicon.ico");
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.trace.config;
|
||||
package com.l.tracecd.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
package com.l.trace.config;
|
||||
package com.l.tracecd.config;
|
||||
|
||||
import com.l.trace.constant.Constants;
|
||||
import com.l.tracecd.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.trace.constant;
|
||||
package com.l.tracecd.constant;
|
||||
|
||||
/**
|
||||
* 系统常量定义,避免魔法值
|
||||
@ -1,8 +1,8 @@
|
||||
package com.l.trace.controller;
|
||||
package com.l.tracecd.controller;
|
||||
|
||||
import com.l.trace.constant.Constants;
|
||||
import com.l.trace.entity.User;
|
||||
import com.l.trace.service.AuthService;
|
||||
import com.l.tracecd.constant.Constants;
|
||||
import com.l.tracecd.entity.User;
|
||||
import com.l.tracecd.service.AuthService;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@ -1,8 +1,8 @@
|
||||
package com.l.trace.controller;
|
||||
package com.l.tracecd.controller;
|
||||
|
||||
import com.l.trace.dto.BrowseQuery;
|
||||
import com.l.trace.entity.DailyRecord;
|
||||
import com.l.trace.service.RecordService;
|
||||
import com.l.tracecd.dto.BrowseQuery;
|
||||
import com.l.tracecd.entity.DailyRecord;
|
||||
import com.l.tracecd.service.RecordService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.trace.controller;
|
||||
package com.l.tracecd.controller;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
@ -1,8 +1,8 @@
|
||||
package com.l.trace.controller;
|
||||
package com.l.tracecd.controller;
|
||||
|
||||
import com.l.trace.constant.Constants;
|
||||
import com.l.trace.dto.FilterOption;
|
||||
import com.l.trace.service.DistinctValueService;
|
||||
import com.l.tracecd.constant.Constants;
|
||||
import com.l.tracecd.dto.FilterOption;
|
||||
import com.l.tracecd.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.trace.controller;
|
||||
package com.l.tracecd.controller;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@ -1,7 +1,7 @@
|
||||
package com.l.trace.controller;
|
||||
package com.l.tracecd.controller;
|
||||
|
||||
import com.l.trace.dto.VoiceResponse;
|
||||
import com.l.trace.service.VoiceService;
|
||||
import com.l.tracecd.dto.VoiceResponse;
|
||||
import com.l.tracecd.service.VoiceService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.lang.NonNull;
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.trace.dto;
|
||||
package com.l.tracecd.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.trace.dto;
|
||||
package com.l.tracecd.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.trace.dto;
|
||||
package com.l.tracecd.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.trace.dto;
|
||||
package com.l.tracecd.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
package com.l.trace.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
package com.l.tracecd.dto;
|
||||
|
||||
/**
|
||||
* 语音处理响应 DTO
|
||||
@ -24,9 +21,6 @@ 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";
|
||||
@ -36,12 +30,11 @@ public class VoiceResponse {
|
||||
return r;
|
||||
}
|
||||
|
||||
public static VoiceResponse queryResult(String summary, List<Map<String, Object>> records) {
|
||||
public static VoiceResponse queryResult(String message) {
|
||||
VoiceResponse r = new VoiceResponse();
|
||||
r.type = "QUERY";
|
||||
r.success = true;
|
||||
r.message = summary;
|
||||
r.records = records;
|
||||
r.message = message;
|
||||
return r;
|
||||
}
|
||||
|
||||
@ -62,21 +55,43 @@ public class VoiceResponse {
|
||||
return r;
|
||||
}
|
||||
|
||||
public String getType() { return type; }
|
||||
public void setType(String type) { this.type = type; }
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public String getMessage() { return message; }
|
||||
public void setMessage(String message) { this.message = message; }
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public String getCategory() { return category; }
|
||||
public void setCategory(String category) { this.category = category; }
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public boolean isSuccess() { return success; }
|
||||
public void setSuccess(boolean success) { this.success = success; }
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public String getError() { return error; }
|
||||
public void setError(String error) { this.error = error; }
|
||||
public String getCategory() {
|
||||
return category;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> getRecords() { return records; }
|
||||
public void setRecords(List<Map<String, Object>> records) { this.records = records; }
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.trace.entity;
|
||||
package com.l.tracecd.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.trace.entity;
|
||||
package com.l.tracecd.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
@ -1,4 +1,4 @@
|
||||
package com.l.trace.entity;
|
||||
package com.l.tracecd.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
@ -1,6 +1,6 @@
|
||||
package com.l.trace.interceptor;
|
||||
package com.l.tracecd.interceptor;
|
||||
|
||||
import com.l.trace.constant.Constants;
|
||||
import com.l.tracecd.constant.Constants;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
@ -21,8 +21,7 @@ public class AuthInterceptor implements HandlerInterceptor {
|
||||
"/login",
|
||||
"/css/",
|
||||
"/js/",
|
||||
"/error",
|
||||
"hello"
|
||||
"/error"
|
||||
};
|
||||
|
||||
@Override
|
||||
@ -1,7 +1,7 @@
|
||||
package com.l.trace.mapper;
|
||||
package com.l.tracecd.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.l.trace.entity.DailyRecord;
|
||||
import com.l.tracecd.entity.DailyRecord;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
@ -1,7 +1,7 @@
|
||||
package com.l.trace.mapper;
|
||||
package com.l.tracecd.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.l.trace.entity.DistinctValue;
|
||||
import com.l.tracecd.entity.DistinctValue;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
@ -1,7 +1,7 @@
|
||||
package com.l.trace.mapper;
|
||||
package com.l.tracecd.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.l.trace.entity.User;
|
||||
import com.l.tracecd.entity.User;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
@ -1,8 +1,8 @@
|
||||
package com.l.trace.service;
|
||||
package com.l.tracecd.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.l.trace.config.MimoAsrConfig;
|
||||
import com.l.tracecd.config.MimoAsrConfig;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@ -1,9 +1,9 @@
|
||||
package com.l.trace.service;
|
||||
package com.l.tracecd.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.l.trace.constant.Constants;
|
||||
import com.l.trace.entity.User;
|
||||
import com.l.trace.mapper.UserMapper;
|
||||
import com.l.tracecd.constant.Constants;
|
||||
import com.l.tracecd.entity.User;
|
||||
import com.l.tracecd.mapper.UserMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
@ -1,10 +1,10 @@
|
||||
package com.l.trace.service;
|
||||
package com.l.tracecd.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
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 com.l.tracecd.constant.Constants;
|
||||
import com.l.tracecd.entity.DailyRecord;
|
||||
import com.l.tracecd.entity.DistinctValue;
|
||||
import com.l.tracecd.mapper.DistinctValueMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
@ -1,11 +1,11 @@
|
||||
package com.l.trace.service;
|
||||
package com.l.tracecd.service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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 com.l.tracecd.config.DeepSeekConfig;
|
||||
import com.l.tracecd.constant.Constants;
|
||||
import com.l.tracecd.dto.DeepSeekRequest;
|
||||
import com.l.tracecd.dto.DeepSeekResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@ -139,7 +139,12 @@ public class LlmService {
|
||||
|
||||
## 查询意图处理
|
||||
当用户意图是查询时,**必须调用 query_daily_records 函数**执行 SQL 查询。
|
||||
- 查询结果的总结和格式化由后续系统自动处理,你只需调用函数即可
|
||||
- 人物、日期、内容、金额相关字段查询
|
||||
- 查询完成后根据返回数据生成回复:
|
||||
* 多条数据用 markdown 表格展示,表格上方展示金额汇总
|
||||
* 一条数据直接描述
|
||||
* 无数据告知用户未找到
|
||||
* 回复要友好自然
|
||||
|
||||
## 聊天意图处理
|
||||
当用户输入与记账无关时,直接友好回复。
|
||||
@ -1,9 +1,9 @@
|
||||
package com.l.trace.service;
|
||||
package com.l.tracecd.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.l.trace.dto.BrowseQuery;
|
||||
import com.l.trace.entity.DailyRecord;
|
||||
import com.l.trace.mapper.DailyRecordMapper;
|
||||
import com.l.tracecd.dto.BrowseQuery;
|
||||
import com.l.tracecd.entity.DailyRecord;
|
||||
import com.l.tracecd.mapper.DailyRecordMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@ -1,6 +1,6 @@
|
||||
package com.l.trace.service;
|
||||
package com.l.tracecd.service;
|
||||
|
||||
import com.l.trace.constant.Constants;
|
||||
import com.l.tracecd.constant.Constants;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@ -1,22 +1,18 @@
|
||||
package com.l.trace.service;
|
||||
package com.l.tracecd.service;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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 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 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;
|
||||
|
||||
@ -133,7 +129,7 @@ public class VoiceService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理查询意图:执行 function call → 返回结果给 LLM 生成总结 → 构造结构化响应
|
||||
* 处理查询意图:执行 function call → 返回结果给 LLM → 获取格式化回复
|
||||
*/
|
||||
private VoiceResponse handleQueryIntent(DeepSeekResponse.ResponseMessage response,
|
||||
String originalText) throws Exception {
|
||||
@ -155,45 +151,15 @@ public class VoiceService {
|
||||
String validatedSql = sqlValidationService.validateSelect(sql);
|
||||
String queryResultJson = executeSelectSql(validatedSql);
|
||||
|
||||
// 解析查询结果为结构化数据,并格式化日期字段
|
||||
List<Map<String, Object>> records = parseQueryResult(queryResultJson);
|
||||
|
||||
// 将查询结果发给 LLM,只生成一句总结
|
||||
// 构建对话历史发送回 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()) {
|
||||
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;
|
||||
replyContent = "查询完成,但未能生成回复";
|
||||
}
|
||||
return VoiceResponse.queryResult(replyContent);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -223,39 +189,6 @@ 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 的完整对话历史
|
||||
*/
|
||||
@ -266,7 +199,8 @@ public class VoiceService {
|
||||
List<DeepSeekRequest.Message> messages = new ArrayList<>();
|
||||
|
||||
// system
|
||||
messages.add(new DeepSeekRequest.Message("system", CONTENT_TEMPLATE));
|
||||
messages.add(new DeepSeekRequest.Message("system",
|
||||
"你是一个日常记账助手。请根据查询结果友好地回答用户的问题。多条数据请用 markdown 表格展示,表格上方展示金额汇总。"));
|
||||
|
||||
// user original
|
||||
messages.add(new DeepSeekRequest.Message("user", originalText));
|
||||
@ -284,16 +218,4 @@ 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: trace
|
||||
name: tracecd
|
||||
main:
|
||||
banner-mode: off # 关闭 Spring Boot banner
|
||||
|
||||
@ -24,9 +24,9 @@ spring:
|
||||
|
||||
# MySQL 数据源
|
||||
datasource:
|
||||
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}
|
||||
url: jdbc:mysql://192.168.2.5:3306/tracecd?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true
|
||||
username: root
|
||||
password: root
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
|
||||
# Session 存储方式
|
||||
@ -53,7 +53,7 @@ mybatis-plus:
|
||||
logging:
|
||||
level:
|
||||
root: WARN
|
||||
com.l.trace: DEBUG
|
||||
com.l.tracecd: DEBUG
|
||||
com.baomidou.mybatisplus: WARN
|
||||
org.apache.ibatis: WARN
|
||||
pattern:
|
||||
@ -61,13 +61,13 @@ logging:
|
||||
|
||||
# DeepSeek API
|
||||
deepseek:
|
||||
api-key: ${DEEPSEEK_API_KEY}
|
||||
api-key: ${DEEPSEEK_API_KEY:sk-732e5f5f1f07454492022401f0a2bf40}
|
||||
base-url: https://api.deepseek.com/chat/completions
|
||||
model: deepseek-v4-flash
|
||||
model: deepseek-v4-pro
|
||||
|
||||
# MIMO ASR API
|
||||
mimo:
|
||||
api-key: ${MIMO_API_KEY}
|
||||
api-key: ${MIMO_API_KEY:sk-cqttr117tzllfolonxztu4qa5208liota2llfut458ixx2m1}
|
||||
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: ${APP_DEFAULT_PASSWORD}
|
||||
default-password: admin123 # 默认密码(首次启动自动创建)
|
||||
|
||||
@ -2,275 +2,167 @@
|
||||
<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, 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>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>浏览事项 - TraceCD</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, '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;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ==================== 导航 ==================== */
|
||||
.navbar {
|
||||
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: 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: 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 { background: rgba(99,102,241,0.06); color: var(--color-primary); }
|
||||
|
||||
/* ==================== 页面容器 ==================== */
|
||||
.page-container {
|
||||
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;
|
||||
box-shadow: 0 0 0 3px rgba(99,102,241,0.08);
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.navbar .brand { font-size: 18px; font-weight: 600; color: #1e293b; }
|
||||
.navbar .nav-links a {
|
||||
color: #64748b; text-decoration: none; margin-left: 20px;
|
||||
font-size: 14px; transition: color 0.2s;
|
||||
}
|
||||
.navbar .nav-links a:hover { color: #4f46e5; }
|
||||
|
||||
.page-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.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;
|
||||
.filter-bar .row { margin-bottom: 12px; }
|
||||
.filter-bar label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
}
|
||||
.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;
|
||||
.filter-bar .form-select,
|
||||
.filter-bar .form-control {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.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 {
|
||||
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;
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: background 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-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: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); }
|
||||
.btn-reset:hover { background: #f1f5f9; }
|
||||
|
||||
/* ==================== 汇总栏 ==================== */
|
||||
/* 汇总 */
|
||||
.summary-bar {
|
||||
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);
|
||||
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;
|
||||
}
|
||||
.summary-bar.visible { display: flex; }
|
||||
.summary-amount {
|
||||
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);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #4f46e5;
|
||||
}
|
||||
.summary-count { font-size: 14px; color: #64748b; }
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ==================== 表格 ==================== */
|
||||
/* 表格 */
|
||||
.table-card {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
.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: 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;
|
||||
background: #f8fafc;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
.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 14px; border-bottom: 1px solid #f1f5f9;
|
||||
font-size: var(--font-sm); color: var(--color-text); vertical-align: middle;
|
||||
transition: background 0.15s;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.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: 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;
|
||||
background: #eef2ff;
|
||||
color: #4f46e5;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.amount-cell {
|
||||
font-weight: 700; color: #ef4444; text-align: right;
|
||||
font-variant-numeric: tabular-nums; font-size: var(--font-sm);
|
||||
font-weight: 600;
|
||||
color: #ef4444;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.empty-msg {
|
||||
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;
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #94a3b8;
|
||||
font-size: 15px;
|
||||
}
|
||||
.empty-msg i { font-size: 32px; opacity: 0.4; }
|
||||
|
||||
/* 加载 */
|
||||
.loading-bar {
|
||||
text-align: center; padding: 40px 20px; display: none;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
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"><span class="brand-dot"></span> Trace</span>
|
||||
<span class="brand">📝 TraceCD</span>
|
||||
<div class="nav-links">
|
||||
<a href="/"><i class="bi bi-mic"></i> 语音</a>
|
||||
<a href="/">🎤 语音录入</a>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</nav>
|
||||
@ -278,45 +170,50 @@
|
||||
<div class="page-container">
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<!-- 人物 -->
|
||||
<div class="filter-section">
|
||||
<div class="filter-section-title">人物</div>
|
||||
<div class="chip-group" id="chipPerson"></div>
|
||||
<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="filter-section">
|
||||
<div class="filter-section-title">分类</div>
|
||||
<div class="chip-group" id="chipCategory"></div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label>开始时间</label>
|
||||
<input type="date" class="form-control" id="filterStartTime">
|
||||
</div>
|
||||
<!-- 地点 -->
|
||||
<div class="filter-section">
|
||||
<div class="filter-section-title">地点</div>
|
||||
<div class="chip-group" id="chipLocation"></div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label>结束时间</label>
|
||||
<input type="date" class="form-control" id="filterEndTime">
|
||||
</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 class="col-md-3 mb-3">
|
||||
<label>地点</label>
|
||||
<select class="form-select" id="filterLocation" multiple size="3">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 按钮 -->
|
||||
<div class="btn-group-row">
|
||||
<button class="btn-reset" onclick="resetFilters()">
|
||||
<i class="bi bi-arrow-clockwise"></i> 重置
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 汇总 -->
|
||||
<div class="summary-bar" id="summaryBar">
|
||||
<span class="summary-count" id="summaryCount"></span>
|
||||
<div>
|
||||
<span style="color:var(--color-text-secondary);font-size:var(--font-xs);margin-right:4px;">合计</span>
|
||||
<span class="summary-count" id="summaryCount"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="color: #64748b;">合计:</span>
|
||||
<span class="summary-amount" id="summaryAmount"></span>
|
||||
</div>
|
||||
</div>
|
||||
@ -324,61 +221,55 @@
|
||||
<!-- 表格 -->
|
||||
<div class="table-card">
|
||||
<div class="loading-bar" id="loadingBar">
|
||||
<div class="spinner-border" role="status"></div>
|
||||
<p>查询中...</p>
|
||||
<div class="spinner-border text-secondary" role="status"></div>
|
||||
<p class="mt-2 text-muted">查询中...</p>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table id="resultTable" style="display:none;">
|
||||
<div class="table-responsive">
|
||||
<table class="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">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<span>点击「查询」查看事项数据</span>
|
||||
</div>
|
||||
<div class="empty-msg" id="emptyMsg">点击"查询"按钮查看事项数据</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 => {
|
||||
const containerId = chipMap[opt.fieldName];
|
||||
if (!containerId) return;
|
||||
const container = document.getElementById(containerId);
|
||||
const fragment = document.createDocumentFragment();
|
||||
opt.values.forEach(v => {
|
||||
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');
|
||||
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;
|
||||
}
|
||||
opt.values.forEach(v => {
|
||||
const option = document.createElement('option');
|
||||
option.value = v;
|
||||
option.textContent = v;
|
||||
selectEl.appendChild(option);
|
||||
});
|
||||
fragment.appendChild(label);
|
||||
});
|
||||
container.appendChild(fragment);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('加载筛选选项失败:', err);
|
||||
@ -388,13 +279,21 @@
|
||||
// ==================== 查询 ====================
|
||||
async function doQuery() {
|
||||
const query = {
|
||||
persons: getCheckedValues('chipPerson'),
|
||||
startTime: buildDateTime('filterStartTime', false),
|
||||
endTime: buildDateTime('filterEndTime', true),
|
||||
locations: getCheckedValues('chipLocation'),
|
||||
categories: getCheckedValues('chipCategory')
|
||||
persons: getSelectedValues('filterPerson'),
|
||||
startTime: document.getElementById('filterStartTime').value || null,
|
||||
endTime: document.getElementById('filterEndTime').value || null,
|
||||
locations: getSelectedValues('filterLocation'),
|
||||
categories: getSelectedValues('filterCategory')
|
||||
};
|
||||
|
||||
// 如果 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');
|
||||
@ -411,24 +310,23 @@
|
||||
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.innerHTML = '<i class="bi bi-exclamation-triangle"></i><span>查询失败,请重试</span>';
|
||||
emptyMsg.style.display = 'flex';
|
||||
emptyMsg.textContent = '查询失败,请重试';
|
||||
emptyMsg.style.display = 'block';
|
||||
} 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');
|
||||
@ -437,68 +335,63 @@
|
||||
|
||||
if (!data.records || data.records.length === 0) {
|
||||
resultTable.style.display = 'none';
|
||||
emptyMsg.innerHTML = '<i class="bi bi-inbox"></i><span>未找到匹配的事项记录</span>';
|
||||
emptyMsg.style.display = 'flex';
|
||||
emptyMsg.textContent = '未找到匹配的事项记录';
|
||||
emptyMsg.style.display = 'block';
|
||||
summaryBar.classList.remove('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
// 渲染表格
|
||||
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('');
|
||||
|
||||
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() {
|
||||
['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('filterPerson').selectedIndex = -1;
|
||||
document.getElementById('filterStartTime').value = '';
|
||||
document.getElementById('filterEndTime').value = '';
|
||||
document.getElementById('filterLocation').selectedIndex = -1;
|
||||
document.getElementById('filterCategory').selectedIndex = -1;
|
||||
|
||||
document.getElementById('resultTable').style.display = 'none';
|
||||
const emptyMsg = document.getElementById('emptyMsg');
|
||||
emptyMsg.innerHTML = '<i class="bi bi-inbox"></i><span>点击「查询」查看事项数据</span>';
|
||||
emptyMsg.style.display = 'flex';
|
||||
document.getElementById('emptyMsg').style.display = 'block';
|
||||
document.getElementById('emptyMsg').textContent = '点击"查询"按钮查看事项数据';
|
||||
document.getElementById('summaryBar').classList.remove('visible');
|
||||
}
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
function getCheckedValues(containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return null;
|
||||
function getSelectedValues(selectId) {
|
||||
const select = document.getElementById(selectId);
|
||||
const values = [];
|
||||
container.querySelectorAll('input[type="checkbox"]:checked').forEach(cb => values.push(cb.value));
|
||||
for (const opt of select.options) {
|
||||
if (opt.selected) values.push(opt.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.getMonth()+1) + '-' + d.getDate() + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function formatAmount(amount) {
|
||||
@ -506,16 +399,12 @@
|
||||
return Number(amount).toFixed(2);
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
function esc(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>
|
||||
|
||||
@ -2,626 +2,578 @@
|
||||
<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, 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>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TraceCD - 日常记录</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);
|
||||
--mic-size: clamp(72px, 20vw, 88px);
|
||||
--mic-color: #6366f1;
|
||||
--mic-color-light: #818cf8;
|
||||
--mic-size: 80px;
|
||||
--mic-color: #4f46e5;
|
||||
--mic-recording: #ef4444;
|
||||
--nav-height: 56px;
|
||||
--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;
|
||||
--mic-shadow: 0 4px 20px rgba(79, 70, 229, 0.4);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
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;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding-bottom: 140px;
|
||||
}
|
||||
|
||||
/* ==================== 顶部导航 ==================== */
|
||||
/* 顶部导航 */
|
||||
.navbar {
|
||||
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);
|
||||
background: white;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.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;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.navbar .brand-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--mic-color), var(--mic-color-light));
|
||||
}
|
||||
.navbar .nav-links { padding-right: 16px; display: flex; gap: 4px; }
|
||||
.navbar .nav-links a {
|
||||
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;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
margin-left: 20px;
|
||||
font-size: 14px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.navbar .nav-links a:hover { background: rgba(99,102,241,0.06); color: var(--mic-color); }
|
||||
.navbar .nav-links a:hover { color: #4f46e5; }
|
||||
|
||||
/* ==================== 通知栏 ==================== */
|
||||
/* 通知栏 */
|
||||
.notification {
|
||||
position: fixed; top: calc(var(--nav-height) + 12px + var(--safe-top)); left: 50%;
|
||||
transform: translateX(-50%) translateY(-8px); z-index: 9999;
|
||||
background: var(--color-surface); color: #059669;
|
||||
padding: 10px 20px; border-radius: var(--radius-full);
|
||||
font-size: var(--font-sm); font-weight: 600;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.08), 0 2px 8px rgba(0,0,0,0.04);
|
||||
border: 1px solid rgba(16,185,129,0.2);
|
||||
opacity: 0; pointer-events: none; transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
max-width: calc(100vw - 40px); text-align: center;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.notification.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
.notification.error {
|
||||
color: #dc2626; border-color: rgba(239,68,68,0.2);
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
background: #10b981;
|
||||
color: white;
|
||||
padding: 12px 28px;
|
||||
border-radius: 999px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.3);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.notification.show { opacity: 1; }
|
||||
.notification.error { background: #ef4444; box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3); }
|
||||
|
||||
/* ==================== 内容区 ==================== */
|
||||
/* 内容区 */
|
||||
.content-area {
|
||||
max-width: 640px; margin: 0 auto;
|
||||
padding: 16px 12px calc(var(--mic-size) + 24px + var(--safe-bottom) + 48px);
|
||||
max-width: 800px;
|
||||
margin: 40px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* ==================== 空状态 ==================== */
|
||||
.empty-state {
|
||||
text-align: center; padding: 48px 20px 32px;
|
||||
animation: fadeInUp 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.empty-icon-wrap {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 80px; height: 80px; border-radius: 24px;
|
||||
background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 50%, #ede9fe 100%);
|
||||
box-shadow: 0 4px 16px rgba(99,102,241,0.12);
|
||||
margin-bottom: 20px; font-size: 36px;
|
||||
}
|
||||
.empty-state .empty-title {
|
||||
font-size: var(--font-lg); font-weight: 700; color: var(--color-text);
|
||||
margin-bottom: 8px; letter-spacing: -0.3px;
|
||||
}
|
||||
.empty-state .empty-desc {
|
||||
font-size: var(--font-sm); color: var(--color-text-muted); line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ==================== 查询结果 ==================== */
|
||||
/* 查询结果 */
|
||||
.result-card {
|
||||
background: transparent; border-radius: 0;
|
||||
padding: 0; box-shadow: none;
|
||||
display: none; animation: fadeInUp 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
margin-top: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
display: none;
|
||||
}
|
||||
.result-card.visible { display: block; }
|
||||
|
||||
/* 卡片列表 */
|
||||
.records-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.record-card {
|
||||
background: var(--color-surface); border-radius: var(--radius-lg);
|
||||
padding: 14px 16px; display: flex; flex-direction: column; gap: 6px;
|
||||
border-left: 4px solid #e2e8f0;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all 0.2s ease;
|
||||
animation: cardSlideIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
.result-card table { width: 100%; }
|
||||
.result-card table th, .result-card table td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
.record-card:nth-child(1) { animation-delay: 0s; }
|
||||
.record-card:nth-child(2) { animation-delay: 0.05s; }
|
||||
.record-card:nth-child(3) { animation-delay: 0.1s; }
|
||||
.record-card:nth-child(4) { animation-delay: 0.15s; }
|
||||
.record-card:nth-child(5) { animation-delay: 0.2s; }
|
||||
@keyframes cardSlideIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
.result-card table th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.record-card:active { transform: scale(0.99); opacity: 0.9; }
|
||||
|
||||
.card-top {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
}
|
||||
.card-time {
|
||||
font-size: 14px; color: var(--color-text-secondary); font-weight: 500;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.card-time .cat-emoji { font-size: 18px; }
|
||||
.card-amount {
|
||||
font-size: 20px; font-weight: 800; color: #ef4444; white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums; letter-spacing: -0.3px;
|
||||
}
|
||||
.card-detail {
|
||||
font-size: 14px; color: #334155; line-height: 1.5;
|
||||
display: flex; flex-wrap: wrap; gap: 4px 10px;
|
||||
}
|
||||
.card-detail .meta-tag {
|
||||
font-size: 12px; color: var(--color-text-muted);
|
||||
background: #f1f5f9; padding: 2px 8px; border-radius: var(--radius-full);
|
||||
.amount-summary {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #4f46e5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 底部总结 */
|
||||
.summary-footer {
|
||||
margin-top: 14px; padding: 16px 20px;
|
||||
background: linear-gradient(135deg, #eef2ff 0%, #ede9fe 100%);
|
||||
border: 1px solid rgba(99,102,241,0.1);
|
||||
border-radius: var(--radius-lg);
|
||||
text-align: center; font-size: 15px; font-weight: 600;
|
||||
color: #4f46e5; line-height: 1.6;
|
||||
animation: fadeInUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) 0.2s both;
|
||||
}
|
||||
|
||||
/* ==================== 聊天回复 ==================== */
|
||||
/* 聊天回复 */
|
||||
.chat-reply {
|
||||
background: var(--color-surface); border-radius: var(--radius-lg);
|
||||
padding: 16px 18px; box-shadow: var(--shadow-md);
|
||||
font-size: var(--font-sm); line-height: 1.7; color: var(--color-text);
|
||||
display: none; animation: fadeInUp 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
border: 1px solid rgba(226,232,240,0.5);
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 24px 30px;
|
||||
margin-top: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
color: #334155;
|
||||
display: none;
|
||||
}
|
||||
.chat-reply.visible { display: block; }
|
||||
.chat-reply p { margin: 0; }
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
/* 加载动画 */
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(255,255,255,0.7);
|
||||
z-index: 9998;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.loading-overlay.show { display: flex; }
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid #e2e8f0;
|
||||
border-top-color: #4f46e5;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ==================== 麦克风按钮区域 ==================== */
|
||||
.mic-bottom-bar {
|
||||
position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000;
|
||||
background: linear-gradient(180deg, transparent 0%, var(--color-bg) 40%, var(--color-bg) 100%);
|
||||
padding: 20px 0 calc(28px + var(--safe-bottom));
|
||||
display: flex; flex-direction: column; align-items: center; pointer-events: none;
|
||||
/* 麦克风按钮 */
|
||||
.mic-container {
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
right: 40px;
|
||||
z-index: 1000;
|
||||
}
|
||||
.mic-container { position: relative; pointer-events: auto; }
|
||||
.mic-btn {
|
||||
width: var(--mic-size); height: var(--mic-size); border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--mic-color) 0%, var(--mic-color-light) 100%);
|
||||
border: none; color: white;
|
||||
font-size: calc(var(--mic-size) * 0.38); cursor: pointer;
|
||||
box-shadow:
|
||||
0 6px 24px rgba(99,102,241,0.35),
|
||||
0 2px 8px rgba(99,102,241,0.2),
|
||||
inset 0 1px 0 rgba(255,255,255,0.2);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
|
||||
position: relative;
|
||||
width: var(--mic-size);
|
||||
height: var(--mic-size);
|
||||
border-radius: 50%;
|
||||
background: var(--mic-color);
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 32px;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--mic-shadow);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.mic-btn::before {
|
||||
content: ''; position: absolute; inset: -6px; border-radius: 50%;
|
||||
background: rgba(99,102,241,0.08); z-index: -1;
|
||||
transition: all 0.3s ease;
|
||||
.mic-btn:hover {
|
||||
transform: scale(1.06);
|
||||
box-shadow: 0 6px 28px rgba(79, 70, 229, 0.5);
|
||||
}
|
||||
.mic-btn:active {
|
||||
transform: scale(0.92);
|
||||
box-shadow: 0 3px 12px rgba(99,102,241,0.3), inset 0 1px 0 rgba(255,255,255,0.2);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.mic-btn.recording {
|
||||
background: linear-gradient(135deg, var(--mic-recording) 0%, #f87171 100%);
|
||||
box-shadow:
|
||||
0 6px 28px rgba(239,68,68,0.45),
|
||||
0 2px 8px rgba(239,68,68,0.25);
|
||||
animation: micPulse 1.6s ease-in-out infinite;
|
||||
background: var(--mic-recording);
|
||||
box-shadow: 0 4px 24px rgba(239, 68, 68, 0.5);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.mic-btn.recording::before { background: rgba(239,68,68,0.1); }
|
||||
@keyframes micPulse {
|
||||
0%, 100% { box-shadow: 0 6px 28px rgba(239,68,68,0.45), 0 2px 8px rgba(239,68,68,0.25); }
|
||||
50% { box-shadow: 0 6px 28px rgba(239,68,68,0.2), 0 0 0 14px rgba(239,68,68,0.06); }
|
||||
}
|
||||
|
||||
/* 底部状态行 */
|
||||
.mic-status-row {
|
||||
display: flex; align-items: center; gap: 10px; margin-top: 12px;
|
||||
pointer-events: none; user-select: none; -webkit-user-select: none;
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5); }
|
||||
50% { box-shadow: 0 0 0 20px rgba(239, 68, 68, 0); }
|
||||
}
|
||||
.mic-hint {
|
||||
font-size: var(--font-xs); color: var(--color-text-muted); font-weight: 500;
|
||||
}
|
||||
.mic-hint.recording-hint {
|
||||
color: var(--mic-recording); font-weight: 600;
|
||||
animation: hintBlink 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes hintBlink {
|
||||
0%, 100% { opacity: 1; } 50% { opacity: 0.4; }
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* 处理中指示器 */
|
||||
.processing-indicator { display: none; align-items: center; gap: 6px; }
|
||||
.processing-indicator.show { display: flex; }
|
||||
.processing-dot {
|
||||
width: 5px; height: 5px; border-radius: 50%; background: var(--mic-color);
|
||||
animation: dotBounce 1.2s ease-in-out infinite;
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.processing-dot:nth-child(2) { animation-delay: 0.15s; }
|
||||
.processing-dot:nth-child(3) { animation-delay: 0.3s; }
|
||||
@keyframes dotBounce {
|
||||
0%, 80%, 100% { transform: scale(0.5); opacity: 0.3; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
.processing-text { font-size: var(--font-xs); color: var(--mic-color); font-weight: 600; }
|
||||
.empty-state i { font-size: 48px; display: block; margin-bottom: 16px; }
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.content-area { padding: 28px 20px 120px; }
|
||||
.navbar .brand { padding-left: 24px; }
|
||||
/* 响应式 */
|
||||
@media (max-width: 640px) {
|
||||
.mic-container {
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
}
|
||||
:root { --mic-size: 68px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 顶部导航 -->
|
||||
<nav class="navbar">
|
||||
<span class="brand"><span class="brand-dot"></span> Trace</span>
|
||||
<span class="brand">📝 TraceCD</span>
|
||||
<div class="nav-links">
|
||||
<a href="/browse"><i class="bi bi-grid-3x3-gap"></i> 浏览</a>
|
||||
<a href="/browse">📋 浏览事项</a>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 通知 -->
|
||||
<div class="notification" id="notification"></div>
|
||||
|
||||
<!-- 加载遮罩 -->
|
||||
<div class="loading-overlay" id="loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<div class="content-area" id="contentArea">
|
||||
<div class="empty-state" id="emptyState">
|
||||
<div class="empty-icon-wrap">🎤</div>
|
||||
<div class="empty-title">语音记账助手</div>
|
||||
<div class="empty-desc">按住下方按钮说话<br>录入事项或查询记录</div>
|
||||
<i class="bi bi-mic-fill"></i>
|
||||
<p>按住右下角麦克风按钮开始语音录入或查询</p>
|
||||
</div>
|
||||
|
||||
<!-- 查询结果卡片 -->
|
||||
<div class="result-card" id="resultCard"></div>
|
||||
|
||||
<!-- 聊天回复 -->
|
||||
<div class="chat-reply" id="chatReply"></div>
|
||||
</div>
|
||||
|
||||
<div class="mic-bottom-bar">
|
||||
<div class="mic-container">
|
||||
<!-- 麦克风按钮 -->
|
||||
<div class="mic-container">
|
||||
<button class="mic-btn" id="micBtn" title="按住录音">
|
||||
<i class="bi bi-mic-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mic-status-row">
|
||||
<div class="mic-hint" id="micHint">按住说话,松开发送</div>
|
||||
<div class="processing-indicator" id="processing">
|
||||
<span class="processing-dot"></span><span class="processing-dot"></span><span class="processing-dot"></span>
|
||||
<span class="processing-text">处理中</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mic-hint">按住说话,松开发送</div>
|
||||
</div>
|
||||
|
||||
<script th:inline="javascript">
|
||||
// ==================== 麦克风权限预检 ====================
|
||||
(async function checkMicPermission() {
|
||||
try {
|
||||
if (navigator.permissions) {
|
||||
const status = await navigator.permissions.query({ name: 'microphone' });
|
||||
if (status.state === 'granted') return;
|
||||
if (status.state === 'denied') {
|
||||
showNotification('麦克风权限已被拒绝,请在系统设置中开启', true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
} catch (err) {
|
||||
console.log('麦克风权限预检:', err.message);
|
||||
}
|
||||
})();
|
||||
|
||||
// ==================== WAV 录音器 ====================
|
||||
class WavRecorder {
|
||||
constructor() {
|
||||
this.audioContext = null; this.stream = null; this.processor = null;
|
||||
this.chunks = []; this.sampleRate = 16000; this.recording = false;
|
||||
this.audioContext = null;
|
||||
this.stream = null;
|
||||
this.processor = null;
|
||||
this.chunks = [];
|
||||
this.sampleRate = 16000;
|
||||
this.recording = false;
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { sampleRate: this.sampleRate, channelCount: 1, echoCancellation: true, noiseSuppression: true }
|
||||
audio: { sampleRate: this.sampleRate, channelCount: 1, echoCancellation: true }
|
||||
});
|
||||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||||
sampleRate: this.sampleRate
|
||||
});
|
||||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: this.sampleRate });
|
||||
const source = this.audioContext.createMediaStreamSource(this.stream);
|
||||
|
||||
// 使用 ScriptProcessorNode 采集 PCM
|
||||
this.processor = this.audioContext.createScriptProcessor(4096, 1, 1);
|
||||
this.chunks = [];
|
||||
this.processor.onaudioprocess = (e) => {
|
||||
if (this.recording) this.chunks.push(new Float32Array(e.inputBuffer.getChannelData(0)));
|
||||
if (this.recording) {
|
||||
const input = e.inputBuffer.getChannelData(0);
|
||||
this.chunks.push(new Float32Array(input));
|
||||
}
|
||||
};
|
||||
|
||||
source.connect(this.processor);
|
||||
this.processor.connect(this.audioContext.destination);
|
||||
this.recording = true;
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.recording = false;
|
||||
return new Promise((resolve) => {
|
||||
// 等一小段时间让最后的数据进来
|
||||
setTimeout(() => {
|
||||
if (this.processor) { this.processor.disconnect(); this.processor = null; }
|
||||
if (this.audioContext) { this.audioContext.close(); this.audioContext = null; }
|
||||
if (this.stream) { this.stream.getTracks().forEach(t => t.stop()); this.stream = null; }
|
||||
// 断开连接
|
||||
if (this.processor) {
|
||||
this.processor.disconnect();
|
||||
this.processor = null;
|
||||
}
|
||||
if (this.audioContext) {
|
||||
this.audioContext.close();
|
||||
this.audioContext = null;
|
||||
}
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(t => t.stop());
|
||||
this.stream = null;
|
||||
}
|
||||
|
||||
// 合并所有 chunk
|
||||
const totalLength = this.chunks.reduce((sum, c) => sum + c.length, 0);
|
||||
const pcm = new Float32Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of this.chunks) { pcm.set(chunk, offset); offset += chunk.length; }
|
||||
resolve(this.encodeWAV(pcm, this.sampleRate));
|
||||
for (const chunk of this.chunks) {
|
||||
pcm.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
// 编码为 WAV
|
||||
const wavBlob = this.encodeWAV(pcm, this.sampleRate);
|
||||
resolve(wavBlob);
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
encodeWAV(samples, sampleRate) {
|
||||
const buffer = new ArrayBuffer(44 + samples.length * 2);
|
||||
const view = new DataView(buffer);
|
||||
writeString(view, 0, 'RIFF'); view.setUint32(4, 36 + samples.length * 2, true);
|
||||
writeString(view, 8, 'WAVE'); writeString(view, 12, 'fmt ');
|
||||
view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true);
|
||||
view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * 2, true);
|
||||
view.setUint16(32, 2, true); view.setUint16(34, 16, true);
|
||||
writeString(view, 36, 'data'); view.setUint32(40, samples.length * 2, true);
|
||||
|
||||
// RIFF header
|
||||
this.writeString(view, 0, 'RIFF');
|
||||
view.setUint32(4, 36 + samples.length * 2, true);
|
||||
this.writeString(view, 8, 'WAVE');
|
||||
this.writeString(view, 12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true); // PCM
|
||||
view.setUint16(22, 1, true); // mono
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * 2, true);
|
||||
view.setUint16(32, 2, true);
|
||||
view.setUint16(34, 16, true);
|
||||
this.writeString(view, 36, 'data');
|
||||
view.setUint32(40, samples.length * 2, true);
|
||||
|
||||
// PCM samples
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, samples[i]));
|
||||
view.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
||||
}
|
||||
|
||||
return new Blob([buffer], { type: 'audio/wav' });
|
||||
}
|
||||
|
||||
writeString(view, offset, string) {
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
function writeString(view, offset, string) {
|
||||
for (let i = 0; i < string.length; i++) view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
|
||||
// ==================== DOM 引用 ====================
|
||||
// ==================== 录音状态管理 ====================
|
||||
const micBtn = document.getElementById('micBtn');
|
||||
const micHint = document.getElementById('micHint');
|
||||
const processing = document.getElementById('processing');
|
||||
const loading = document.getElementById('loading');
|
||||
const resultCard = document.getElementById('resultCard');
|
||||
const chatReply = document.getElementById('chatReply');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const notification = document.getElementById('notification');
|
||||
|
||||
let recorder = null, isPressed = false, activePointerId = null;
|
||||
let recorder = null;
|
||||
let isPressed = false;
|
||||
|
||||
function getPointerId(e) {
|
||||
if (e.touches && e.touches.length > 0) return 't' + e.touches[0].identifier;
|
||||
if (e.changedTouches && e.changedTouches.length > 0) return 't' + e.changedTouches[0].identifier;
|
||||
return 'mouse';
|
||||
}
|
||||
|
||||
async function startRecording(e) {
|
||||
// 按下开始录音
|
||||
micBtn.addEventListener('mousedown', async (e) => {
|
||||
e.preventDefault();
|
||||
if (isPressed) return;
|
||||
isPressed = true; activePointerId = getPointerId(e);
|
||||
isPressed = true;
|
||||
|
||||
try {
|
||||
recorder = new WavRecorder();
|
||||
await recorder.start();
|
||||
micBtn.classList.add('recording');
|
||||
micHint.textContent = '正在录音...';
|
||||
micHint.classList.add('recording-hint');
|
||||
hapticFeedback('light');
|
||||
micBtn.querySelector('i').className = 'bi bi-mic-fill'; // keep icon
|
||||
} catch (err) {
|
||||
console.error('无法启动录音:', err);
|
||||
showNotification('无法访问麦克风,请检查权限', true);
|
||||
resetRecordingState();
|
||||
}
|
||||
isPressed = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function stopRecording(e) {
|
||||
// 松开停止录音并发送
|
||||
micBtn.addEventListener('mouseup', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!isPressed || !recorder) return;
|
||||
const currentId = getPointerId(e);
|
||||
if (activePointerId && currentId !== activePointerId && currentId !== 'mouse') return;
|
||||
isPressed = false; activePointerId = null;
|
||||
isPressed = false;
|
||||
|
||||
micBtn.classList.remove('recording');
|
||||
micHint.classList.remove('recording-hint');
|
||||
micHint.textContent = '按住说话,松开发送';
|
||||
micBtn.querySelector('i').className = 'bi bi-mic-fill';
|
||||
|
||||
try {
|
||||
loading.classList.add('show');
|
||||
const wavBlob = await recorder.stop();
|
||||
recorder = null;
|
||||
if (wavBlob.size < 200) {
|
||||
|
||||
if (wavBlob.size < 100) {
|
||||
// 录音太短
|
||||
loading.classList.remove('show');
|
||||
showNotification('录音时间太短,请重试', true);
|
||||
hapticFeedback('warning');
|
||||
return;
|
||||
}
|
||||
processing.classList.add('show');
|
||||
micHint.style.display = 'none';
|
||||
|
||||
await sendAudio(wavBlob);
|
||||
} catch (err) {
|
||||
console.error('发送失败:', err);
|
||||
showNotification('处理失败,请重试', true);
|
||||
} finally {
|
||||
processing.classList.remove('show');
|
||||
micHint.style.display = '';
|
||||
}
|
||||
loading.classList.remove('show');
|
||||
}
|
||||
});
|
||||
|
||||
// 触摸事件(移动端)
|
||||
micBtn.addEventListener('touchstart', async (e) => {
|
||||
e.preventDefault();
|
||||
if (isPressed) return;
|
||||
isPressed = true;
|
||||
|
||||
try {
|
||||
recorder = new WavRecorder();
|
||||
await recorder.start();
|
||||
micBtn.classList.add('recording');
|
||||
} catch (err) {
|
||||
console.error('无法启动录音:', err);
|
||||
showNotification('无法访问麦克风,请检查权限', true);
|
||||
isPressed = false;
|
||||
}
|
||||
});
|
||||
|
||||
micBtn.addEventListener('touchend', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!isPressed || !recorder) return;
|
||||
isPressed = false;
|
||||
|
||||
function resetRecordingState() {
|
||||
isPressed = false; activePointerId = null;
|
||||
micBtn.classList.remove('recording');
|
||||
micHint.classList.remove('recording-hint');
|
||||
micHint.textContent = '按住说话,松开发送';
|
||||
processing.classList.remove('show');
|
||||
micHint.style.display = '';
|
||||
if (recorder) { recorder.stop().catch(() => {}); recorder = null; }
|
||||
|
||||
try {
|
||||
loading.classList.add('show');
|
||||
const wavBlob = await recorder.stop();
|
||||
recorder = null;
|
||||
|
||||
if (wavBlob.size < 100) {
|
||||
loading.classList.remove('show');
|
||||
showNotification('录音时间太短,请重试', true);
|
||||
return;
|
||||
}
|
||||
|
||||
micBtn.addEventListener('mousedown', startRecording);
|
||||
micBtn.addEventListener('mouseup', stopRecording);
|
||||
micBtn.addEventListener('mouseleave', (e) => { if (isPressed) stopRecording(e); });
|
||||
micBtn.addEventListener('touchstart', startRecording, { passive: false });
|
||||
micBtn.addEventListener('touchend', stopRecording);
|
||||
micBtn.addEventListener('touchcancel', () => resetRecordingState());
|
||||
document.addEventListener('mouseup', (e) => {
|
||||
if (isPressed && activePointerId === 'mouse' && e.target !== micBtn) stopRecording(e);
|
||||
});
|
||||
document.addEventListener('touchend', (e) => {
|
||||
if (isPressed && recorder && e.target !== micBtn) stopRecording(e);
|
||||
await sendAudio(wavBlob);
|
||||
} catch (err) {
|
||||
console.error('发送失败:', err);
|
||||
showNotification('处理失败,请重试', true);
|
||||
} finally {
|
||||
loading.classList.remove('show');
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 发送音频 ====================
|
||||
async function sendAudio(audioBlob) {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', audioBlob, 'recording.wav');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/voice/process', { method: 'POST', body: formData });
|
||||
if (response.status === 401) { window.location.href = '/login'; return; }
|
||||
displayResult(await response.json());
|
||||
const response = await fetch('/api/voice/process', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
displayResult(result);
|
||||
} catch (error) {
|
||||
console.error('请求失败:', error);
|
||||
showNotification('网络错误,请重试', true);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 结果展示 ====================
|
||||
// ==================== 展示结果 ====================
|
||||
function displayResult(result) {
|
||||
hideAll();
|
||||
if (!result.success) { showNotification(result.error || '处理失败', true); return; }
|
||||
|
||||
if (!result.success) {
|
||||
showNotification(result.error || '处理失败', true);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (result.type) {
|
||||
case 'RECORD':
|
||||
showNotification('已录入「' + (result.category || '未知') + '」', false);
|
||||
hapticFeedback('success'); break;
|
||||
// 录入成功:显示通知
|
||||
showNotification('已录入「' + (result.category || '未知') + '」事项', false);
|
||||
break;
|
||||
|
||||
case 'QUERY':
|
||||
// 查询结果:渲染 markdown 表格
|
||||
emptyState.style.display = 'none';
|
||||
resultCard.classList.add('visible');
|
||||
resultCard.innerHTML = buildQueryHtml(result);
|
||||
resultCard.scrollIntoView({ behavior: 'smooth', block: 'start' }); break;
|
||||
resultCard.innerHTML = renderMarkdown(result.message);
|
||||
// 滚动到结果区
|
||||
resultCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
break;
|
||||
|
||||
case 'CHAT':
|
||||
// 聊天回复
|
||||
emptyState.style.display = 'none';
|
||||
chatReply.classList.add('visible');
|
||||
chatReply.innerHTML = '<p>' + escapeHtml(result.message) + '</p>';
|
||||
chatReply.scrollIntoView({ behavior: 'smooth', block: 'start' }); break;
|
||||
chatReply.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function hideAll() {
|
||||
resultCard.classList.remove('visible'); resultCard.innerHTML = '';
|
||||
chatReply.classList.remove('visible'); chatReply.innerHTML = '';
|
||||
resultCard.classList.remove('visible');
|
||||
chatReply.classList.remove('visible');
|
||||
}
|
||||
|
||||
// ==================== 分类样式映射 ====================
|
||||
const CAT_STYLES = {
|
||||
'吃饭': { e:'🍜', c:'#f97316', b:'#fff7ed' },
|
||||
'购物': { e:'🛒', c:'#3b82f6', b:'#eff6ff' },
|
||||
'加油': { e:'⛽', c:'#22c55e', b:'#f0fdf4' },
|
||||
'修车': { e:'🔧', c:'#22c55e', b:'#f0fdf4' },
|
||||
'交通': { e:'🚗', c:'#22c55e', b:'#f0fdf4' },
|
||||
'出行': { e:'🚕', c:'#22c55e', b:'#f0fdf4' },
|
||||
'租房': { e:'🏠', c:'#8b5cf6', b:'#f5f3ff' },
|
||||
'房租': { e:'🏠', c:'#8b5cf6', b:'#f5f3ff' },
|
||||
'水电': { e:'💡', c:'#06b6d4', b:'#ecfeff' },
|
||||
'话费': { e:'📱', c:'#06b6d4', b:'#ecfeff' },
|
||||
'旅游': { e:'✈️', c:'#ec4899', b:'#fdf2f8' },
|
||||
'娱乐': { e:'🎮', c:'#ec4899', b:'#fdf2f8' },
|
||||
'医疗': { e:'💊', c:'#ef4444', b:'#fef2f2' },
|
||||
'药品': { e:'💊', c:'#ef4444', b:'#fef2f2' },
|
||||
'教育': { e:'📚', c:'#6366f1', b:'#eef2ff' },
|
||||
'学习': { e:'📚', c:'#6366f1', b:'#eef2ff' },
|
||||
'剪头': { e:'💇', c:'#a855f7', b:'#faf5ff' },
|
||||
'理发': { e:'💇', c:'#a855f7', b:'#faf5ff' },
|
||||
'衣服': { e:'👗', c:'#ec4899', b:'#fdf2f8' },
|
||||
'水果': { e:'🍎', c:'#f97316', b:'#fff7ed' },
|
||||
'零食': { e:'🍿', c:'#f97316', b:'#fff7ed' },
|
||||
'饮料': { e:'🧃', c:'#06b6d4', b:'#ecfeff' },
|
||||
'运动': { e:'⚽', c:'#22c55e', b:'#f0fdf4' },
|
||||
'宠物': { e:'🐱', c:'#f97316', b:'#fff7ed' },
|
||||
'快递': { e:'📦', c:'#64748b', b:'#f8fafc' },
|
||||
'日用': { e:'🧴', c:'#64748b', b:'#f8fafc' },
|
||||
};
|
||||
const CAT_DEFAULT = { e:'📌', c:'#94a3b8', b:'#f8fafc' };
|
||||
// ==================== 简易 Markdown 渲染 ====================
|
||||
function renderMarkdown(md) {
|
||||
if (!md) return '';
|
||||
|
||||
function catStyle(category) {
|
||||
if (category && CAT_STYLES[category]) return CAT_STYLES[category];
|
||||
return CAT_DEFAULT;
|
||||
}
|
||||
let html = md;
|
||||
|
||||
// ==================== 构建卡片列表 ====================
|
||||
function buildQueryHtml(result) {
|
||||
let html = '';
|
||||
const records = result.records;
|
||||
// 提取金额汇总行
|
||||
html = html.replace(/^(.*?金额.*?[::]?\s*[\d,.]+.*)$/gm,
|
||||
'<div class="amount-summary">$1</div>');
|
||||
|
||||
if (records && records.length > 0) {
|
||||
html += '<div class="records-list">';
|
||||
records.forEach(row => {
|
||||
const time = field(row, 'record_time', 'recordTime');
|
||||
const person = field(row, 'person');
|
||||
const loc = field(row, 'location');
|
||||
const cont = field(row, 'content');
|
||||
const amount = field(row, 'amount');
|
||||
const cat = field(row, 'category');
|
||||
const cs = catStyle(cat);
|
||||
|
||||
const meta = [person, loc].filter(Boolean).map(escapeHtml).join(' · ');
|
||||
|
||||
html += '<div class="record-card" style="border-left-color:' + cs.c + ';background:' + cs.b + ';">' +
|
||||
'<div class="card-top">' +
|
||||
'<span class="card-time"><span class="cat-emoji">' + cs.e + '</span>' + escapeHtml(time || '') + '</span>' +
|
||||
'<span class="card-amount">¥' + formatAmount(amount) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="card-detail">' +
|
||||
'<span>' + escapeHtml(cont || '') + '</span>' +
|
||||
(meta ? '<span class="meta-tag">' + meta + '</span>' : '') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
// 表格
|
||||
html = html.replace(/\|(.+)\|/g, (match) => {
|
||||
const cells = match.split('|').filter(c => c.trim() !== '');
|
||||
const isHeader = match.includes('---');
|
||||
if (isHeader) return '';
|
||||
const tag = match === html.split('\n').find(l => l.includes('|') && !l.includes('---')) ? 'th' : 'td';
|
||||
return '<tr>' + cells.map(c => '<' + tag + '>' + c.trim() + '</' + tag + '>').join('') + '</tr>';
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '<div class="summary-footer">' + escapeHtml(result.message || '查询完成') + '</div>';
|
||||
// 包裹表格
|
||||
html = html.replace(/(<tr>[\s\S]*?<\/tr>)/g, (match) => {
|
||||
if (!match.includes('<table>')) {
|
||||
return '<table class="table table-striped">' + match + '</table>';
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
// 金额汇总(如果未匹配到)
|
||||
html = html.replace(/(?:合计|总计|金额汇总|总金额)[::]?\s*([\d,.]+)\s*元?/g,
|
||||
'<div class="amount-summary">合计:$1 元</div>');
|
||||
|
||||
// 换行转 <br>
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function field(row, ...keys) {
|
||||
for (const k of keys) {
|
||||
if (row[k] != null) return row[k];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatAmount(val) {
|
||||
if (val == null) return '0.00';
|
||||
const n = Number(val);
|
||||
return isNaN(n) ? '0.00' : n.toFixed(2);
|
||||
}
|
||||
|
||||
function hapticFeedback(style) {
|
||||
if (navigator.vibrate) {
|
||||
const patterns = { light: 10, success: [10, 50, 10, 50, 10], warning: [30, 80, 30] };
|
||||
navigator.vibrate(Array.isArray(patterns[style]) ? patterns[style] : [patterns[style] || 10]);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 通知 ====================
|
||||
let notifyTimer = null;
|
||||
|
||||
function showNotification(message, isError) {
|
||||
if (notifyTimer) clearTimeout(notifyTimer);
|
||||
const icon = isError ? '✕' : '✓';
|
||||
notification.innerHTML = '<span style="font-weight:800;margin-right:2px;">' + icon + '</span> ' + escapeHtml(message);
|
||||
|
||||
notification.textContent = message;
|
||||
notification.className = 'notification' + (isError ? ' error' : '');
|
||||
void notification.offsetWidth;
|
||||
// 强制回流
|
||||
notification.offsetHeight;
|
||||
notification.classList.add('show');
|
||||
notifyTimer = setTimeout(() => notification.classList.remove('show'), 3000);
|
||||
|
||||
notifyTimer = setTimeout(() => {
|
||||
notification.classList.remove('show');
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
|
||||
@ -2,245 +2,48 @@
|
||||
<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, 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>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 - TraceCD</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 {
|
||||
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;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ==================== 渐变背景 ==================== */
|
||||
.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: 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);
|
||||
}
|
||||
@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);
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
.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; }
|
||||
.login-card h1 {
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<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 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>
|
||||
|
||||
<div th:if="${error}" class="alert-custom">
|
||||
<i class="bi bi-exclamation-circle-fill"></i>
|
||||
<span th:text="${error}"></span>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">密码</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</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>
|
||||
<button type="submit" class="btn btn-primary w-100">登 录</button>
|
||||
</form>
|
||||
|
||||
<div class="login-footer">语音记录 · 智能分析 · 轻松管理</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user