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