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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dev # 只有推送到 dev 分支时才触发自动部署
|
- dev # 只有推送到 main 分支时才触发自动部署
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE_NAME: trace-img
|
IMAGE_NAME: trace-img
|
||||||
@ -16,25 +16,16 @@ jobs:
|
|||||||
runs-on: centos-env
|
runs-on: centos-env
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# 替换为以下纯命令形式(docker:cli 镜像中默认自带 git):
|
- name: 1. 拉取最新的仓库代码
|
||||||
- name: 1. 拉取最新的仓库代码 (纯 Git 命令行模式)
|
uses: actions/checkout@v4
|
||||||
shell: sh
|
|
||||||
run: |
|
|
||||||
# 清空当前工作目录,防止历史残留
|
|
||||||
rm -rf ./*
|
|
||||||
# 利用 Gitea 自动注入的内置变量,动态克隆当前仓库、当前分支、当前提交
|
|
||||||
git clone --depth 1 -b ${{ gitea.ref_name }} http://gitea:3000/${{ gitea.repository }} .
|
|
||||||
git checkout ${{ gitea.sha }}
|
|
||||||
|
|
||||||
- name: 2. 触发 Docker 容器内编译与镜像打包
|
- name: 2. 触发 Docker 容器内编译与镜像打包
|
||||||
shell: sh
|
|
||||||
run: |
|
run: |
|
||||||
echo "🚀 开始构建 JDK 26 镜像: ${IMAGE_NAME}:${{ gitea.sha }}"
|
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
|
docker tag ${IMAGE_NAME}:${{ gitea.sha }} ${IMAGE_NAME}:latest
|
||||||
|
|
||||||
- name: 3. 旧容器清理与新容器平滑重启
|
- name: 3. 旧容器清理与新容器平滑重启
|
||||||
shell: sh
|
|
||||||
run: |
|
run: |
|
||||||
echo "🧹 正在清理旧版本的容器..."
|
echo "🧹 正在清理旧版本的容器..."
|
||||||
docker stop ${CONTAINER_NAME} || true
|
docker stop ${CONTAINER_NAME} || true
|
||||||
@ -43,38 +34,28 @@ jobs:
|
|||||||
echo "🔄 正在启动全新 JDK 26 容器环境..."
|
echo "🔄 正在启动全新 JDK 26 容器环境..."
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name ${CONTAINER_NAME} \
|
--name ${CONTAINER_NAME} \
|
||||||
--network trace-net \
|
-p ${APP_PORT}:${APP_PORT} \
|
||||||
-p 127.0.0.1:${APP_PORT}:${APP_PORT} \
|
--restart no \
|
||||||
--restart unless-stopped \
|
-m 512m \
|
||||||
--memory 384m \
|
-e JAVA_TOOL_OPTIONS="-Xms128m -Xmx384m -XX:+UseG1GC" \
|
||||||
--memory-swap 768m \
|
|
||||||
--cpus 0.75 \
|
|
||||||
--pids-limit 256 \
|
|
||||||
--env-file /opt/trace/app.env \
|
|
||||||
-e JAVA_TOOL_OPTIONS="-Xms64m -Xmx256m -XX:+UseSerialGC" \
|
|
||||||
${IMAGE_NAME}:latest
|
${IMAGE_NAME}:latest
|
||||||
|
|
||||||
- name: 4. 服务健康检查 (HTTP 探活)
|
- name: 4. 服务健康检查 (HTTP 探活)
|
||||||
shell: sh
|
|
||||||
run: |
|
run: |
|
||||||
echo "🔍 开始对端口 ${APP_PORT} 进行健康状态探活..."
|
echo "🔍 开始对端口 ${APP_PORT} 进行健康状态探活..."
|
||||||
|
|
||||||
# 循环探活:每隔 5 秒检测一次,最多尝试 15 次(总计 75 秒,给高版本 JVM 充足的预热时间)
|
# 循环探活:每隔 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 已拉起
|
# 发送轻量级请求,只要服务响应了(无论是200还是Spring默认的404/无路由),都证明 Tomcat/Netty 已拉起
|
||||||
if wget -q --spider http://${CONTAINER_NAME}:${APP_PORT}/; then
|
if curl -s -f http://localhost:${APP_PORT}/hello > /dev/null; then
|
||||||
echo "✅ [SUCCESS] 探活成功!Java 26 服务已成功拉起。"
|
echo "✅ [SUCCESS] 探活成功!Java 26 服务已成功拉起,并开始提供服务。"
|
||||||
echo "🧹 开始安全地清理历史无用镜像及过期构建缓存..."
|
|
||||||
docker image prune -f
|
|
||||||
docker builder prune --filter "until=24h" -f
|
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "⏳ [WAITING] 服务仍在启动中,等待 5 秒后重试 ($i/6)..."
|
echo "⏳ [WAITING] 服务仍在启动中,等待 5 秒后重试 ($i/15)..."
|
||||||
sleep 5
|
sleep 5
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "❌ [ERROR] 探活失败!服务在 30 秒内未能建立连接,可能发生了启动闪退。"
|
echo "❌ [ERROR] 探活失败!服务在 75 秒内未能建立连接,可能发生了启动闪退。"
|
||||||
echo "===== 以下为容器崩溃前的实时日志 ====="
|
echo "===== 以下为容器崩溃前的实时日志 ====="
|
||||||
docker logs ${CONTAINER_NAME}
|
docker logs ${CONTAINER_NAME}
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -1,10 +1,4 @@
|
|||||||
target/
|
target/
|
||||||
deploy/.env
|
|
||||||
deploy/stack.env
|
|
||||||
deploy/app.env
|
|
||||||
deploy/runner-token
|
|
||||||
deploy/data/
|
|
||||||
.migration-private/
|
|
||||||
!.mvn/wrapper/maven-wrapper.jar
|
!.mvn/wrapper/maven-wrapper.jar
|
||||||
!**/src/main/**/target/
|
!**/src/main/**/target/
|
||||||
!**/src/test/**/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"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
<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>
|
<driver-ref>mysql.8</driver-ref>
|
||||||
<synchronize>true</synchronize>
|
<synchronize>true</synchronize>
|
||||||
<imported>true</imported>
|
<imported>true</imported>
|
||||||
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
|
<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>
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
</data-source>
|
</data-source>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@ -36,4 +36,4 @@ Standard Maven layout:
|
|||||||
- `src/main/resources/` — resources bundled with the app
|
- `src/main/resources/` — resources bundled with the app
|
||||||
- `src/test/java/` — test source
|
- `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 进行编译打包 ===
|
# === 第一阶段:使用 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
|
WORKDIR /app
|
||||||
|
|
||||||
# 1C/1G 部署机上限制 Maven 峰值内存,避免构建挤占运行中的服务。
|
|
||||||
ENV MAVEN_OPTS="-Xms64m -Xmx384m -XX:+UseSerialGC"
|
|
||||||
|
|
||||||
# 1. 配置阿里云 Maven 镜像加速下载(可选,国内服务器强烈推荐)
|
# 1. 配置阿里云 Maven 镜像加速下载(可选,国内服务器强烈推荐)
|
||||||
RUN mkdir -p /root/.m2
|
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
|
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,实现"说话即记账"的流畅体验。
|
基于语音识别的智能日常记账应用。按住说话即可完成事项录入与查询,由 DeepSeek 大模型理解意图并自动生成 SQL,实现"说话即记账"的流畅体验。
|
||||||
|
|
||||||
@ -41,8 +41,8 @@
|
|||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
src/main/java/com/l/trace/
|
src/main/java/com/l/tracecd/
|
||||||
├── TraceApplication.java # 应用主入口
|
├── TracecdApplication.java # 应用主入口
|
||||||
├── config/
|
├── config/
|
||||||
│ ├── DatabaseInitializer.java # 启动自动建表
|
│ ├── DatabaseInitializer.java # 启动自动建表
|
||||||
│ ├── DeepSeekConfig.java # DeepSeek API 配置
|
│ ├── DeepSeekConfig.java # DeepSeek API 配置
|
||||||
@ -108,7 +108,7 @@ src/main/java/com/l/trace/
|
|||||||
创建 MySQL 数据库(应用会自动建表):
|
创建 MySQL 数据库(应用会自动建表):
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE DATABASE IF NOT EXISTS trace DEFAULT CHARSET utf8mb4;
|
CREATE DATABASE IF NOT EXISTS tracecd DEFAULT CHARSET utf8mb4;
|
||||||
```
|
```
|
||||||
|
|
||||||
修改 `src/main/resources/application.yml` 中的数据库连接信息:
|
修改 `src/main/resources/application.yml` 中的数据库连接信息:
|
||||||
@ -116,7 +116,7 @@ CREATE DATABASE IF NOT EXISTS trace DEFAULT CHARSET utf8mb4;
|
|||||||
```yaml
|
```yaml
|
||||||
spring:
|
spring:
|
||||||
datasource:
|
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
|
username: your-username
|
||||||
password: your-password
|
password: your-password
|
||||||
```
|
```
|
||||||
@ -141,7 +141,7 @@ mvn spring-boot:run
|
|||||||
|
|
||||||
# 或先打包再运行
|
# 或先打包再运行
|
||||||
mvn clean package -DskipTests
|
mvn clean package -DskipTests
|
||||||
java -jar target/trace-1.0-SNAPSHOT.jar
|
java -jar target/tracecd-1.0-SNAPSHOT.jar
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. 访问
|
### 4. 访问
|
||||||
@ -155,18 +155,18 @@ java -jar target/trace-1.0-SNAPSHOT.jar
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 构建镜像
|
# 构建镜像
|
||||||
docker build -t trace:latest .
|
docker build -t tracecd:latest .
|
||||||
|
|
||||||
# 运行容器
|
# 运行容器
|
||||||
docker run -d \
|
docker run -d \
|
||||||
-p 8080:8080 \
|
-p 8080:8080 \
|
||||||
-e DEEPSEEK_API_KEY=sk-your-key \
|
-e DEEPSEEK_API_KEY=sk-your-key \
|
||||||
-e MIMO_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_USERNAME=root \
|
||||||
-e SPRING_DATASOURCE_PASSWORD=root \
|
-e SPRING_DATASOURCE_PASSWORD=root \
|
||||||
--name trace \
|
--name tracecd \
|
||||||
trace:latest
|
tracecd:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## API 接口
|
## 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>
|
</parent>
|
||||||
|
|
||||||
<groupId>com.l</groupId>
|
<groupId>com.l</groupId>
|
||||||
<artifactId>trace</artifactId>
|
<artifactId>tracecd</artifactId>
|
||||||
<version>1.0-SNAPSHOT</version>
|
<version>1.0-SNAPSHOT</version>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package com.l.trace;
|
package com.l.tracecd;
|
||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
@ -8,9 +8,9 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
* 应用主入口
|
* 应用主入口
|
||||||
*/
|
*/
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
public class TraceApplication {
|
public class TracecdApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
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.boot.context.properties.ConfigurationProperties;
|
||||||
import org.springframework.context.annotation.Bean;
|
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.boot.context.properties.ConfigurationProperties;
|
||||||
import org.springframework.context.annotation.Bean;
|
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.context.annotation.Configuration;
|
||||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
@ -15,6 +15,6 @@ public class MvcConfig implements WebMvcConfigurer {
|
|||||||
public void addInterceptors(InterceptorRegistry registry) {
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
registry.addInterceptor(new AuthInterceptor())
|
registry.addInterceptor(new AuthInterceptor())
|
||||||
.addPathPatterns("/**")
|
.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;
|
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.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.session.MapSession;
|
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.tracecd.constant.Constants;
|
||||||
import com.l.trace.entity.User;
|
import com.l.tracecd.entity.User;
|
||||||
import com.l.trace.service.AuthService;
|
import com.l.tracecd.service.AuthService;
|
||||||
import jakarta.servlet.http.HttpSession;
|
import jakarta.servlet.http.HttpSession;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
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.tracecd.dto.BrowseQuery;
|
||||||
import com.l.trace.entity.DailyRecord;
|
import com.l.tracecd.entity.DailyRecord;
|
||||||
import com.l.trace.service.RecordService;
|
import com.l.tracecd.service.RecordService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
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.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
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.tracecd.constant.Constants;
|
||||||
import com.l.trace.dto.FilterOption;
|
import com.l.tracecd.dto.FilterOption;
|
||||||
import com.l.trace.service.DistinctValueService;
|
import com.l.tracecd.service.DistinctValueService;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
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.stereotype.Controller;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
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.tracecd.dto.VoiceResponse;
|
||||||
import com.l.trace.service.VoiceService;
|
import com.l.tracecd.service.VoiceService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.lang.NonNull;
|
import org.springframework.lang.NonNull;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.l.trace.dto;
|
package com.l.tracecd.dto;
|
||||||
|
|
||||||
import java.util.List;
|
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.JsonInclude;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
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.JsonIgnoreProperties;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.l.trace.dto;
|
package com.l.tracecd.dto;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@ -1,7 +1,4 @@
|
|||||||
package com.l.trace.dto;
|
package com.l.tracecd.dto;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 语音处理响应 DTO
|
* 语音处理响应 DTO
|
||||||
@ -24,9 +21,6 @@ public class VoiceResponse {
|
|||||||
/** 错误信息 */
|
/** 错误信息 */
|
||||||
private String error;
|
private String error;
|
||||||
|
|
||||||
/** 查询结果记录列表 */
|
|
||||||
private List<Map<String, Object>> records;
|
|
||||||
|
|
||||||
public static VoiceResponse recordSuccess(String category) {
|
public static VoiceResponse recordSuccess(String category) {
|
||||||
VoiceResponse r = new VoiceResponse();
|
VoiceResponse r = new VoiceResponse();
|
||||||
r.type = "RECORD";
|
r.type = "RECORD";
|
||||||
@ -36,12 +30,11 @@ public class VoiceResponse {
|
|||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static VoiceResponse queryResult(String summary, List<Map<String, Object>> records) {
|
public static VoiceResponse queryResult(String message) {
|
||||||
VoiceResponse r = new VoiceResponse();
|
VoiceResponse r = new VoiceResponse();
|
||||||
r.type = "QUERY";
|
r.type = "QUERY";
|
||||||
r.success = true;
|
r.success = true;
|
||||||
r.message = summary;
|
r.message = message;
|
||||||
r.records = records;
|
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,21 +55,43 @@ public class VoiceResponse {
|
|||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getType() { return type; }
|
public String getType() {
|
||||||
public void setType(String type) { this.type = type; }
|
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() {
|
||||||
public boolean isSuccess() { return success; }
|
return message;
|
||||||
public void setSuccess(boolean success) { this.success = success; }
|
}
|
||||||
|
|
||||||
public String getError() { return error; }
|
public void setMessage(String message) {
|
||||||
public void setError(String error) { this.error = error; }
|
this.message = message;
|
||||||
|
}
|
||||||
public List<Map<String, Object>> getRecords() { return records; }
|
|
||||||
public void setRecords(List<Map<String, Object>> records) { this.records = records; }
|
public String getCategory() {
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.IdType;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
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.IdType;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
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.IdType;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
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.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import jakarta.servlet.http.HttpSession;
|
import jakarta.servlet.http.HttpSession;
|
||||||
@ -21,8 +21,7 @@ public class AuthInterceptor implements HandlerInterceptor {
|
|||||||
"/login",
|
"/login",
|
||||||
"/css/",
|
"/css/",
|
||||||
"/js/",
|
"/js/",
|
||||||
"/error",
|
"/error"
|
||||||
"hello"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -1,7 +1,7 @@
|
|||||||
package com.l.trace.mapper;
|
package com.l.tracecd.mapper;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
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;
|
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.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.l.trace.entity.DistinctValue;
|
import com.l.tracecd.entity.DistinctValue;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
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.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.l.trace.entity.User;
|
import com.l.tracecd.entity.User;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
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.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
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.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.l.trace.constant.Constants;
|
import com.l.tracecd.constant.Constants;
|
||||||
import com.l.trace.entity.User;
|
import com.l.tracecd.entity.User;
|
||||||
import com.l.trace.mapper.UserMapper;
|
import com.l.tracecd.mapper.UserMapper;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
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.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.l.trace.constant.Constants;
|
import com.l.tracecd.constant.Constants;
|
||||||
import com.l.trace.entity.DailyRecord;
|
import com.l.tracecd.entity.DailyRecord;
|
||||||
import com.l.trace.entity.DistinctValue;
|
import com.l.tracecd.entity.DistinctValue;
|
||||||
import com.l.trace.mapper.DistinctValueMapper;
|
import com.l.tracecd.mapper.DistinctValueMapper;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
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.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.l.trace.config.DeepSeekConfig;
|
import com.l.tracecd.config.DeepSeekConfig;
|
||||||
import com.l.trace.constant.Constants;
|
import com.l.tracecd.constant.Constants;
|
||||||
import com.l.trace.dto.DeepSeekRequest;
|
import com.l.tracecd.dto.DeepSeekRequest;
|
||||||
import com.l.trace.dto.DeepSeekResponse;
|
import com.l.tracecd.dto.DeepSeekResponse;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@ -139,7 +139,12 @@ public class LlmService {
|
|||||||
|
|
||||||
## 查询意图处理
|
## 查询意图处理
|
||||||
当用户意图是查询时,**必须调用 query_daily_records 函数**执行 SQL 查询。
|
当用户意图是查询时,**必须调用 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.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.l.trace.dto.BrowseQuery;
|
import com.l.tracecd.dto.BrowseQuery;
|
||||||
import com.l.trace.entity.DailyRecord;
|
import com.l.tracecd.entity.DailyRecord;
|
||||||
import com.l.trace.mapper.DailyRecordMapper;
|
import com.l.tracecd.mapper.DailyRecordMapper;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
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.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.l.trace.constant.Constants;
|
import com.l.tracecd.constant.Constants;
|
||||||
import com.l.trace.dto.DeepSeekRequest;
|
import com.l.tracecd.dto.DeepSeekRequest;
|
||||||
import com.l.trace.dto.DeepSeekResponse;
|
import com.l.tracecd.dto.DeepSeekResponse;
|
||||||
import com.l.trace.dto.VoiceResponse;
|
import com.l.tracecd.dto.VoiceResponse;
|
||||||
import com.l.trace.entity.DailyRecord;
|
import com.l.tracecd.entity.DailyRecord;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import javax.sql.DataSource;
|
import javax.sql.DataSource;
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@ -133,7 +129,7 @@ public class VoiceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理查询意图:执行 function call → 返回结果给 LLM 生成总结 → 构造结构化响应
|
* 处理查询意图:执行 function call → 返回结果给 LLM → 获取格式化回复
|
||||||
*/
|
*/
|
||||||
private VoiceResponse handleQueryIntent(DeepSeekResponse.ResponseMessage response,
|
private VoiceResponse handleQueryIntent(DeepSeekResponse.ResponseMessage response,
|
||||||
String originalText) throws Exception {
|
String originalText) throws Exception {
|
||||||
@ -155,45 +151,15 @@ public class VoiceService {
|
|||||||
String validatedSql = sqlValidationService.validateSelect(sql);
|
String validatedSql = sqlValidationService.validateSelect(sql);
|
||||||
String queryResultJson = executeSelectSql(validatedSql);
|
String queryResultJson = executeSelectSql(validatedSql);
|
||||||
|
|
||||||
// 解析查询结果为结构化数据,并格式化日期字段
|
// 构建对话历史发送回 LLM
|
||||||
List<Map<String, Object>> records = parseQueryResult(queryResultJson);
|
|
||||||
|
|
||||||
// 将查询结果发给 LLM,只生成一句总结
|
|
||||||
List<DeepSeekRequest.Message> messages = buildToolResultMessages(originalText, response, toolCall, queryResultJson);
|
List<DeepSeekRequest.Message> messages = buildToolResultMessages(originalText, response, toolCall, queryResultJson);
|
||||||
DeepSeekResponse.ResponseMessage finalResponse = llmService.continueWithToolResult(messages);
|
DeepSeekResponse.ResponseMessage finalResponse = llmService.continueWithToolResult(messages);
|
||||||
|
|
||||||
String replyContent = finalResponse.getContent();
|
String replyContent = finalResponse.getContent();
|
||||||
String summary;
|
|
||||||
if (replyContent == null || replyContent.isBlank()) {
|
if (replyContent == null || replyContent.isBlank()) {
|
||||||
summary = "查询完成";
|
replyContent = "查询完成,但未能生成回复";
|
||||||
} else {
|
|
||||||
// 尝试从 LLM 回复中提取 JSON
|
|
||||||
summary = extractJsonField(replyContent, "summary");
|
|
||||||
if (summary == null) {
|
|
||||||
summary = replyContent; // 降级:直接使用原始回复
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return VoiceResponse.queryResult(summary, records);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从 LLM 返回的 JSON 字符串中提取指定字段
|
|
||||||
*/
|
|
||||||
private String extractJsonField(String content, String field) {
|
|
||||||
try {
|
|
||||||
// 尝试直接解析
|
|
||||||
String trimmed = content.trim();
|
|
||||||
// 去掉可能的 markdown 代码块包裹
|
|
||||||
trimmed = trimmed.replaceAll("^```json\\s*", "").replaceAll("^```\\s*", "").replaceAll("```$", "").trim();
|
|
||||||
Map<String, Object> map = objectMapper.readValue(trimmed,
|
|
||||||
new TypeReference<Map<String, Object>>() {});
|
|
||||||
Object value = map.get(field);
|
|
||||||
return value != null ? value.toString() : null;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.debug("解析 LLM JSON 回复失败,将使用原始文本: {}", e.getMessage());
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return VoiceResponse.queryResult(replyContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -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 的完整对话历史
|
* 构建 function call 的完整对话历史
|
||||||
*/
|
*/
|
||||||
@ -266,7 +199,8 @@ public class VoiceService {
|
|||||||
List<DeepSeekRequest.Message> messages = new ArrayList<>();
|
List<DeepSeekRequest.Message> messages = new ArrayList<>();
|
||||||
|
|
||||||
// system
|
// system
|
||||||
messages.add(new DeepSeekRequest.Message("system", CONTENT_TEMPLATE));
|
messages.add(new DeepSeekRequest.Message("system",
|
||||||
|
"你是一个日常记账助手。请根据查询结果友好地回答用户的问题。多条数据请用 markdown 表格展示,表格上方展示金额汇总。"));
|
||||||
|
|
||||||
// user original
|
// user original
|
||||||
messages.add(new DeepSeekRequest.Message("user", originalText));
|
messages.add(new DeepSeekRequest.Message("user", originalText));
|
||||||
@ -284,16 +218,4 @@ public class VoiceService {
|
|||||||
|
|
||||||
return messages;
|
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:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: trace
|
name: tracecd
|
||||||
main:
|
main:
|
||||||
banner-mode: off # 关闭 Spring Boot banner
|
banner-mode: off # 关闭 Spring Boot banner
|
||||||
|
|
||||||
@ -24,9 +24,9 @@ spring:
|
|||||||
|
|
||||||
# MySQL 数据源
|
# MySQL 数据源
|
||||||
datasource:
|
datasource:
|
||||||
url: ${SPRING_DATASOURCE_URL:jdbc:mysql://mysql:3306/trace?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true}
|
url: jdbc:mysql://192.168.2.5:3306/tracecd?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true
|
||||||
username: ${SPRING_DATASOURCE_USERNAME:trace}
|
username: root
|
||||||
password: ${SPRING_DATASOURCE_PASSWORD}
|
password: root
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
|
||||||
# Session 存储方式
|
# Session 存储方式
|
||||||
@ -53,7 +53,7 @@ mybatis-plus:
|
|||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
root: WARN
|
root: WARN
|
||||||
com.l.trace: DEBUG
|
com.l.tracecd: DEBUG
|
||||||
com.baomidou.mybatisplus: WARN
|
com.baomidou.mybatisplus: WARN
|
||||||
org.apache.ibatis: WARN
|
org.apache.ibatis: WARN
|
||||||
pattern:
|
pattern:
|
||||||
@ -61,13 +61,13 @@ logging:
|
|||||||
|
|
||||||
# DeepSeek API
|
# DeepSeek API
|
||||||
deepseek:
|
deepseek:
|
||||||
api-key: ${DEEPSEEK_API_KEY}
|
api-key: ${DEEPSEEK_API_KEY:sk-732e5f5f1f07454492022401f0a2bf40}
|
||||||
base-url: https://api.deepseek.com/chat/completions
|
base-url: https://api.deepseek.com/chat/completions
|
||||||
model: deepseek-v4-flash
|
model: deepseek-v4-pro
|
||||||
|
|
||||||
# MIMO ASR API
|
# MIMO ASR API
|
||||||
mimo:
|
mimo:
|
||||||
api-key: ${MIMO_API_KEY}
|
api-key: ${MIMO_API_KEY:sk-cqttr117tzllfolonxztu4qa5208liota2llfut458ixx2m1}
|
||||||
base-url: https://api.xiaomimimo.com/v1/chat/completions
|
base-url: https://api.xiaomimimo.com/v1/chat/completions
|
||||||
model: mimo-v2.5-asr
|
model: mimo-v2.5-asr
|
||||||
|
|
||||||
@ -75,4 +75,4 @@ mimo:
|
|||||||
app:
|
app:
|
||||||
sql-max-retries: 5 # SQL 生成最大重试次数
|
sql-max-retries: 5 # SQL 生成最大重试次数
|
||||||
default-username: admin # 默认用户名
|
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">
|
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<title>浏览事项 - TraceCD</title>
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
|
||||||
<title>浏览事项 - Trace</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@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">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
<style>
|
<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 {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
background: var(--color-bg); min-height: 100vh; min-height: 100dvh;
|
background: #f8fafc;
|
||||||
margin: 0; padding: 0; -webkit-font-smoothing: antialiased; overflow-x: hidden;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==================== 导航 ==================== */
|
|
||||||
.navbar {
|
.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;
|
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 {
|
.filter-bar {
|
||||||
display: flex; flex-wrap: wrap; gap: 6px;
|
background: white;
|
||||||
max-height: 96px; overflow-y: auto; -webkit-overflow-scrolling: touch;
|
border-radius: 16px;
|
||||||
padding: 2px 0;
|
padding: 24px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||||
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
.chip-group::-webkit-scrollbar { width: 3px; }
|
.filter-bar .row { margin-bottom: 12px; }
|
||||||
.chip-group::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 3px; }
|
.filter-bar label {
|
||||||
|
font-size: 13px;
|
||||||
.chip-group .chip {
|
font-weight: 600;
|
||||||
display: inline-flex; align-items: center; gap: 4px;
|
color: #64748b;
|
||||||
padding: 6px 12px; border-radius: var(--radius-full);
|
margin-bottom: 4px;
|
||||||
border: 1.5px solid var(--color-border); font-size: var(--font-xs);
|
display: block;
|
||||||
cursor: pointer; user-select: none; -webkit-user-select: none;
|
|
||||||
transition: all 0.2s ease; background: #fafbfc;
|
|
||||||
color: var(--color-text-secondary); white-space: nowrap; min-height: 32px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
.chip-group .chip:active { transform: scale(0.95); }
|
.filter-bar .form-select,
|
||||||
.chip-group .chip input[type="checkbox"] {
|
.filter-bar .form-control {
|
||||||
width: 14px; height: 14px; accent-color: var(--color-primary); margin: 0; flex-shrink: 0;
|
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 {
|
.btn-search {
|
||||||
flex: 1; background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%);
|
background: #4f46e5;
|
||||||
color: white; border: none; padding: 11px 20px; border-radius: var(--radius-sm);
|
color: white;
|
||||||
font-weight: 700; font-size: var(--font-sm);
|
border: none;
|
||||||
transition: all 0.2s; cursor: pointer;
|
padding: 10px 32px;
|
||||||
display: flex; align-items: center; justify-content: center; gap: 5px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 14px rgba(99,102,241,0.25);
|
font-weight: 500;
|
||||||
position: relative; overflow: hidden;
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
.btn-search::after {
|
.btn-search:hover { background: #4338ca; }
|
||||||
content: ''; position: absolute; inset: 0;
|
.btn-reset {
|
||||||
background: linear-gradient(135deg, transparent 0%, rgba(255,255,255,0.12) 100%);
|
background: transparent;
|
||||||
pointer-events: none;
|
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-reset:hover { background: #f1f5f9; }
|
||||||
.btn-search:active { transform: translateY(0) scale(0.98); box-shadow: 0 2px 8px rgba(99,102,241,0.25); }
|
|
||||||
|
|
||||||
/* ==================== 汇总栏 ==================== */
|
/* 汇总 */
|
||||||
.summary-bar {
|
.summary-bar {
|
||||||
background: var(--color-surface); border-radius: var(--radius-lg);
|
background: white;
|
||||||
padding: 14px 18px; margin-bottom: 12px; display: none;
|
border-radius: 12px;
|
||||||
align-items: center; justify-content: space-between;
|
padding: 16px 24px;
|
||||||
box-shadow: var(--shadow-sm);
|
margin-bottom: 16px;
|
||||||
border: 1px solid rgba(226,232,240,0.5);
|
display: flex;
|
||||||
animation: fadeInUp 0.35s cubic-bezier(0.16, 1, 0.3, 1);
|
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-bar.visible { display: flex; }
|
||||||
.summary-amount {
|
.summary-amount {
|
||||||
font-size: var(--font-lg); font-weight: 800;
|
font-size: 22px;
|
||||||
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%);
|
font-weight: 700;
|
||||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
color: #4f46e5;
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
.summary-count {
|
|
||||||
font-size: var(--font-xs); color: var(--color-text-secondary); font-weight: 500;
|
|
||||||
background: #f1f5f9; padding: 4px 10px; border-radius: var(--radius-full);
|
|
||||||
}
|
}
|
||||||
|
.summary-count { font-size: 14px; color: #64748b; }
|
||||||
|
|
||||||
@keyframes fadeInUp {
|
/* 表格 */
|
||||||
from { opacity: 0; transform: translateY(8px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== 表格 ==================== */
|
|
||||||
.table-card {
|
.table-card {
|
||||||
background: var(--color-surface); border-radius: var(--radius-xl);
|
background: white;
|
||||||
overflow: hidden; box-shadow: var(--shadow-md);
|
border-radius: 16px;
|
||||||
border: 1px solid rgba(226,232,240,0.5);
|
overflow: hidden;
|
||||||
animation: fadeInUp 0.45s cubic-bezier(0.16, 1, 0.3, 1) 0.1s both;
|
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 {
|
.table-card table thead th {
|
||||||
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
background: #f8fafc;
|
||||||
font-size: 11px; font-weight: 700; color: var(--color-text-secondary);
|
font-size: 12px;
|
||||||
text-transform: uppercase; letter-spacing: 0.6px; padding: 12px 14px;
|
font-weight: 700;
|
||||||
border-bottom: 2px solid var(--color-border); white-space: nowrap;
|
color: #64748b;
|
||||||
position: sticky; top: 0;
|
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 {
|
.table-card table tbody td {
|
||||||
padding: 12px 14px; border-bottom: 1px solid #f1f5f9;
|
padding: 12px 16px;
|
||||||
font-size: var(--font-sm); color: var(--color-text); vertical-align: middle;
|
border-bottom: 1px solid #f1f5f9;
|
||||||
transition: background 0.15s;
|
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:hover { background: #f8fafc; }
|
||||||
.table-card table tbody tr:active { background: #f1f5f9; }
|
|
||||||
|
|
||||||
.category-badge {
|
.category-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%);
|
background: #eef2ff;
|
||||||
color: var(--color-primary);
|
color: #4f46e5;
|
||||||
padding: 3px 10px; border-radius: var(--radius-full);
|
padding: 3px 10px;
|
||||||
font-size: 11px; font-weight: 600; white-space: nowrap;
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.amount-cell {
|
.amount-cell {
|
||||||
font-weight: 700; color: #ef4444; text-align: right;
|
font-weight: 600;
|
||||||
font-variant-numeric: tabular-nums; font-size: var(--font-sm);
|
color: #ef4444;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-msg {
|
.empty-msg {
|
||||||
text-align: center; padding: 48px 20px; color: var(--color-text-muted);
|
text-align: center;
|
||||||
font-size: var(--font-sm); display: flex; flex-direction: column;
|
padding: 60px 20px;
|
||||||
align-items: center; gap: 8px;
|
color: #94a3b8;
|
||||||
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
.empty-msg i { font-size: 32px; opacity: 0.4; }
|
|
||||||
|
|
||||||
|
/* 加载 */
|
||||||
.loading-bar {
|
.loading-bar {
|
||||||
text-align: center; padding: 40px 20px; display: none;
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
.loading-bar.show { display: block; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav class="navbar">
|
<nav class="navbar">
|
||||||
<span class="brand"><span class="brand-dot"></span> Trace</span>
|
<span class="brand">📝 TraceCD</span>
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<a href="/"><i class="bi bi-mic"></i> 语音</a>
|
<a href="/">🎤 语音录入</a>
|
||||||
<a href="/logout">退出</a>
|
<a href="/logout">退出</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@ -278,45 +170,50 @@
|
|||||||
<div class="page-container">
|
<div class="page-container">
|
||||||
<!-- 筛选栏 -->
|
<!-- 筛选栏 -->
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<!-- 人物 -->
|
<div class="row">
|
||||||
<div class="filter-section">
|
<div class="col-md-3 mb-3">
|
||||||
<div class="filter-section-title">人物</div>
|
<label>人物</label>
|
||||||
<div class="chip-group" id="chipPerson"></div>
|
<select class="form-select" id="filterPerson" multiple size="3">
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<!-- 分类 -->
|
<div class="col-md-3 mb-3">
|
||||||
<div class="filter-section">
|
<label>开始时间</label>
|
||||||
<div class="filter-section-title">分类</div>
|
<input type="date" class="form-control" id="filterStartTime">
|
||||||
<div class="chip-group" id="chipCategory"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- 地点 -->
|
<div class="col-md-3 mb-3">
|
||||||
<div class="filter-section">
|
<label>结束时间</label>
|
||||||
<div class="filter-section-title">地点</div>
|
<input type="date" class="form-control" id="filterEndTime">
|
||||||
<div class="chip-group" id="chipLocation"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- 日期 -->
|
<div class="col-md-3 mb-3">
|
||||||
<div class="filter-section">
|
<label>地点</label>
|
||||||
<div class="filter-section-title">时间范围</div>
|
<select class="form-select" id="filterLocation" multiple size="3">
|
||||||
<div class="date-row">
|
</select>
|
||||||
<input type="date" id="filterStartTime" placeholder="开始">
|
|
||||||
<input type="date" id="filterEndTime" placeholder="结束">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 按钮 -->
|
<div class="row">
|
||||||
<div class="btn-group-row">
|
<div class="col-md-3 mb-3">
|
||||||
<button class="btn-reset" onclick="resetFilters()">
|
<label>分类</label>
|
||||||
<i class="bi bi-arrow-clockwise"></i> 重置
|
<select class="form-select" id="filterCategory" multiple size="3">
|
||||||
</button>
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-9 mb-3 d-flex align-items-end">
|
||||||
<button class="btn-search" onclick="doQuery()">
|
<button class="btn-search" onclick="doQuery()">
|
||||||
<i class="bi bi-search"></i> 查询
|
<i class="bi bi-search"></i> 查询
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn-reset" onclick="resetFilters()">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i> 重置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 汇总 -->
|
<!-- 汇总 -->
|
||||||
<div class="summary-bar" id="summaryBar">
|
<div class="summary-bar" id="summaryBar">
|
||||||
<span class="summary-count" id="summaryCount"></span>
|
|
||||||
<div>
|
<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>
|
<span class="summary-amount" id="summaryAmount"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -324,61 +221,55 @@
|
|||||||
<!-- 表格 -->
|
<!-- 表格 -->
|
||||||
<div class="table-card">
|
<div class="table-card">
|
||||||
<div class="loading-bar" id="loadingBar">
|
<div class="loading-bar" id="loadingBar">
|
||||||
<div class="spinner-border" role="status"></div>
|
<div class="spinner-border text-secondary" role="status"></div>
|
||||||
<p>查询中...</p>
|
<p class="mt-2 text-muted">查询中...</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-scroll">
|
<div class="table-responsive">
|
||||||
<table id="resultTable" style="display:none;">
|
<table class="table" id="resultTable" style="display:none;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="resultTbody"></tbody>
|
<tbody id="resultTbody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="empty-msg" id="emptyMsg">
|
<div class="empty-msg" id="emptyMsg">点击"查询"按钮查看事项数据</div>
|
||||||
<i class="bi bi-inbox"></i>
|
|
||||||
<span>点击「查询」查看事项数据</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
// ==================== 初始化 Chip 选择器 ====================
|
// ==================== 初始化 ====================
|
||||||
document.addEventListener('DOMContentLoaded', loadFilterOptions);
|
document.addEventListener('DOMContentLoaded', loadFilterOptions);
|
||||||
|
|
||||||
const chipMap = {
|
|
||||||
person: 'chipPerson',
|
|
||||||
location: 'chipLocation',
|
|
||||||
category: 'chipCategory'
|
|
||||||
};
|
|
||||||
|
|
||||||
async function loadFilterOptions() {
|
async function loadFilterOptions() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/filter/options');
|
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();
|
const options = await response.json();
|
||||||
|
|
||||||
options.forEach(opt => {
|
options.forEach(opt => {
|
||||||
const containerId = chipMap[opt.fieldName];
|
let selectEl;
|
||||||
if (!containerId) return;
|
switch (opt.fieldName) {
|
||||||
const container = document.getElementById(containerId);
|
case 'person': selectEl = document.getElementById('filterPerson'); break;
|
||||||
const fragment = document.createDocumentFragment();
|
case 'location': selectEl = document.getElementById('filterLocation'); break;
|
||||||
opt.values.forEach(v => {
|
case 'category': selectEl = document.getElementById('filterCategory'); break;
|
||||||
const label = document.createElement('label');
|
default: return;
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
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) {
|
} catch (err) {
|
||||||
console.error('加载筛选选项失败:', err);
|
console.error('加载筛选选项失败:', err);
|
||||||
@ -388,13 +279,21 @@
|
|||||||
// ==================== 查询 ====================
|
// ==================== 查询 ====================
|
||||||
async function doQuery() {
|
async function doQuery() {
|
||||||
const query = {
|
const query = {
|
||||||
persons: getCheckedValues('chipPerson'),
|
persons: getSelectedValues('filterPerson'),
|
||||||
startTime: buildDateTime('filterStartTime', false),
|
startTime: document.getElementById('filterStartTime').value || null,
|
||||||
endTime: buildDateTime('filterEndTime', true),
|
endTime: document.getElementById('filterEndTime').value || null,
|
||||||
locations: getCheckedValues('chipLocation'),
|
locations: getSelectedValues('filterLocation'),
|
||||||
categories: getCheckedValues('chipCategory')
|
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 loadingBar = document.getElementById('loadingBar');
|
||||||
const resultTable = document.getElementById('resultTable');
|
const resultTable = document.getElementById('resultTable');
|
||||||
const emptyMsg = document.getElementById('emptyMsg');
|
const emptyMsg = document.getElementById('emptyMsg');
|
||||||
@ -411,24 +310,23 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(query)
|
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();
|
const data = await response.json();
|
||||||
renderResults(data);
|
renderResults(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('查询失败:', err);
|
console.error('查询失败:', err);
|
||||||
emptyMsg.innerHTML = '<i class="bi bi-exclamation-triangle"></i><span>查询失败,请重试</span>';
|
emptyMsg.textContent = '查询失败,请重试';
|
||||||
emptyMsg.style.display = 'flex';
|
emptyMsg.style.display = 'block';
|
||||||
} finally {
|
} finally {
|
||||||
loadingBar.classList.remove('show');
|
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) {
|
function renderResults(data) {
|
||||||
const tbody = document.getElementById('resultTbody');
|
const tbody = document.getElementById('resultTbody');
|
||||||
const resultTable = document.getElementById('resultTable');
|
const resultTable = document.getElementById('resultTable');
|
||||||
@ -437,68 +335,63 @@
|
|||||||
|
|
||||||
if (!data.records || data.records.length === 0) {
|
if (!data.records || data.records.length === 0) {
|
||||||
resultTable.style.display = 'none';
|
resultTable.style.display = 'none';
|
||||||
emptyMsg.innerHTML = '<i class="bi bi-inbox"></i><span>未找到匹配的事项记录</span>';
|
emptyMsg.textContent = '未找到匹配的事项记录';
|
||||||
emptyMsg.style.display = 'flex';
|
emptyMsg.style.display = 'block';
|
||||||
summaryBar.classList.remove('visible');
|
summaryBar.classList.remove('visible');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fragment = document.createDocumentFragment();
|
// 渲染表格
|
||||||
data.records.forEach(r => {
|
tbody.innerHTML = data.records.map(r => `
|
||||||
const tr = document.createElement('tr');
|
<tr>
|
||||||
tr.innerHTML =
|
<td>${esc(r.person)}</td>
|
||||||
'<td>' + escHtml(r.person) + '</td>' +
|
<td>${formatTime(r.recordTime)}</td>
|
||||||
'<td>' + formatTime(r.recordTime) + '</td>' +
|
<td>${esc(r.location)}</td>
|
||||||
'<td>' + escHtml(r.location) + '</td>' +
|
<td>${esc(r.content)}</td>
|
||||||
'<td>' + escHtml(r.content) + '</td>' +
|
<td><span class="category-badge">${esc(r.category)}</span></td>
|
||||||
'<td><span class="category-badge">' + escHtml(r.category) + '</span></td>' +
|
<td class="amount-cell">¥${formatAmount(r.amount)}</td>
|
||||||
'<td class="amount-cell">¥' + formatAmount(r.amount) + '</td>';
|
</tr>
|
||||||
fragment.appendChild(tr);
|
`).join('');
|
||||||
});
|
|
||||||
tbody.innerHTML = '';
|
|
||||||
tbody.appendChild(fragment);
|
|
||||||
|
|
||||||
resultTable.style.display = 'table';
|
resultTable.style.display = 'table';
|
||||||
emptyMsg.style.display = 'none';
|
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');
|
summaryBar.classList.add('visible');
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
['chipPerson', 'chipLocation', 'chipCategory'].forEach(id => {
|
document.getElementById('filterPerson').selectedIndex = -1;
|
||||||
const container = document.getElementById(id);
|
|
||||||
if (!container) return;
|
|
||||||
container.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
|
||||||
cb.checked = false;
|
|
||||||
cb.parentElement.classList.remove('selected');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
document.getElementById('filterStartTime').value = '';
|
document.getElementById('filterStartTime').value = '';
|
||||||
document.getElementById('filterEndTime').value = '';
|
document.getElementById('filterEndTime').value = '';
|
||||||
|
document.getElementById('filterLocation').selectedIndex = -1;
|
||||||
|
document.getElementById('filterCategory').selectedIndex = -1;
|
||||||
|
|
||||||
document.getElementById('resultTable').style.display = 'none';
|
document.getElementById('resultTable').style.display = 'none';
|
||||||
const emptyMsg = document.getElementById('emptyMsg');
|
document.getElementById('emptyMsg').style.display = 'block';
|
||||||
emptyMsg.innerHTML = '<i class="bi bi-inbox"></i><span>点击「查询」查看事项数据</span>';
|
document.getElementById('emptyMsg').textContent = '点击"查询"按钮查看事项数据';
|
||||||
emptyMsg.style.display = 'flex';
|
|
||||||
document.getElementById('summaryBar').classList.remove('visible');
|
document.getElementById('summaryBar').classList.remove('visible');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 工具函数 ====================
|
// ==================== 工具函数 ====================
|
||||||
function getCheckedValues(containerId) {
|
function getSelectedValues(selectId) {
|
||||||
const container = document.getElementById(containerId);
|
const select = document.getElementById(selectId);
|
||||||
if (!container) return null;
|
|
||||||
const values = [];
|
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;
|
return values.length > 0 ? values : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(timeStr) {
|
function formatTime(timeStr) {
|
||||||
if (!timeStr) return '';
|
if (!timeStr) return '';
|
||||||
|
// 格式化 ISO 时间为可读格式
|
||||||
const d = new Date(timeStr);
|
const d = new Date(timeStr);
|
||||||
if (isNaN(d.getTime())) return timeStr;
|
if (isNaN(d.getTime())) return timeStr;
|
||||||
const pad = n => String(n).padStart(2, '0');
|
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) {
|
function formatAmount(amount) {
|
||||||
@ -506,16 +399,12 @@
|
|||||||
return Number(amount).toFixed(2);
|
return Number(amount).toFixed(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function escHtml(str) {
|
function esc(str) {
|
||||||
if (!str) return '';
|
if (!str) return '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = str;
|
div.textContent = str;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escAttr(str) {
|
|
||||||
return str.replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -2,626 +2,578 @@
|
|||||||
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
|
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<title>TraceCD - 日常记录</title>
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
|
||||||
<title>Trace - 日常记录</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@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">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--safe-top: env(safe-area-inset-top, 0px);
|
--mic-size: 80px;
|
||||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
--mic-color: #4f46e5;
|
||||||
--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-recording: #ef4444;
|
--mic-recording: #ef4444;
|
||||||
--nav-height: 56px;
|
--mic-shadow: 0 4px 20px rgba(79, 70, 229, 0.4);
|
||||||
--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; }
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
background: var(--color-bg); min-height: 100vh; min-height: 100dvh;
|
background: #f8fafc;
|
||||||
margin: 0; padding: 0; -webkit-font-smoothing: antialiased; overflow-x: hidden;
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding-bottom: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==================== 顶部导航 ==================== */
|
/* 顶部导航 */
|
||||||
.navbar {
|
.navbar {
|
||||||
background: rgba(255,255,255,0.78);
|
background: white;
|
||||||
border-bottom: 1px solid rgba(226,232,240,0.6);
|
border-bottom: 1px solid #e2e8f0;
|
||||||
height: var(--nav-height); padding: 0 var(--safe-left) 0 var(--safe-right);
|
padding: 12px 20px;
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
display: flex;
|
||||||
position: sticky; top: 0; z-index: 100;
|
justify-content: space-between;
|
||||||
backdrop-filter: blur(24px) saturate(1.4);
|
align-items: center;
|
||||||
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
|
||||||
}
|
}
|
||||||
.navbar .brand {
|
.navbar .brand {
|
||||||
font-size: 18px; font-weight: 800; color: var(--color-text);
|
font-size: 18px;
|
||||||
letter-spacing: -0.4px; padding-left: 20px;
|
font-weight: 600;
|
||||||
display: flex; align-items: center; gap: 6px;
|
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 {
|
.navbar .nav-links a {
|
||||||
color: var(--color-text-secondary); text-decoration: none; font-size: var(--font-sm);
|
color: #64748b;
|
||||||
padding: 8px 14px; border-radius: var(--radius-sm); transition: all 0.2s;
|
text-decoration: none;
|
||||||
font-weight: 500; white-space: nowrap;
|
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 {
|
.notification {
|
||||||
position: fixed; top: calc(var(--nav-height) + 12px + var(--safe-top)); left: 50%;
|
position: fixed;
|
||||||
transform: translateX(-50%) translateY(-8px); z-index: 9999;
|
top: 20px;
|
||||||
background: var(--color-surface); color: #059669;
|
left: 50%;
|
||||||
padding: 10px 20px; border-radius: var(--radius-full);
|
transform: translateX(-50%);
|
||||||
font-size: var(--font-sm); font-weight: 600;
|
z-index: 9999;
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,0.08), 0 2px 8px rgba(0,0,0,0.04);
|
background: #10b981;
|
||||||
border: 1px solid rgba(16,185,129,0.2);
|
color: white;
|
||||||
opacity: 0; pointer-events: none; transition: opacity 0.3s ease, transform 0.3s ease;
|
padding: 12px 28px;
|
||||||
max-width: calc(100vw - 40px); text-align: center;
|
border-radius: 999px;
|
||||||
display: flex; align-items: center; gap: 8px;
|
font-size: 15px;
|
||||||
}
|
font-weight: 500;
|
||||||
.notification.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.3);
|
||||||
.notification.error {
|
opacity: 0;
|
||||||
color: #dc2626; border-color: rgba(239,68,68,0.2);
|
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 {
|
.content-area {
|
||||||
max-width: 640px; margin: 0 auto;
|
max-width: 800px;
|
||||||
padding: 16px 12px calc(var(--mic-size) + 24px + var(--safe-bottom) + 48px);
|
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 {
|
.result-card {
|
||||||
background: transparent; border-radius: 0;
|
background: white;
|
||||||
padding: 0; box-shadow: none;
|
border-radius: 16px;
|
||||||
display: none; animation: fadeInUp 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
padding: 30px;
|
||||||
|
margin-top: 20px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
.result-card.visible { display: block; }
|
.result-card.visible { display: block; }
|
||||||
|
.result-card table { width: 100%; }
|
||||||
/* 卡片列表 */
|
.result-card table th, .result-card table td {
|
||||||
.records-list { display: flex; flex-direction: column; gap: 8px; }
|
padding: 10px 14px;
|
||||||
.record-card {
|
border-bottom: 1px solid #f1f5f9;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
.record-card:nth-child(1) { animation-delay: 0s; }
|
.result-card table th {
|
||||||
.record-card:nth-child(2) { animation-delay: 0.05s; }
|
background: #f8fafc;
|
||||||
.record-card:nth-child(3) { animation-delay: 0.1s; }
|
font-weight: 600;
|
||||||
.record-card:nth-child(4) { animation-delay: 0.15s; }
|
font-size: 13px;
|
||||||
.record-card:nth-child(5) { animation-delay: 0.2s; }
|
color: #64748b;
|
||||||
@keyframes cardSlideIn {
|
text-transform: uppercase;
|
||||||
from { opacity: 0; transform: translateY(10px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
}
|
||||||
.record-card:active { transform: scale(0.99); opacity: 0.9; }
|
.amount-summary {
|
||||||
|
font-size: 18px;
|
||||||
.card-top {
|
font-weight: 700;
|
||||||
display: flex; justify-content: space-between; align-items: baseline;
|
color: #4f46e5;
|
||||||
}
|
margin-bottom: 16px;
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 底部总结 */
|
/* 聊天回复 */
|
||||||
.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 {
|
.chat-reply {
|
||||||
background: var(--color-surface); border-radius: var(--radius-lg);
|
background: white;
|
||||||
padding: 16px 18px; box-shadow: var(--shadow-md);
|
border-radius: 16px;
|
||||||
font-size: var(--font-sm); line-height: 1.7; color: var(--color-text);
|
padding: 24px 30px;
|
||||||
display: none; animation: fadeInUp 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
margin-top: 20px;
|
||||||
border: 1px solid rgba(226,232,240,0.5);
|
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.visible { display: block; }
|
||||||
.chat-reply p { margin: 0; }
|
|
||||||
|
|
||||||
@keyframes fadeInUp {
|
/* 加载动画 */
|
||||||
from { opacity: 0; transform: translateY(12px); }
|
.loading-overlay {
|
||||||
to { opacity: 1; transform: translateY(0); }
|
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 {
|
.mic-container {
|
||||||
position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000;
|
position: fixed;
|
||||||
background: linear-gradient(180deg, transparent 0%, var(--color-bg) 40%, var(--color-bg) 100%);
|
bottom: 40px;
|
||||||
padding: 20px 0 calc(28px + var(--safe-bottom));
|
right: 40px;
|
||||||
display: flex; flex-direction: column; align-items: center; pointer-events: none;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
.mic-container { position: relative; pointer-events: auto; }
|
|
||||||
.mic-btn {
|
.mic-btn {
|
||||||
width: var(--mic-size); height: var(--mic-size); border-radius: 50%;
|
width: var(--mic-size);
|
||||||
background: linear-gradient(135deg, var(--mic-color) 0%, var(--mic-color-light) 100%);
|
height: var(--mic-size);
|
||||||
border: none; color: white;
|
border-radius: 50%;
|
||||||
font-size: calc(var(--mic-size) * 0.38); cursor: pointer;
|
background: var(--mic-color);
|
||||||
box-shadow:
|
border: none;
|
||||||
0 6px 24px rgba(99,102,241,0.35),
|
color: white;
|
||||||
0 2px 8px rgba(99,102,241,0.2),
|
font-size: 32px;
|
||||||
inset 0 1px 0 rgba(255,255,255,0.2);
|
cursor: pointer;
|
||||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
box-shadow: var(--mic-shadow);
|
||||||
display: flex; align-items: center; justify-content: center;
|
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.2s ease;
|
||||||
user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
|
display: flex;
|
||||||
position: relative;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
.mic-btn::before {
|
.mic-btn:hover {
|
||||||
content: ''; position: absolute; inset: -6px; border-radius: 50%;
|
transform: scale(1.06);
|
||||||
background: rgba(99,102,241,0.08); z-index: -1;
|
box-shadow: 0 6px 28px rgba(79, 70, 229, 0.5);
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
}
|
||||||
.mic-btn:active {
|
.mic-btn:active {
|
||||||
transform: scale(0.92);
|
transform: scale(0.96);
|
||||||
box-shadow: 0 3px 12px rgba(99,102,241,0.3), inset 0 1px 0 rgba(255,255,255,0.2);
|
|
||||||
}
|
}
|
||||||
.mic-btn.recording {
|
.mic-btn.recording {
|
||||||
background: linear-gradient(135deg, var(--mic-recording) 0%, #f87171 100%);
|
background: var(--mic-recording);
|
||||||
box-shadow:
|
box-shadow: 0 4px 24px rgba(239, 68, 68, 0.5);
|
||||||
0 6px 28px rgba(239,68,68,0.45),
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
0 2px 8px rgba(239,68,68,0.25);
|
|
||||||
animation: micPulse 1.6s ease-in-out infinite;
|
|
||||||
}
|
}
|
||||||
.mic-btn.recording::before { background: rgba(239,68,68,0.1); }
|
@keyframes pulse {
|
||||||
@keyframes micPulse {
|
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5); }
|
||||||
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 0 0 20px rgba(239, 68, 68, 0); }
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
.mic-hint {
|
.mic-hint {
|
||||||
font-size: var(--font-xs); color: var(--color-text-muted); font-weight: 500;
|
text-align: center;
|
||||||
}
|
margin-top: 12px;
|
||||||
.mic-hint.recording-hint {
|
font-size: 12px;
|
||||||
color: var(--mic-recording); font-weight: 600;
|
color: #94a3b8;
|
||||||
animation: hintBlink 1.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
@keyframes hintBlink {
|
|
||||||
0%, 100% { opacity: 1; } 50% { opacity: 0.4; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 处理中指示器 */
|
/* 空状态 */
|
||||||
.processing-indicator { display: none; align-items: center; gap: 6px; }
|
.empty-state {
|
||||||
.processing-indicator.show { display: flex; }
|
text-align: center;
|
||||||
.processing-dot {
|
padding: 80px 20px;
|
||||||
width: 5px; height: 5px; border-radius: 50%; background: var(--mic-color);
|
color: #94a3b8;
|
||||||
animation: dotBounce 1.2s ease-in-out infinite;
|
|
||||||
}
|
}
|
||||||
.processing-dot:nth-child(2) { animation-delay: 0.15s; }
|
.empty-state i { font-size: 48px; display: block; margin-bottom: 16px; }
|
||||||
.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; }
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
/* 响应式 */
|
||||||
.content-area { padding: 28px 20px 120px; }
|
@media (max-width: 640px) {
|
||||||
.navbar .brand { padding-left: 24px; }
|
.mic-container {
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
}
|
||||||
|
:root { --mic-size: 68px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<!-- 顶部导航 -->
|
||||||
<nav class="navbar">
|
<nav class="navbar">
|
||||||
<span class="brand"><span class="brand-dot"></span> Trace</span>
|
<span class="brand">📝 TraceCD</span>
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<a href="/browse"><i class="bi bi-grid-3x3-gap"></i> 浏览</a>
|
<a href="/browse">📋 浏览事项</a>
|
||||||
<a href="/logout">退出</a>
|
<a href="/logout">退出</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- 通知 -->
|
||||||
<div class="notification" id="notification"></div>
|
<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="content-area" id="contentArea">
|
||||||
<div class="empty-state" id="emptyState">
|
<div class="empty-state" id="emptyState">
|
||||||
<div class="empty-icon-wrap">🎤</div>
|
<i class="bi bi-mic-fill"></i>
|
||||||
<div class="empty-title">语音记账助手</div>
|
<p>按住右下角麦克风按钮开始语音录入或查询</p>
|
||||||
<div class="empty-desc">按住下方按钮说话<br>录入事项或查询记录</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 查询结果卡片 -->
|
||||||
<div class="result-card" id="resultCard"></div>
|
<div class="result-card" id="resultCard"></div>
|
||||||
|
|
||||||
|
<!-- 聊天回复 -->
|
||||||
<div class="chat-reply" id="chatReply"></div>
|
<div class="chat-reply" id="chatReply"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mic-bottom-bar">
|
<!-- 麦克风按钮 -->
|
||||||
<div class="mic-container">
|
<div class="mic-container">
|
||||||
<button class="mic-btn" id="micBtn" title="按住录音">
|
<button class="mic-btn" id="micBtn" title="按住录音">
|
||||||
<i class="bi bi-mic-fill"></i>
|
<i class="bi bi-mic-fill"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<div class="mic-hint">按住说话,松开发送</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>
|
</div>
|
||||||
|
|
||||||
<script th:inline="javascript">
|
<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 录音器 ====================
|
// ==================== WAV 录音器 ====================
|
||||||
class WavRecorder {
|
class WavRecorder {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.audioContext = null; this.stream = null; this.processor = null;
|
this.audioContext = null;
|
||||||
this.chunks = []; this.sampleRate = 16000; this.recording = false;
|
this.stream = null;
|
||||||
|
this.processor = null;
|
||||||
|
this.chunks = [];
|
||||||
|
this.sampleRate = 16000;
|
||||||
|
this.recording = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
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);
|
const source = this.audioContext.createMediaStreamSource(this.stream);
|
||||||
|
|
||||||
|
// 使用 ScriptProcessorNode 采集 PCM
|
||||||
this.processor = this.audioContext.createScriptProcessor(4096, 1, 1);
|
this.processor = this.audioContext.createScriptProcessor(4096, 1, 1);
|
||||||
this.chunks = [];
|
this.chunks = [];
|
||||||
this.processor.onaudioprocess = (e) => {
|
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);
|
source.connect(this.processor);
|
||||||
this.processor.connect(this.audioContext.destination);
|
this.processor.connect(this.audioContext.destination);
|
||||||
this.recording = true;
|
this.recording = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
this.recording = false;
|
this.recording = false;
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
// 等一小段时间让最后的数据进来
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.processor) { this.processor.disconnect(); this.processor = null; }
|
// 断开连接
|
||||||
if (this.audioContext) { this.audioContext.close(); this.audioContext = null; }
|
if (this.processor) {
|
||||||
if (this.stream) { this.stream.getTracks().forEach(t => t.stop()); this.stream = null; }
|
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 totalLength = this.chunks.reduce((sum, c) => sum + c.length, 0);
|
||||||
const pcm = new Float32Array(totalLength);
|
const pcm = new Float32Array(totalLength);
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
for (const chunk of this.chunks) { pcm.set(chunk, offset); offset += chunk.length; }
|
for (const chunk of this.chunks) {
|
||||||
resolve(this.encodeWAV(pcm, this.sampleRate));
|
pcm.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编码为 WAV
|
||||||
|
const wavBlob = this.encodeWAV(pcm, this.sampleRate);
|
||||||
|
resolve(wavBlob);
|
||||||
}, 100);
|
}, 100);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
encodeWAV(samples, sampleRate) {
|
encodeWAV(samples, sampleRate) {
|
||||||
const buffer = new ArrayBuffer(44 + samples.length * 2);
|
const buffer = new ArrayBuffer(44 + samples.length * 2);
|
||||||
const view = new DataView(buffer);
|
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 ');
|
// RIFF header
|
||||||
view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true);
|
this.writeString(view, 0, 'RIFF');
|
||||||
view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * 2, true);
|
view.setUint32(4, 36 + samples.length * 2, true);
|
||||||
view.setUint16(32, 2, true); view.setUint16(34, 16, true);
|
this.writeString(view, 8, 'WAVE');
|
||||||
writeString(view, 36, 'data'); view.setUint32(40, samples.length * 2, true);
|
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++) {
|
for (let i = 0; i < samples.length; i++) {
|
||||||
const s = Math.max(-1, Math.min(1, samples[i]));
|
const s = Math.max(-1, Math.min(1, samples[i]));
|
||||||
view.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
view.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Blob([buffer], { type: 'audio/wav' });
|
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 micBtn = document.getElementById('micBtn');
|
||||||
const micHint = document.getElementById('micHint');
|
const loading = document.getElementById('loading');
|
||||||
const processing = document.getElementById('processing');
|
|
||||||
const resultCard = document.getElementById('resultCard');
|
const resultCard = document.getElementById('resultCard');
|
||||||
const chatReply = document.getElementById('chatReply');
|
const chatReply = document.getElementById('chatReply');
|
||||||
const emptyState = document.getElementById('emptyState');
|
const emptyState = document.getElementById('emptyState');
|
||||||
const notification = document.getElementById('notification');
|
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;
|
micBtn.addEventListener('mousedown', async (e) => {
|
||||||
if (e.changedTouches && e.changedTouches.length > 0) return 't' + e.changedTouches[0].identifier;
|
|
||||||
return 'mouse';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startRecording(e) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (isPressed) return;
|
if (isPressed) return;
|
||||||
isPressed = true; activePointerId = getPointerId(e);
|
isPressed = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
recorder = new WavRecorder();
|
recorder = new WavRecorder();
|
||||||
await recorder.start();
|
await recorder.start();
|
||||||
micBtn.classList.add('recording');
|
micBtn.classList.add('recording');
|
||||||
micHint.textContent = '正在录音...';
|
micBtn.querySelector('i').className = 'bi bi-mic-fill'; // keep icon
|
||||||
micHint.classList.add('recording-hint');
|
|
||||||
hapticFeedback('light');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('无法启动录音:', err);
|
console.error('无法启动录音:', err);
|
||||||
showNotification('无法访问麦克风,请检查权限', true);
|
showNotification('无法访问麦克风,请检查权限', true);
|
||||||
resetRecordingState();
|
isPressed = false;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function stopRecording(e) {
|
// 松开停止录音并发送
|
||||||
|
micBtn.addEventListener('mouseup', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!isPressed || !recorder) return;
|
if (!isPressed || !recorder) return;
|
||||||
const currentId = getPointerId(e);
|
isPressed = false;
|
||||||
if (activePointerId && currentId !== activePointerId && currentId !== 'mouse') return;
|
|
||||||
isPressed = false; activePointerId = null;
|
|
||||||
micBtn.classList.remove('recording');
|
micBtn.classList.remove('recording');
|
||||||
micHint.classList.remove('recording-hint');
|
micBtn.querySelector('i').className = 'bi bi-mic-fill';
|
||||||
micHint.textContent = '按住说话,松开发送';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
loading.classList.add('show');
|
||||||
const wavBlob = await recorder.stop();
|
const wavBlob = await recorder.stop();
|
||||||
recorder = null;
|
recorder = null;
|
||||||
if (wavBlob.size < 200) {
|
|
||||||
|
if (wavBlob.size < 100) {
|
||||||
|
// 录音太短
|
||||||
|
loading.classList.remove('show');
|
||||||
showNotification('录音时间太短,请重试', true);
|
showNotification('录音时间太短,请重试', true);
|
||||||
hapticFeedback('warning');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
processing.classList.add('show');
|
|
||||||
micHint.style.display = 'none';
|
|
||||||
await sendAudio(wavBlob);
|
await sendAudio(wavBlob);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('发送失败:', err);
|
console.error('发送失败:', err);
|
||||||
showNotification('处理失败,请重试', true);
|
showNotification('处理失败,请重试', true);
|
||||||
} finally {
|
} finally {
|
||||||
processing.classList.remove('show');
|
loading.classList.remove('show');
|
||||||
micHint.style.display = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 触摸事件(移动端)
|
||||||
|
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');
|
micBtn.classList.remove('recording');
|
||||||
micHint.classList.remove('recording-hint');
|
|
||||||
micHint.textContent = '按住说话,松开发送';
|
try {
|
||||||
processing.classList.remove('show');
|
loading.classList.add('show');
|
||||||
micHint.style.display = '';
|
const wavBlob = await recorder.stop();
|
||||||
if (recorder) { recorder.stop().catch(() => {}); recorder = null; }
|
recorder = null;
|
||||||
|
|
||||||
|
if (wavBlob.size < 100) {
|
||||||
|
loading.classList.remove('show');
|
||||||
|
showNotification('录音时间太短,请重试', true);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
micBtn.addEventListener('mousedown', startRecording);
|
await sendAudio(wavBlob);
|
||||||
micBtn.addEventListener('mouseup', stopRecording);
|
} catch (err) {
|
||||||
micBtn.addEventListener('mouseleave', (e) => { if (isPressed) stopRecording(e); });
|
console.error('发送失败:', err);
|
||||||
micBtn.addEventListener('touchstart', startRecording, { passive: false });
|
showNotification('处理失败,请重试', true);
|
||||||
micBtn.addEventListener('touchend', stopRecording);
|
} finally {
|
||||||
micBtn.addEventListener('touchcancel', () => resetRecordingState());
|
loading.classList.remove('show');
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== 发送音频 ====================
|
||||||
async function sendAudio(audioBlob) {
|
async function sendAudio(audioBlob) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('audio', audioBlob, 'recording.wav');
|
formData.append('audio', audioBlob, 'recording.wav');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/voice/process', { method: 'POST', body: formData });
|
const response = await fetch('/api/voice/process', {
|
||||||
if (response.status === 401) { window.location.href = '/login'; return; }
|
method: 'POST',
|
||||||
displayResult(await response.json());
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
displayResult(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('请求失败:', error);
|
console.error('请求失败:', error);
|
||||||
showNotification('网络错误,请重试', true);
|
showNotification('网络错误,请重试', true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 结果展示 ====================
|
// ==================== 展示结果 ====================
|
||||||
function displayResult(result) {
|
function displayResult(result) {
|
||||||
hideAll();
|
hideAll();
|
||||||
if (!result.success) { showNotification(result.error || '处理失败', true); return; }
|
|
||||||
|
if (!result.success) {
|
||||||
|
showNotification(result.error || '处理失败', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (result.type) {
|
switch (result.type) {
|
||||||
case 'RECORD':
|
case 'RECORD':
|
||||||
showNotification('已录入「' + (result.category || '未知') + '」', false);
|
// 录入成功:显示通知
|
||||||
hapticFeedback('success'); break;
|
showNotification('已录入「' + (result.category || '未知') + '」事项', false);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'QUERY':
|
case 'QUERY':
|
||||||
|
// 查询结果:渲染 markdown 表格
|
||||||
emptyState.style.display = 'none';
|
emptyState.style.display = 'none';
|
||||||
resultCard.classList.add('visible');
|
resultCard.classList.add('visible');
|
||||||
resultCard.innerHTML = buildQueryHtml(result);
|
resultCard.innerHTML = renderMarkdown(result.message);
|
||||||
resultCard.scrollIntoView({ behavior: 'smooth', block: 'start' }); break;
|
// 滚动到结果区
|
||||||
|
resultCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
break;
|
||||||
|
|
||||||
case 'CHAT':
|
case 'CHAT':
|
||||||
|
// 聊天回复
|
||||||
emptyState.style.display = 'none';
|
emptyState.style.display = 'none';
|
||||||
chatReply.classList.add('visible');
|
chatReply.classList.add('visible');
|
||||||
chatReply.innerHTML = '<p>' + escapeHtml(result.message) + '</p>';
|
chatReply.innerHTML = '<p>' + escapeHtml(result.message) + '</p>';
|
||||||
chatReply.scrollIntoView({ behavior: 'smooth', block: 'start' }); break;
|
chatReply.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideAll() {
|
function hideAll() {
|
||||||
resultCard.classList.remove('visible'); resultCard.innerHTML = '';
|
resultCard.classList.remove('visible');
|
||||||
chatReply.classList.remove('visible'); chatReply.innerHTML = '';
|
chatReply.classList.remove('visible');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 分类样式映射 ====================
|
// ==================== 简易 Markdown 渲染 ====================
|
||||||
const CAT_STYLES = {
|
function renderMarkdown(md) {
|
||||||
'吃饭': { e:'🍜', c:'#f97316', b:'#fff7ed' },
|
if (!md) return '';
|
||||||
'购物': { 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' };
|
|
||||||
|
|
||||||
function catStyle(category) {
|
let html = md;
|
||||||
if (category && CAT_STYLES[category]) return CAT_STYLES[category];
|
|
||||||
return CAT_DEFAULT;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 构建卡片列表 ====================
|
// 提取金额汇总行
|
||||||
function buildQueryHtml(result) {
|
html = html.replace(/^(.*?金额.*?[::]?\s*[\d,.]+.*)$/gm,
|
||||||
let html = '';
|
'<div class="amount-summary">$1</div>');
|
||||||
const records = result.records;
|
|
||||||
|
|
||||||
if (records && records.length > 0) {
|
// 表格
|
||||||
html += '<div class="records-list">';
|
html = html.replace(/\|(.+)\|/g, (match) => {
|
||||||
records.forEach(row => {
|
const cells = match.split('|').filter(c => c.trim() !== '');
|
||||||
const time = field(row, 'record_time', 'recordTime');
|
const isHeader = match.includes('---');
|
||||||
const person = field(row, 'person');
|
if (isHeader) return '';
|
||||||
const loc = field(row, 'location');
|
const tag = match === html.split('\n').find(l => l.includes('|') && !l.includes('---')) ? 'th' : 'td';
|
||||||
const cont = field(row, 'content');
|
return '<tr>' + cells.map(c => '<' + tag + '>' + c.trim() + '</' + tag + '>').join('') + '</tr>';
|
||||||
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 += '</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;
|
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;
|
let notifyTimer = null;
|
||||||
|
|
||||||
function showNotification(message, isError) {
|
function showNotification(message, isError) {
|
||||||
if (notifyTimer) clearTimeout(notifyTimer);
|
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' : '');
|
notification.className = 'notification' + (isError ? ' error' : '');
|
||||||
void notification.offsetWidth;
|
// 强制回流
|
||||||
|
notification.offsetHeight;
|
||||||
notification.classList.add('show');
|
notification.classList.add('show');
|
||||||
notifyTimer = setTimeout(() => notification.classList.remove('show'), 3000);
|
|
||||||
|
notifyTimer = setTimeout(() => {
|
||||||
|
notification.classList.remove('show');
|
||||||
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 工具函数 ====================
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
if (!str) return '';
|
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = str;
|
div.textContent = str;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
|
|||||||
@ -2,245 +2,48 @@
|
|||||||
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
|
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<title>登录 - TraceCD</title>
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
|
||||||
<title>登录 - Trace</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@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>
|
<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 {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
background: var(--color-bg);
|
min-height: 100vh;
|
||||||
min-height: 100vh; min-height: 100dvh;
|
display: flex;
|
||||||
margin: 0; display: flex; align-items: center; justify-content: center;
|
align-items: center;
|
||||||
padding: 24px; -webkit-font-smoothing: antialiased;
|
justify-content: center;
|
||||||
overflow: hidden; position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==================== 渐变背景 ==================== */
|
|
||||||
.bg-gradient {
|
|
||||||
position: fixed; inset: 0; z-index: -2;
|
|
||||||
background:
|
|
||||||
radial-gradient(ellipse 80% 60% at 20% 10%, rgba(99,102,241,0.12) 0%, transparent 60%),
|
|
||||||
radial-gradient(ellipse 60% 50% at 80% 20%, rgba(168,85,247,0.10) 0%, transparent 50%),
|
|
||||||
radial-gradient(ellipse 70% 40% at 50% 90%, rgba(59,130,246,0.08) 0%, transparent 50%),
|
|
||||||
linear-gradient(160deg, #f0f4ff 0%, #f8fafc 40%, #faf5ff 70%, #f0f9ff 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 浮动光斑 */
|
|
||||||
.bg-orb {
|
|
||||||
position: fixed; border-radius: 50%; filter: blur(80px); z-index: -1;
|
|
||||||
animation: orbFloat 20s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.bg-orb-1 { width: 400px; height: 400px; top: -100px; left: -80px; background: rgba(99,102,241,0.12); }
|
|
||||||
.bg-orb-2 { width: 300px; height: 300px; bottom: -60px; right: -60px; background: rgba(168,85,247,0.10); animation-delay: -7s; }
|
|
||||||
.bg-orb-3 { width: 250px; height: 250px; top: 40%; left: 60%; background: rgba(59,130,246,0.08); animation-delay: -14s; }
|
|
||||||
@keyframes orbFloat {
|
|
||||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
|
||||||
33% { transform: translate(30px, -20px) scale(1.05); }
|
|
||||||
66% { transform: translate(-20px, 15px) scale(0.95); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== 登录卡片 ==================== */
|
|
||||||
.login-card {
|
.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;
|
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,
|
.login-card h1 {
|
||||||
.input-wrapper input:focus + .input-icon { color: var(--color-primary); }
|
font-size: 24px;
|
||||||
|
text-align: center;
|
||||||
/* ==================== 按钮 ==================== */
|
margin-bottom: 30px;
|
||||||
.btn-login {
|
color: #333;
|
||||||
width: 100%; border: none; padding: 14px;
|
|
||||||
border-radius: var(--radius-md); font-size: 15px;
|
|
||||||
font-weight: 700; cursor: pointer; letter-spacing: 0.5px;
|
|
||||||
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%);
|
|
||||||
color: white; position: relative; overflow: hidden;
|
|
||||||
box-shadow: 0 4px 16px rgba(99,102,241,0.3);
|
|
||||||
transition: all 0.25s ease;
|
|
||||||
animation: fadeInUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0.4s both;
|
|
||||||
}
|
|
||||||
.btn-login:hover {
|
|
||||||
box-shadow: 0 6px 24px rgba(99,102,241,0.4);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
.btn-login:active {
|
|
||||||
transform: translateY(0) scale(0.98);
|
|
||||||
box-shadow: 0 2px 8px rgba(99,102,241,0.3);
|
|
||||||
}
|
|
||||||
.btn-login::after {
|
|
||||||
content: ''; position: absolute; inset: 0;
|
|
||||||
background: linear-gradient(135deg, transparent 0%, rgba(255,255,255,0.15) 100%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== 错误提示 ==================== */
|
|
||||||
.alert-custom {
|
|
||||||
display: flex; align-items: center; gap: 10px;
|
|
||||||
background: linear-gradient(135deg, #fef2f2 0%, #fff1f2 100%);
|
|
||||||
border: 1px solid rgba(239,68,68,0.15);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 12px 16px; margin-bottom: 20px;
|
|
||||||
font-size: 14px; color: #dc2626; font-weight: 500;
|
|
||||||
animation: shakeIn 0.4s ease;
|
|
||||||
}
|
|
||||||
.alert-custom i { font-size: 18px; flex-shrink: 0; }
|
|
||||||
@keyframes shakeIn {
|
|
||||||
0% { opacity: 0; transform: translateX(-8px); }
|
|
||||||
40% { transform: translateX(4px); }
|
|
||||||
70% { transform: translateX(-2px); }
|
|
||||||
100% { opacity: 1; transform: translateX(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== 底部装饰 ==================== */
|
|
||||||
.login-footer {
|
|
||||||
text-align: center; margin-top: 24px;
|
|
||||||
font-size: 12px; color: var(--color-text-muted);
|
|
||||||
animation: fadeInUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0.5s both;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ==================== 响应式 ==================== */
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.login-card { padding: 48px 36px 36px; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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-card">
|
||||||
<div class="login-header">
|
<h1>TraceCD 日常记录</h1>
|
||||||
<div class="app-icon">📝</div>
|
<div th:if="${error}" class="alert alert-danger" th:text="${error}"></div>
|
||||||
<h1>Trace</h1>
|
<form method="post" action="/login">
|
||||||
<p class="subtitle">语音智能记账助手</p>
|
<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>
|
||||||
|
<div class="mb-3">
|
||||||
<div th:if="${error}" class="alert-custom">
|
<label for="password" class="form-label">密码</label>
|
||||||
<i class="bi bi-exclamation-circle-fill"></i>
|
<input type="password" class="form-control" id="password" name="password" required>
|
||||||
<span th:text="${error}"></span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">登 录</button>
|
||||||
<form method="post" action="/login" autocomplete="off">
|
|
||||||
<div class="input-group-custom">
|
|
||||||
<label for="username">用户名</label>
|
|
||||||
<div class="input-wrapper">
|
|
||||||
<i class="bi bi-person input-icon"></i>
|
|
||||||
<input type="text" id="username" name="username"
|
|
||||||
placeholder="请输入用户名" required autofocus autocomplete="username">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="input-group-custom" style="margin-bottom:28px;">
|
|
||||||
<label for="password">密码</label>
|
|
||||||
<div class="input-wrapper">
|
|
||||||
<i class="bi bi-lock input-icon"></i>
|
|
||||||
<input type="password" id="password" name="password"
|
|
||||||
placeholder="请输入密码" required autocomplete="current-password">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn-login">登 录</button>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="login-footer">语音记录 · 智能分析 · 轻松管理</div>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user