优化页面
All checks were successful
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Successful in 1m22s

This commit is contained in:
Lee 2026-06-08 23:20:38 +08:00
parent 0476b72624
commit 172c2a098f
7 changed files with 864 additions and 677 deletions

View File

@ -1,5 +1,8 @@
package com.l.tracecd.dto;
import java.util.List;
import java.util.Map;
/**
* 语音处理响应 DTO
* 返回给前端的统一响应结构
@ -21,6 +24,9 @@ public class VoiceResponse {
/** 错误信息 */
private String error;
/** 查询结果记录列表 */
private List<Map<String, Object>> records;
public static VoiceResponse recordSuccess(String category) {
VoiceResponse r = new VoiceResponse();
r.type = "RECORD";
@ -30,11 +36,12 @@ public class VoiceResponse {
return r;
}
public static VoiceResponse queryResult(String message) {
public static VoiceResponse queryResult(String summary, List<Map<String, Object>> records) {
VoiceResponse r = new VoiceResponse();
r.type = "QUERY";
r.success = true;
r.message = message;
r.message = summary;
r.records = records;
return r;
}
@ -55,43 +62,21 @@ public class VoiceResponse {
return r;
}
public String getType() {
return type;
}
public String getType() { return type; }
public void setType(String type) { this.type = type; }
public void setType(String type) {
this.type = type;
}
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public String getMessage() {
return message;
}
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public void setMessage(String message) {
this.message = message;
}
public boolean isSuccess() { return success; }
public void setSuccess(boolean success) { this.success = success; }
public String getCategory() {
return category;
}
public String getError() { return error; }
public void setError(String error) { this.error = error; }
public void setCategory(String category) {
this.category = category;
}
public boolean isSuccess() {
return success;
}
public void setSuccess(boolean success) {
this.success = success;
}
public String getError() {
return error;
}
public void setError(String error) {
this.error = error;
}
public List<Map<String, Object>> getRecords() { return records; }
public void setRecords(List<Map<String, Object>> records) { this.records = records; }
}

View File

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

View File

@ -12,7 +12,11 @@ import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import javax.sql.DataSource;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ -129,7 +133,7 @@ public class VoiceService {
}
/**
* 处理查询意图执行 function call 返回结果给 LLM 获取格式化回复
* 处理查询意图执行 function call 返回结果给 LLM 生成总结 构造结构化响应
*/
private VoiceResponse handleQueryIntent(DeepSeekResponse.ResponseMessage response,
String originalText) throws Exception {
@ -151,15 +155,45 @@ public class VoiceService {
String validatedSql = sqlValidationService.validateSelect(sql);
String queryResultJson = executeSelectSql(validatedSql);
// 构建对话历史发送回 LLM
// 解析查询结果为结构化数据并格式化日期字段
List<Map<String, Object>> records = parseQueryResult(queryResultJson);
// 将查询结果发给 LLM只生成一句总结
List<DeepSeekRequest.Message> messages = buildToolResultMessages(originalText, response, toolCall, queryResultJson);
DeepSeekResponse.ResponseMessage finalResponse = llmService.continueWithToolResult(messages);
String replyContent = finalResponse.getContent();
String summary;
if (replyContent == null || replyContent.isBlank()) {
replyContent = "查询完成,但未能生成回复";
summary = "查询完成";
} else {
// 尝试从 LLM 回复中提取 JSON
summary = extractJsonField(replyContent, "summary");
if (summary == null) {
summary = replyContent; // 降级直接使用原始回复
}
}
return VoiceResponse.queryResult(summary, records);
}
/**
* LLM 返回的 JSON 字符串中提取指定字段
*/
private String extractJsonField(String content, String field) {
try {
// 尝试直接解析
String trimmed = content.trim();
// 去掉可能的 markdown 代码块包裹
trimmed = trimmed.replaceAll("^```json\\s*", "").replaceAll("^```\\s*", "").replaceAll("```$", "").trim();
Map<String, Object> map = objectMapper.readValue(trimmed,
new TypeReference<Map<String, Object>>() {});
Object value = map.get(field);
return value != null ? value.toString() : null;
} catch (Exception e) {
log.debug("解析 LLM JSON 回复失败,将使用原始文本: {}", e.getMessage());
return null;
}
return VoiceResponse.queryResult(replyContent);
}
/**
@ -189,6 +223,39 @@ public class VoiceService {
}
}
/**
* 解析查询结果 JSON格式化日期字段为 M-d HH:mm
*/
private List<Map<String, Object>> parseQueryResult(String queryResultJson) throws Exception {
List<Map<String, Object>> raw = objectMapper.readValue(queryResultJson,
new TypeReference<List<Map<String, Object>>>() {});
DateTimeFormatter outFmt = DateTimeFormatter.ofPattern("M-d HH:mm");
List<Map<String, Object>> result = new ArrayList<>();
for (Map<String, Object> row : raw) {
LinkedHashMap<String, Object> formatted = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : row.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
// 保留全部字段包括 category前端用于分类着色
// 格式化时间字段
if (value instanceof String && (key.toLowerCase().contains("time") || key.toLowerCase().contains("record"))) {
try {
LocalDateTime dt = LocalDateTime.parse((String) value);
value = dt.format(outFmt);
} catch (Exception ignored) {}
}
// 金额字段转为数字
if (value instanceof BigDecimal) {
value = ((BigDecimal) value).doubleValue();
}
formatted.put(key, value);
}
result.add(formatted);
}
return result;
}
/**
* 构建 function call 的完整对话历史
*/
@ -199,8 +266,7 @@ public class VoiceService {
List<DeepSeekRequest.Message> messages = new ArrayList<>();
// system
messages.add(new DeepSeekRequest.Message("system",
"你是一个日常记账助手。请根据查询结果友好地回答用户的问题。多条数据请用 markdown 表格展示,表格上方展示金额汇总。"));
messages.add(new DeepSeekRequest.Message("system", CONTENT_TEMPLATE));
// user original
messages.add(new DeepSeekRequest.Message("user", originalText));
@ -218,4 +284,16 @@ public class VoiceService {
return messages;
}
private static final String CONTENT_TEMPLATE = """
你是一个日常记账助手根据用户问题和查询结果生成一句友好的总结回复
请严格只返回以下 JSON 格式不要包含任何其他内容不要 markdown 代码块
{"summary": "你的总结包含记录条数如共X条和金额汇总合计 ¥XXX.XX语气友好自然"}
示例返回
{"summary": "共3条记录合计 ¥156.00"}
{"summary": "只找到1条记录金额 ¥20.00"}
{"summary": "没有找到匹配的记录哦"}
""";
}

View File

@ -63,7 +63,7 @@ logging:
deepseek:
api-key: ${DEEPSEEK_API_KEY:sk-732e5f5f1f07454492022401f0a2bf40}
base-url: https://api.deepseek.com/chat/completions
model: deepseek-v4-pro
model: deepseek-v4-flash
# MIMO ASR API
mimo:

View File

@ -2,159 +2,183 @@
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<title>浏览事项 - TraceCD</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<style>
:root {
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--nav-height: 52px;
--color-primary: #6366f1;
--color-bg: #f1f5f9;
--color-surface: #ffffff;
--color-text: #1e293b;
--color-text-secondary: #64748b;
--color-text-muted: #94a3b8;
--color-border: #e2e8f0;
--radius-lg: 20px;
--radius-md: 14px;
--radius-sm: 10px;
--radius-full: 999px;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
--font-xs: 13px;
--font-sm: 14px;
--font-base: 17px;
--font-lg: 19px;
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f8fafc;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--color-bg); min-height: 100vh; min-height: 100dvh;
margin: 0; padding: 0; -webkit-font-smoothing: antialiased; overflow-x: hidden;
}
/* ==================== 导航 ==================== */
.navbar {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 12px 20px;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--color-surface); border-bottom: 1px solid var(--color-border);
height: var(--nav-height); padding: 0 var(--safe-left) 0 var(--safe-right);
display: flex; justify-content: space-between; align-items: center;
position: sticky; top: 0; z-index: 100;
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
background: rgba(255,255,255,0.85);
}
.navbar .brand { font-size: 18px; font-weight: 600; color: #1e293b; }
.navbar .brand { font-size: 17px; font-weight: 700; color: var(--color-text); letter-spacing: -0.3px; padding-left: 20px; }
.navbar .nav-links { padding-right: 16px; display: flex; gap: 6px; }
.navbar .nav-links a {
color: #64748b; text-decoration: none; margin-left: 20px;
font-size: 14px; transition: color 0.2s;
color: var(--color-text-secondary); text-decoration: none; font-size: var(--font-sm);
padding: 8px 14px; border-radius: var(--radius-sm); transition: all 0.2s; font-weight: 500; white-space: nowrap;
}
.navbar .nav-links a:hover { color: #4f46e5; }
.navbar .nav-links a:hover, .navbar .nav-links a:active { background: #f1f5f9; color: var(--color-primary); }
/* ==================== 页面容器 ==================== */
.page-container {
max-width: 1200px;
margin: 0 auto;
padding: 30px 20px;
max-width: 720px; margin: 0 auto;
padding: 16px 12px calc(24px + var(--safe-bottom));
}
/* 筛选栏 */
/* ==================== 筛选栏 — 紧凑 ==================== */
.filter-bar {
background: white;
border-radius: 16px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
margin-bottom: 24px;
background: var(--color-surface); border-radius: var(--radius-md);
padding: 12px 14px; box-shadow: var(--shadow-sm); margin-bottom: 12px;
}
.filter-bar .row { margin-bottom: 12px; }
.filter-bar label {
font-size: 13px;
font-weight: 600;
color: #64748b;
margin-bottom: 4px;
display: block;
}
.filter-bar .form-select,
.filter-bar .form-control {
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
.filter-section { margin-bottom: 10px; }
.filter-section:last-of-type { margin-bottom: 10px; }
.filter-section-title {
font-size: 11px; font-weight: 700; color: var(--color-text-muted);
text-transform: uppercase; letter-spacing: 0.6px; margin-bottom: 4px;
}
.btn-search {
background: #4f46e5;
color: white;
border: none;
padding: 10px 32px;
border-radius: 8px;
font-weight: 500;
transition: background 0.2s;
/* 日期输入 */
.date-row {
display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; margin-bottom: 2px;
}
.date-row input[type="date"] {
border: 1.5px solid var(--color-border); border-radius: var(--radius-sm);
font-size: var(--font-sm); padding: 0 10px; transition: border-color 0.2s;
background: #f8fafc; width: 100%; min-width: 0; height: 40px; min-height: 40px;
-webkit-appearance: none; line-height: 40px;
}
.date-row input[type="date"]:focus {
border-color: var(--color-primary); outline: none;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
/* Chip 多选标签组 */
.chip-group {
display: flex; flex-wrap: wrap; gap: 5px;
max-height: 88px; overflow-y: auto; -webkit-overflow-scrolling: touch;
padding: 2px 0;
}
.chip-group .chip {
display: inline-flex; align-items: center; gap: 3px;
padding: 4px 10px; 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.15s; background: #fafbfc; color: var(--color-text-secondary);
white-space: nowrap; min-height: 30px;
}
.chip-group .chip:active { transform: scale(0.96); }
.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: #eef2ff; border-color: var(--color-primary); color: var(--color-primary); font-weight: 600;
}
.chip-group .chip-count {
font-size: 10px; color: var(--color-text-muted); margin-left: auto;
}
/* 按钮行 */
.btn-group-row {
display: flex; gap: 8px; margin-top: 12px;
}
.btn-search:hover { background: #4338ca; }
.btn-reset {
background: transparent;
color: #64748b;
border: 1px solid #e2e8f0;
padding: 10px 24px;
border-radius: 8px;
margin-left: 8px;
transition: all 0.2s;
background: var(--color-surface); color: var(--color-text-secondary);
border: 1.5px solid var(--color-border); padding: 10px 18px; border-radius: var(--radius-sm);
font-weight: 500; font-size: var(--font-sm); transition: all 0.2s;
display: flex; align-items: center; justify-content: center; gap: 4px; white-space: nowrap; cursor: pointer;
}
.btn-reset:hover { background: #f1f5f9; }
.btn-reset:active { background: #f1f5f9; }
.btn-search {
flex: 1; background: var(--color-primary); color: white; border: none;
padding: 10px 20px; border-radius: var(--radius-sm); font-weight: 600; font-size: var(--font-sm);
transition: background 0.2s, transform 0.1s; cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: 4px;
}
.btn-search:active { background: #4f46e5; transform: scale(0.98); }
/* 汇总 */
/* ==================== 汇总栏 ==================== */
.summary-bar {
background: white;
border-radius: 12px;
padding: 16px 24px;
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
display: none;
background: var(--color-surface); border-radius: var(--radius-sm);
padding: 10px 16px; margin-bottom: 10px; display: none;
align-items: center; justify-content: space-between;
box-shadow: var(--shadow-sm); animation: fadeInUp 0.3s ease;
}
.summary-bar.visible { display: flex; }
.summary-amount {
font-size: 22px;
font-weight: 700;
color: #4f46e5;
}
.summary-count { font-size: 14px; color: #64748b; }
.summary-amount { font-size: var(--font-base); font-weight: 700; color: var(--color-primary); }
.summary-count { font-size: var(--font-xs); color: var(--color-text-secondary); font-weight: 500; }
/* 表格 */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* ==================== 表格 ==================== */
.table-card {
background: white;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.table-card table {
width: 100%;
margin: 0;
background: var(--color-surface); border-radius: var(--radius-md);
overflow: hidden; box-shadow: var(--shadow-sm);
}
.table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.table-card table { width: 100%; min-width: 500px; margin: 0; border-collapse: collapse; }
.table-card table thead th {
background: #f8fafc;
font-size: 12px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
padding: 14px 16px;
border-bottom: 2px solid #e2e8f0;
background: #f8fafc; font-size: 11px; font-weight: 700; color: var(--color-text-secondary);
text-transform: uppercase; letter-spacing: 0.4px; padding: 4px 6px;
border-bottom: 2px solid var(--color-border); white-space: nowrap; position: sticky; top: 0;
}
.table-card table tbody td {
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
font-size: 14px;
color: #334155;
vertical-align: middle;
padding: 3px 6px; border-bottom: 1px solid #f1f5f9;
font-size: var(--font-xs); color: var(--color-text); vertical-align: middle;
}
.table-card table tbody tr:hover { background: #f8fafc; }
.table-card table tbody tr:last-child td { border-bottom: none; }
.table-card table tbody tr:active { background: #f8fafc; }
.category-badge {
display: inline-block;
background: #eef2ff;
color: #4f46e5;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
}
.amount-cell {
font-weight: 600;
color: #ef4444;
text-align: right;
display: inline-block; background: #eef2ff; color: var(--color-primary);
padding: 2px 8px; border-radius: var(--radius-full); font-size: 11px; font-weight: 600; white-space: nowrap;
}
.amount-cell { font-weight: 600; color: #ef4444; text-align: right; font-variant-numeric: tabular-nums; }
.empty-msg {
text-align: center;
padding: 60px 20px;
color: #94a3b8;
font-size: 15px;
}
/* 加载 */
.loading-bar {
text-align: center;
padding: 40px;
display: none;
}
.empty-msg { text-align: center; padding: 40px 20px; color: var(--color-text-muted); font-size: var(--font-sm); }
.loading-bar { text-align: center; padding: 36px 20px; display: none; }
.loading-bar.show { display: block; }
.loading-bar .spinner-border { width: 28px; height: 28px; border-width: 3px; color: var(--color-primary); }
</style>
</head>
<body>
@ -162,7 +186,7 @@
<nav class="navbar">
<span class="brand">📝 TraceCD</span>
<div class="nav-links">
<a href="/">🎤 语音录入</a>
<a href="/">🎤 语音</a>
<a href="/logout">退出</a>
</div>
</nav>
@ -170,50 +194,45 @@
<div class="page-container">
<!-- 筛选栏 -->
<div class="filter-bar">
<div class="row">
<div class="col-md-3 mb-3">
<label>人物</label>
<select class="form-select" id="filterPerson" multiple size="3">
</select>
</div>
<div class="col-md-3 mb-3">
<label>开始时间</label>
<input type="date" class="form-control" id="filterStartTime">
</div>
<div class="col-md-3 mb-3">
<label>结束时间</label>
<input type="date" class="form-control" id="filterEndTime">
</div>
<div class="col-md-3 mb-3">
<label>地点</label>
<select class="form-select" id="filterLocation" multiple size="3">
</select>
<!-- 人物 -->
<div class="filter-section">
<div class="filter-section-title">人物</div>
<div class="chip-group" id="chipPerson"></div>
</div>
<!-- 分类 -->
<div class="filter-section">
<div class="filter-section-title">分类</div>
<div class="chip-group" id="chipCategory"></div>
</div>
<!-- 地点 -->
<div class="filter-section">
<div class="filter-section-title">地点</div>
<div class="chip-group" id="chipLocation"></div>
</div>
<!-- 日期 -->
<div class="filter-section">
<div class="filter-section-title">时间范围</div>
<div class="date-row">
<input type="date" id="filterStartTime" placeholder="开始">
<input type="date" id="filterEndTime" placeholder="结束">
</div>
</div>
<div class="row">
<div class="col-md-3 mb-3">
<label>分类</label>
<select class="form-select" id="filterCategory" multiple size="3">
</select>
</div>
<div class="col-md-9 mb-3 d-flex align-items-end">
<button class="btn-search" onclick="doQuery()">
<i class="bi bi-search"></i> 查询
</button>
<button class="btn-reset" onclick="resetFilters()">
<i class="bi bi-arrow-clockwise"></i> 重置
</button>
</div>
<!-- 按钮:重置在左,查询在右 -->
<div class="btn-group-row">
<button class="btn-reset" onclick="resetFilters()">
<i class="bi bi-arrow-clockwise"></i> 重置
</button>
<button class="btn-search" onclick="doQuery()">
<i class="bi bi-search"></i> 查询
</button>
</div>
</div>
<!-- 汇总 -->
<div class="summary-bar" id="summaryBar">
<span class="summary-count" id="summaryCount"></span>
<div>
<span class="summary-count" id="summaryCount"></span>
</div>
<div>
<span style="color: #64748b;">合计:</span>
<span style="color:var(--color-text-secondary);font-size:var(--font-xs);">合计 </span>
<span class="summary-amount" id="summaryAmount"></span>
</div>
</div>
@ -221,55 +240,60 @@
<!-- 表格 -->
<div class="table-card">
<div class="loading-bar" id="loadingBar">
<div class="spinner-border text-secondary" role="status"></div>
<p class="mt-2 text-muted">查询中...</p>
<div class="spinner-border" role="status"></div>
<p style="margin-top:10px;color:var(--color-text-muted);font-size:var(--font-sm);">查询中...</p>
</div>
<div class="table-responsive">
<table class="table" id="resultTable" style="display:none;">
<div class="table-scroll">
<table id="resultTable" style="display:none;">
<thead>
<tr>
<th>人物</th>
<th>时间</th>
<th>地点</th>
<th>内容</th>
<th>分类</th>
<th style="text-align:right;">金额</th>
<th>人物</th><th>时间</th><th>地点</th><th>内容</th><th>分类</th><th style="text-align:right;">金额</th>
</tr>
</thead>
<tbody id="resultTbody"></tbody>
</table>
</div>
<div class="empty-msg" id="emptyMsg">点击"查询"按钮查看事项数据</div>
<div class="empty-msg" id="emptyMsg">点击「查询」查看事项数据</div>
</div>
</div>
<script th:inline="javascript">
// ==================== 初始化 ====================
// ==================== 初始化 Chip 选择器 ====================
document.addEventListener('DOMContentLoaded', loadFilterOptions);
// 存储每个 filter 组的 chip 容器 ID 映射
const chipMap = {
person: 'chipPerson',
location: 'chipLocation',
category: 'chipCategory'
};
async function loadFilterOptions() {
try {
const response = await fetch('/api/filter/options');
if (response.status === 401) {
window.location.href = '/login';
return;
}
if (response.status === 401) { window.location.href = '/login'; return; }
const options = await response.json();
options.forEach(opt => {
let selectEl;
switch (opt.fieldName) {
case 'person': selectEl = document.getElementById('filterPerson'); break;
case 'location': selectEl = document.getElementById('filterLocation'); break;
case 'category': selectEl = document.getElementById('filterCategory'); break;
default: return;
}
const containerId = chipMap[opt.fieldName];
if (!containerId) return;
const container = document.getElementById(containerId);
const fragment = document.createDocumentFragment();
opt.values.forEach(v => {
const option = document.createElement('option');
option.value = v;
option.textContent = v;
selectEl.appendChild(option);
const label = document.createElement('label');
label.className = 'chip';
label.innerHTML = '<input type="checkbox" value="' + escAttr(v) + '"> ' + escHtml(v);
// 点击 label 切换 selected 样式
label.addEventListener('change', function(e) {
if (e.target.checked) {
label.classList.add('selected');
} else {
label.classList.remove('selected');
}
});
fragment.appendChild(label);
});
container.appendChild(fragment);
});
} catch (err) {
console.error('加载筛选选项失败:', err);
@ -279,21 +303,13 @@
// ==================== 查询 ====================
async function doQuery() {
const query = {
persons: getSelectedValues('filterPerson'),
startTime: document.getElementById('filterStartTime').value || null,
endTime: document.getElementById('filterEndTime').value || null,
locations: getSelectedValues('filterLocation'),
categories: getSelectedValues('filterCategory')
persons: getCheckedValues('chipPerson'),
startTime: buildDateTime('filterStartTime', false),
endTime: buildDateTime('filterEndTime', true),
locations: getCheckedValues('chipLocation'),
categories: getCheckedValues('chipCategory')
};
// 如果 endTime 有值,附加时间
if (query.endTime) {
query.endTime += 'T23:59:59';
}
if (query.startTime) {
query.startTime += 'T00:00:00';
}
const loadingBar = document.getElementById('loadingBar');
const resultTable = document.getElementById('resultTable');
const emptyMsg = document.getElementById('emptyMsg');
@ -310,12 +326,7 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(query)
});
if (response.status === 401) {
window.location.href = '/login';
return;
}
if (response.status === 401) { window.location.href = '/login'; return; }
const data = await response.json();
renderResults(data);
} catch (err) {
@ -327,6 +338,12 @@
}
}
function buildDateTime(dateInputId, isEndOfDay) {
const val = document.getElementById(dateInputId).value;
if (!val) return null;
return isEndOfDay ? val + 'T23:59:59' : val + 'T00:00:00';
}
function renderResults(data) {
const tbody = document.getElementById('resultTbody');
const resultTable = document.getElementById('resultTable');
@ -341,57 +358,62 @@
return;
}
// 渲染表格
tbody.innerHTML = data.records.map(r => `
<tr>
<td>${esc(r.person)}</td>
<td>${formatTime(r.recordTime)}</td>
<td>${esc(r.location)}</td>
<td>${esc(r.content)}</td>
<td><span class="category-badge">${esc(r.category)}</span></td>
<td class="amount-cell">¥${formatAmount(r.amount)}</td>
</tr>
`).join('');
const fragment = document.createDocumentFragment();
data.records.forEach(r => {
const tr = document.createElement('tr');
tr.innerHTML =
'<td>' + escHtml(r.person) + '</td>' +
'<td>' + formatTime(r.recordTime) + '</td>' +
'<td>' + escHtml(r.location) + '</td>' +
'<td>' + escHtml(r.content) + '</td>' +
'<td><span class="category-badge">' + escHtml(r.category) + '</span></td>' +
'<td class="amount-cell">¥' + formatAmount(r.amount) + '</td>';
fragment.appendChild(tr);
});
tbody.innerHTML = '';
tbody.appendChild(fragment);
resultTable.style.display = 'table';
emptyMsg.style.display = 'none';
// 汇总
document.getElementById('summaryCount').textContent = `共 ${data.count} 条记录`;
document.getElementById('summaryAmount').textContent = `¥${formatAmount(data.totalAmount)}`;
document.getElementById('summaryCount').textContent = '共 ' + data.count + ' 条记录';
document.getElementById('summaryAmount').textContent = '¥' + formatAmount(data.totalAmount);
summaryBar.classList.add('visible');
}
function resetFilters() {
document.getElementById('filterPerson').selectedIndex = -1;
// 取消所有 chip 勾选
['chipPerson', 'chipLocation', 'chipCategory'].forEach(id => {
const container = document.getElementById(id);
if (!container) return;
container.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.checked = false;
cb.parentElement.classList.remove('selected');
});
});
document.getElementById('filterStartTime').value = '';
document.getElementById('filterEndTime').value = '';
document.getElementById('filterLocation').selectedIndex = -1;
document.getElementById('filterCategory').selectedIndex = -1;
document.getElementById('resultTable').style.display = 'none';
document.getElementById('emptyMsg').textContent = '点击「查询」查看事项数据';
document.getElementById('emptyMsg').style.display = 'block';
document.getElementById('emptyMsg').textContent = '点击"查询"按钮查看事项数据';
document.getElementById('summaryBar').classList.remove('visible');
}
// ==================== 工具函数 ====================
function getSelectedValues(selectId) {
const select = document.getElementById(selectId);
function getCheckedValues(containerId) {
const container = document.getElementById(containerId);
if (!container) return null;
const values = [];
for (const opt of select.options) {
if (opt.selected) values.push(opt.value);
}
container.querySelectorAll('input[type="checkbox"]:checked').forEach(cb => values.push(cb.value));
return values.length > 0 ? values : null;
}
function formatTime(timeStr) {
if (!timeStr) return '';
// 格式化 ISO 时间为可读格式
const d = new Date(timeStr);
if (isNaN(d.getTime())) return timeStr;
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
return (d.getMonth()+1) + '-' + d.getDate() + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function formatAmount(amount) {
@ -399,12 +421,16 @@
return Number(amount).toFixed(2);
}
function esc(str) {
function escHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function escAttr(str) {
return str.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
</script>
</body>
</html>

View File

@ -2,578 +2,565 @@
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<title>TraceCD - 日常记录</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<style>
:root {
--mic-size: 80px;
--mic-color: #4f46e5;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--mic-size: clamp(72px, 20vw, 92px);
--mic-color: #6366f1;
--mic-recording: #ef4444;
--mic-shadow: 0 4px 20px rgba(79, 70, 229, 0.4);
--nav-height: 52px;
--color-bg: #f1f5f9;
--color-surface: #ffffff;
--color-text: #1e293b;
--color-text-secondary: #64748b;
--color-text-muted: #94a3b8;
--color-border: #e2e8f0;
--radius-lg: 20px;
--radius-md: 14px;
--radius-sm: 10px;
--radius-full: 999px;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
--font-xs: 14px;
--font-sm: 15px;
--font-base: 18px;
--font-lg: 20px;
}
* { box-sizing: border-box; }
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f8fafc;
min-height: 100vh;
margin: 0;
padding-bottom: 140px;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--color-bg); min-height: 100vh; min-height: 100dvh;
margin: 0; padding: 0; -webkit-font-smoothing: antialiased; overflow-x: hidden;
}
/* 顶部导航 */
/* ==================== 顶部导航 ==================== */
.navbar {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 12px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar .brand {
font-size: 18px;
font-weight: 600;
color: #1e293b;
background: rgba(255,255,255,0.85); border-bottom: 1px solid var(--color-border);
height: var(--nav-height); padding: 0 var(--safe-left) 0 var(--safe-right);
display: flex; justify-content: space-between; align-items: center;
position: sticky; top: 0; z-index: 100;
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
}
.navbar .brand { font-size: 18px; font-weight: 700; color: var(--color-text); letter-spacing: -0.3px; padding-left: 20px; }
.navbar .nav-links { padding-right: 16px; display: flex; gap: 6px; }
.navbar .nav-links a {
color: #64748b;
text-decoration: none;
margin-left: 20px;
font-size: 14px;
transition: color 0.2s;
color: var(--color-text-secondary); text-decoration: none; font-size: var(--font-sm);
padding: 8px 14px; border-radius: var(--radius-sm); transition: all 0.2s; font-weight: 500; white-space: nowrap;
}
.navbar .nav-links a:hover { color: #4f46e5; }
.navbar .nav-links a:hover, .navbar .nav-links a:active { background: #f1f5f9; color: var(--mic-color); }
/* 通知栏 */
/* ==================== 通知栏 ==================== */
.notification {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
background: #10b981;
color: white;
padding: 12px 28px;
border-radius: 999px;
font-size: 15px;
font-weight: 500;
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.3);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
position: fixed; top: calc(var(--nav-height) + 12px + var(--safe-top)); left: 50%;
transform: translateX(-50%) translateY(-8px); z-index: 9999;
background: #10b981; color: white; padding: 10px 20px; border-radius: var(--radius-full);
font-size: var(--font-sm); font-weight: 600;
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.35);
opacity: 0; pointer-events: none; transition: opacity 0.3s ease, transform 0.3s ease;
max-width: calc(100vw - 40px); text-align: center;
}
.notification.show { opacity: 1; }
.notification.error { background: #ef4444; box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3); }
.notification.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.notification.error { background: #ef4444; box-shadow: 0 4px 16px rgba(239, 68, 68, 0.35); }
/* 内容区 */
/* ==================== 内容区 ==================== */
.content-area {
max-width: 800px;
margin: 40px auto;
padding: 0 20px;
max-width: 640px; margin: 0 auto;
padding: 10px 8px calc(var(--mic-size) + 20px + var(--safe-bottom) + 40px);
}
/* 查询结果 */
/* ==================== 空状态 ==================== */
.empty-state { text-align: center; padding: 40px 16px 24px; color: var(--color-text-muted); }
.empty-state .empty-icon { font-size: 52px; margin-bottom: 14px; display: block; opacity: 0.6; }
.empty-state .empty-title { font-size: var(--font-lg); font-weight: 600; color: var(--color-text); margin-bottom: 6px; }
.empty-state .empty-desc { font-size: var(--font-sm); color: var(--color-text-muted); line-height: 1.4; }
/* ==================== 查询结果 ==================== */
.result-card {
background: white;
border-radius: 16px;
padding: 30px;
margin-top: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
display: none;
background: transparent; border-radius: 0;
padding: 0; box-shadow: none;
display: none; animation: fadeInUp 0.35s ease;
}
.result-card.visible { display: block; }
.result-card table { width: 100%; }
.result-card table th, .result-card table td {
padding: 10px 14px;
border-bottom: 1px solid #f1f5f9;
/* 卡片列表 */
.records-list {
display: -webkit-flex; display: flex;
-webkit-flex-direction: column; flex-direction: column;
gap: 5px;
}
.result-card table th {
background: #f8fafc;
font-weight: 600;
font-size: 13px;
color: #64748b;
text-transform: uppercase;
.record-card {
border-radius: 12px; padding: 10px 14px;
display: -webkit-flex; display: flex;
-webkit-flex-direction: column; flex-direction: column;
gap: 2px;
border-left: 4px solid #e2e8f0;
box-shadow: 0 1px 2px rgba(0,0,0,0.03);
-webkit-tap-highlight-color: rgba(0,0,0,0.05);
}
.amount-summary {
font-size: 18px;
font-weight: 700;
color: #4f46e5;
margin-bottom: 16px;
.record-card:active { opacity: 0.85; }
.card-top {
display: -webkit-flex; display: flex;
-webkit-justify-content: space-between; justify-content: space-between;
-webkit-align-items: baseline; align-items: baseline;
}
.card-time { font-size: 16px; color: #64748b; font-weight: 500; }
.card-time .cat-emoji { margin-right: 4px; font-size: 18px; }
.card-amount { font-size: 20px; font-weight: 700; color: #ef4444; white-space: nowrap; }
.card-detail {
font-size: 15px; color: #334155; line-height: 1.5;
display: -webkit-flex; display: flex;
-webkit-flex-wrap: wrap; flex-wrap: wrap;
gap: 4px 8px;
}
.card-detail .meta-tag {
font-size: 13px; color: #94a3b8; white-space: nowrap;
}
/* 聊天回复 */
/* 底部总结 */
.summary-footer {
margin-top: 10px; padding: 14px 16px;
background: #eef2ff; border-radius: 12px;
text-align: center; font-size: 16px; font-weight: 600;
color: #4f46e5; line-height: 1.5;
}
/* ==================== 聊天回复 ==================== */
.chat-reply {
background: white;
border-radius: 16px;
padding: 24px 30px;
margin-top: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
font-size: 16px;
line-height: 1.7;
color: #334155;
display: none;
background: var(--color-surface); border-radius: var(--radius-sm);
padding: 12px 12px; box-shadow: var(--shadow-sm);
font-size: var(--font-sm); line-height: 1.6; color: var(--color-text);
display: none; animation: fadeInUp 0.35s ease;
}
.chat-reply.visible { display: block; }
.chat-reply p { margin: 0; }
/* 加载动画 */
.loading-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(255,255,255,0.7);
z-index: 9998;
display: none;
align-items: center;
justify-content: center;
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.loading-overlay.show { display: flex; }
.spinner {
width: 48px;
height: 48px;
border: 4px solid #e2e8f0;
border-top-color: #4f46e5;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* 麦克风按钮 */
.mic-container {
position: fixed;
bottom: 40px;
right: 40px;
z-index: 1000;
/* ==================== 麦克风按钮区域 ==================== */
.mic-bottom-bar {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000;
background: linear-gradient(180deg, transparent 0%, var(--color-bg) 50%, var(--color-bg) 100%);
padding: 16px 0 calc(24px + var(--safe-bottom));
display: flex; flex-direction: column; align-items: center; pointer-events: none;
}
.mic-container { position: relative; pointer-events: auto; }
.mic-btn {
width: var(--mic-size);
height: var(--mic-size);
border-radius: 50%;
background: var(--mic-color);
border: none;
color: white;
font-size: 32px;
cursor: pointer;
box-shadow: var(--mic-shadow);
width: var(--mic-size); height: var(--mic-size); border-radius: 50%;
background: var(--mic-color); border: none; color: white;
font-size: calc(var(--mic-size) * 0.4); cursor: pointer;
box-shadow: 0 4px 24px rgba(99, 102, 241, 0.4);
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
-webkit-tap-highlight-color: transparent;
user-select: none;
-webkit-user-select: none;
}
.mic-btn:hover {
transform: scale(1.06);
box-shadow: 0 6px 28px rgba(79, 70, 229, 0.5);
}
.mic-btn:active {
transform: scale(0.96);
display: flex; align-items: center; justify-content: center;
user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; position: relative;
}
.mic-btn:active { transform: scale(0.94); box-shadow: 0 2px 12px rgba(99, 102, 241, 0.3); transition: transform 0.08s ease; }
.mic-btn.recording {
background: var(--mic-recording);
box-shadow: 0 4px 24px rgba(239, 68, 68, 0.5);
animation: pulse 1.5s ease-in-out infinite;
background: var(--mic-recording); box-shadow: 0 4px 28px rgba(239, 68, 68, 0.5);
animation: micPulse 1.6s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5); }
50% { box-shadow: 0 0 0 20px rgba(239, 68, 68, 0); }
@keyframes micPulse {
0%, 100% { box-shadow: 0 4px 28px rgba(239, 68, 68, 0.5); }
50% { box-shadow: 0 4px 28px rgba(239, 68, 68, 0.25), 0 0 0 16px rgba(239, 68, 68, 0.08); }
}
/* 底部状态行:提示文字 + 处理中指示器 */
.mic-status-row {
display: flex; align-items: center; gap: 10px; margin-top: 10px;
pointer-events: none; user-select: none; -webkit-user-select: none;
}
.mic-hint {
text-align: center;
margin-top: 12px;
font-size: 12px;
color: #94a3b8;
font-size: var(--font-xs); color: var(--color-text-muted); font-weight: 500;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 80px 20px;
color: #94a3b8;
.mic-hint.recording-hint { color: var(--mic-recording); font-weight: 600; animation: hintBlink 1.2s ease-in-out infinite; }
@keyframes hintBlink {
0%, 100% { opacity: 1; } 50% { opacity: 0.5; }
}
.empty-state i { font-size: 48px; display: block; margin-bottom: 16px; }
/* 处理中指示器 */
.processing-indicator {
display: none; align-items: center; gap: 4px;
}
.processing-indicator.show { display: flex; }
.processing-dot {
width: 6px; height: 6px; border-radius: 50%; background: var(--mic-color);
animation: dotBounce 1.2s ease-in-out infinite;
}
.processing-dot:nth-child(2) { animation-delay: 0.15s; }
.processing-dot:nth-child(3) { animation-delay: 0.3s; }
@keyframes dotBounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
.processing-text { font-size: var(--font-xs); color: var(--mic-color); font-weight: 500; }
/* 响应式 */
@media (max-width: 640px) {
.mic-container {
bottom: 24px;
right: 24px;
}
:root { --mic-size: 68px; }
@media (min-width: 768px) {
.content-area { padding: 32px 20px 120px; }
.navbar .brand { padding-left: 24px; }
}
</style>
</head>
<body>
<!-- 顶部导航 -->
<nav class="navbar">
<span class="brand">📝 TraceCD</span>
<div class="nav-links">
<a href="/browse">📋 浏览事项</a>
<a href="/browse">📋 浏览</a>
<a href="/logout">退出</a>
</div>
</nav>
<!-- 通知 -->
<div class="notification" id="notification"></div>
<!-- 加载遮罩 -->
<div class="loading-overlay" id="loading">
<div class="spinner"></div>
</div>
<!-- 内容区 -->
<div class="content-area" id="contentArea">
<div class="empty-state" id="emptyState">
<i class="bi bi-mic-fill"></i>
<p>按住右下角麦克风按钮开始语音录入或查询</p>
<span class="empty-icon">🎤</span>
<div class="empty-title">语音记账助手</div>
<div class="empty-desc">按住下方按钮说话<br>录入事项或查询记录</div>
</div>
<!-- 查询结果卡片 -->
<div class="result-card" id="resultCard"></div>
<!-- 聊天回复 -->
<div class="chat-reply" id="chatReply"></div>
</div>
<!-- 麦克风按钮 -->
<div class="mic-container">
<button class="mic-btn" id="micBtn" title="按住录音">
<i class="bi bi-mic-fill"></i>
</button>
<div class="mic-hint">按住说话,松开发送</div>
<div class="mic-bottom-bar">
<div class="mic-container">
<button class="mic-btn" id="micBtn" title="按住录音">
<i class="bi bi-mic-fill"></i>
</button>
</div>
<div class="mic-status-row">
<div class="mic-hint" id="micHint">按住说话,松开发送</div>
<div class="processing-indicator" id="processing">
<span class="processing-dot"></span><span class="processing-dot"></span><span class="processing-dot"></span>
<span class="processing-text">处理中</span>
</div>
</div>
</div>
<script th:inline="javascript">
// ==================== 麦克风权限预检 ====================
(async function checkMicPermission() {
try {
// 先检查当前权限状态
if (navigator.permissions) {
const status = await navigator.permissions.query({ name: 'microphone' });
if (status.state === 'granted') return; // 已有权限
if (status.state === 'denied') {
showNotification('麦克风权限已被拒绝,请在系统设置中开启', true);
return;
}
}
// 请求权限
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach(t => t.stop());
} catch (err) {
console.log('麦克风权限预检:', err.message);
// 不强制弹通知,用户使用时还会再次触发权限请求
}
})();
// ==================== WAV 录音器 ====================
class WavRecorder {
constructor() {
this.audioContext = null;
this.stream = null;
this.processor = null;
this.chunks = [];
this.sampleRate = 16000;
this.recording = false;
this.audioContext = null; this.stream = null; this.processor = null;
this.chunks = []; this.sampleRate = 16000; this.recording = false;
}
async start() {
this.stream = await navigator.mediaDevices.getUserMedia({
audio: { sampleRate: this.sampleRate, channelCount: 1, echoCancellation: true }
});
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: this.sampleRate
audio: { sampleRate: this.sampleRate, channelCount: 1, echoCancellation: true, noiseSuppression: true }
});
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: this.sampleRate });
const source = this.audioContext.createMediaStreamSource(this.stream);
// 使用 ScriptProcessorNode 采集 PCM
this.processor = this.audioContext.createScriptProcessor(4096, 1, 1);
this.chunks = [];
this.processor.onaudioprocess = (e) => {
if (this.recording) {
const input = e.inputBuffer.getChannelData(0);
this.chunks.push(new Float32Array(input));
}
if (this.recording) this.chunks.push(new Float32Array(e.inputBuffer.getChannelData(0)));
};
source.connect(this.processor);
this.processor.connect(this.audioContext.destination);
this.recording = true;
}
stop() {
this.recording = false;
return new Promise((resolve) => {
// 等一小段时间让最后的数据进来
setTimeout(() => {
// 断开连接
if (this.processor) {
this.processor.disconnect();
this.processor = null;
}
if (this.audioContext) {
this.audioContext.close();
this.audioContext = null;
}
if (this.stream) {
this.stream.getTracks().forEach(t => t.stop());
this.stream = null;
}
// 合并所有 chunk
if (this.processor) { this.processor.disconnect(); this.processor = null; }
if (this.audioContext) { this.audioContext.close(); this.audioContext = null; }
if (this.stream) { this.stream.getTracks().forEach(t => t.stop()); this.stream = null; }
const totalLength = this.chunks.reduce((sum, c) => sum + c.length, 0);
const pcm = new Float32Array(totalLength);
let offset = 0;
for (const chunk of this.chunks) {
pcm.set(chunk, offset);
offset += chunk.length;
}
// 编码为 WAV
const wavBlob = this.encodeWAV(pcm, this.sampleRate);
resolve(wavBlob);
for (const chunk of this.chunks) { pcm.set(chunk, offset); offset += chunk.length; }
resolve(this.encodeWAV(pcm, this.sampleRate));
}, 100);
});
}
encodeWAV(samples, sampleRate) {
const buffer = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buffer);
// RIFF header
this.writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + samples.length * 2, true);
this.writeString(view, 8, 'WAVE');
this.writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true); // PCM
view.setUint16(22, 1, true); // mono
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
this.writeString(view, 36, 'data');
view.setUint32(40, samples.length * 2, true);
// PCM samples
writeString(view, 0, 'RIFF'); view.setUint32(4, 36 + samples.length * 2, true);
writeString(view, 8, 'WAVE'); writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true); view.setUint16(34, 16, true);
writeString(view, 36, 'data'); view.setUint32(40, samples.length * 2, true);
for (let i = 0; i < samples.length; i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
view.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
return new Blob([buffer], { type: 'audio/wav' });
}
writeString(view, offset, string) {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
}
function writeString(view, offset, string) {
for (let i = 0; i < string.length; i++) view.setUint8(offset + i, string.charCodeAt(i));
}
// ==================== 录音状态管理 ====================
// ==================== DOM 引用 ====================
const micBtn = document.getElementById('micBtn');
const loading = document.getElementById('loading');
const micHint = document.getElementById('micHint');
const processing = document.getElementById('processing');
const resultCard = document.getElementById('resultCard');
const chatReply = document.getElementById('chatReply');
const emptyState = document.getElementById('emptyState');
const notification = document.getElementById('notification');
let recorder = null;
let isPressed = false;
let recorder = null, isPressed = false, activePointerId = null;
// 按下开始录音
micBtn.addEventListener('mousedown', async (e) => {
function getPointerId(e) {
if (e.touches && e.touches.length > 0) return 't' + e.touches[0].identifier;
if (e.changedTouches && e.changedTouches.length > 0) return 't' + e.changedTouches[0].identifier;
return 'mouse';
}
async function startRecording(e) {
e.preventDefault();
if (isPressed) return;
isPressed = true;
isPressed = true; activePointerId = getPointerId(e);
try {
recorder = new WavRecorder();
await recorder.start();
micBtn.classList.add('recording');
micBtn.querySelector('i').className = 'bi bi-mic-fill'; // keep icon
micHint.textContent = '正在录音...';
micHint.classList.add('recording-hint');
hapticFeedback('light');
} catch (err) {
console.error('无法启动录音:', err);
showNotification('无法访问麦克风,请检查权限', true);
isPressed = false;
resetRecordingState();
}
});
}
// 松开停止录音并发送
micBtn.addEventListener('mouseup', async (e) => {
async function stopRecording(e) {
e.preventDefault();
if (!isPressed || !recorder) return;
isPressed = false;
const currentId = getPointerId(e);
if (activePointerId && currentId !== activePointerId && currentId !== 'mouse') return;
isPressed = false; activePointerId = null;
micBtn.classList.remove('recording');
micBtn.querySelector('i').className = 'bi bi-mic-fill';
micHint.classList.remove('recording-hint');
micHint.textContent = '按住说话,松开发送';
try {
loading.classList.add('show');
const wavBlob = await recorder.stop();
recorder = null;
if (wavBlob.size < 100) {
// 录音太短
loading.classList.remove('show');
if (wavBlob.size < 200) {
showNotification('录音时间太短,请重试', true);
hapticFeedback('warning');
return;
}
// 显示底部处理中指示器(不阻塞操作)
processing.classList.add('show');
micHint.style.display = 'none';
await sendAudio(wavBlob);
} catch (err) {
console.error('发送失败:', err);
showNotification('处理失败,请重试', true);
} finally {
loading.classList.remove('show');
processing.classList.remove('show');
micHint.style.display = '';
}
});
// 触摸事件(移动端)
micBtn.addEventListener('touchstart', async (e) => {
e.preventDefault();
if (isPressed) return;
isPressed = true;
try {
recorder = new WavRecorder();
await recorder.start();
micBtn.classList.add('recording');
} catch (err) {
console.error('无法启动录音:', err);
showNotification('无法访问麦克风,请检查权限', true);
isPressed = false;
}
});
micBtn.addEventListener('touchend', async (e) => {
e.preventDefault();
if (!isPressed || !recorder) return;
isPressed = false;
}
function resetRecordingState() {
isPressed = false; activePointerId = null;
micBtn.classList.remove('recording');
micHint.classList.remove('recording-hint');
micHint.textContent = '按住说话,松开发送';
processing.classList.remove('show');
micHint.style.display = '';
if (recorder) { recorder.stop().catch(() => {}); recorder = null; }
}
try {
loading.classList.add('show');
const wavBlob = await recorder.stop();
recorder = null;
if (wavBlob.size < 100) {
loading.classList.remove('show');
showNotification('录音时间太短,请重试', true);
return;
}
await sendAudio(wavBlob);
} catch (err) {
console.error('发送失败:', err);
showNotification('处理失败,请重试', true);
} finally {
loading.classList.remove('show');
}
micBtn.addEventListener('mousedown', startRecording);
micBtn.addEventListener('mouseup', stopRecording);
micBtn.addEventListener('mouseleave', (e) => { if (isPressed) stopRecording(e); });
micBtn.addEventListener('touchstart', startRecording, { passive: false });
micBtn.addEventListener('touchend', stopRecording);
micBtn.addEventListener('touchcancel', () => resetRecordingState());
document.addEventListener('mouseup', (e) => {
if (isPressed && activePointerId === 'mouse' && e.target !== micBtn) stopRecording(e);
});
document.addEventListener('touchend', (e) => {
if (isPressed && recorder && e.target !== micBtn) stopRecording(e);
});
// ==================== 发送音频 ====================
async function sendAudio(audioBlob) {
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.wav');
try {
const response = await fetch('/api/voice/process', {
method: 'POST',
body: formData
});
if (response.status === 401) {
window.location.href = '/login';
return;
}
const result = await response.json();
displayResult(result);
const response = await fetch('/api/voice/process', { method: 'POST', body: formData });
if (response.status === 401) { window.location.href = '/login'; return; }
displayResult(await response.json());
} catch (error) {
console.error('请求失败:', error);
showNotification('网络错误,请重试', true);
}
}
// ==================== 展示结果 ====================
// ==================== 结果展示 ====================
function displayResult(result) {
hideAll();
if (!result.success) {
showNotification(result.error || '处理失败', true);
return;
}
if (!result.success) { showNotification(result.error || '处理失败', true); return; }
switch (result.type) {
case 'RECORD':
// 录入成功:显示通知
showNotification('已录入「' + (result.category || '未知') + '」事项', false);
break;
showNotification('已录入「' + (result.category || '未知') + '」', false);
hapticFeedback('success'); break;
case 'QUERY':
// 查询结果:渲染 markdown 表格
emptyState.style.display = 'none';
resultCard.classList.add('visible');
resultCard.innerHTML = renderMarkdown(result.message);
// 滚动到结果区
resultCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
break;
resultCard.innerHTML = buildQueryHtml(result);
resultCard.scrollIntoView({ behavior: 'smooth', block: 'start' }); break;
case 'CHAT':
// 聊天回复
emptyState.style.display = 'none';
chatReply.classList.add('visible');
chatReply.innerHTML = '<p>' + escapeHtml(result.message) + '</p>';
chatReply.scrollIntoView({ behavior: 'smooth', block: 'start' });
break;
chatReply.scrollIntoView({ behavior: 'smooth', block: 'start' }); break;
}
}
function hideAll() {
resultCard.classList.remove('visible');
chatReply.classList.remove('visible');
resultCard.classList.remove('visible'); resultCard.innerHTML = '';
chatReply.classList.remove('visible'); chatReply.innerHTML = '';
}
// ==================== 简易 Markdown 渲染 ====================
function renderMarkdown(md) {
if (!md) return '';
// ==================== 分类样式映射 ====================
const CAT_STYLES = {
'吃饭': { e:'🍜', c:'#f97316', b:'#fff7ed' },
'购物': { e:'🛒', c:'#3b82f6', b:'#eff6ff' },
'加油': { e:'⛽', c:'#22c55e', b:'#f0fdf4' },
'修车': { e:'🔧', c:'#22c55e', b:'#f0fdf4' },
'交通': { e:'🚗', c:'#22c55e', b:'#f0fdf4' },
'出行': { e:'🚕', c:'#22c55e', b:'#f0fdf4' },
'租房': { e:'🏠', c:'#8b5cf6', b:'#f5f3ff' },
'房租': { e:'🏠', c:'#8b5cf6', b:'#f5f3ff' },
'水电': { e:'💡', c:'#06b6d4', b:'#ecfeff' },
'话费': { e:'📱', c:'#06b6d4', b:'#ecfeff' },
'旅游': { e:'✈️', c:'#ec4899', b:'#fdf2f8' },
'娱乐': { e:'🎮', c:'#ec4899', b:'#fdf2f8' },
'医疗': { e:'💊', c:'#ef4444', b:'#fef2f2' },
'药品': { e:'💊', c:'#ef4444', b:'#fef2f2' },
'教育': { e:'📚', c:'#6366f1', b:'#eef2ff' },
'学习': { e:'📚', c:'#6366f1', b:'#eef2ff' },
'剪头': { e:'💇', c:'#a855f7', b:'#faf5ff' },
'理发': { e:'💇', c:'#a855f7', b:'#faf5ff' },
'衣服': { e:'👗', c:'#ec4899', b:'#fdf2f8' },
'水果': { e:'🍎', c:'#f97316', b:'#fff7ed' },
'零食': { e:'🍿', c:'#f97316', b:'#fff7ed' },
'饮料': { e:'🧃', c:'#06b6d4', b:'#ecfeff' },
'运动': { e:'⚽', c:'#22c55e', b:'#f0fdf4' },
'宠物': { e:'🐱', c:'#f97316', b:'#fff7ed' },
'快递': { e:'📦', c:'#64748b', b:'#f8fafc' },
'日用': { e:'🧴', c:'#64748b', b:'#f8fafc' },
};
const CAT_DEFAULT = { e:'📌', c:'#94a3b8', b:'#f8fafc' };
let html = md;
function catStyle(category) {
if (category && CAT_STYLES[category]) return CAT_STYLES[category];
return CAT_DEFAULT;
}
// 提取金额汇总行
html = html.replace(/^(.*?金额.*?[:]?\s*[\d,.]+.*)$/gm,
'<div class="amount-summary">$1</div>');
// ==================== 构建卡片列表 ====================
function buildQueryHtml(result) {
let html = '';
const records = result.records;
// 表格
html = html.replace(/\|(.+)\|/g, (match) => {
const cells = match.split('|').filter(c => c.trim() !== '');
const isHeader = match.includes('---');
if (isHeader) return '';
const tag = match === html.split('\n').find(l => l.includes('|') && !l.includes('---')) ? 'th' : 'td';
return '<tr>' + cells.map(c => '<' + tag + '>' + c.trim() + '</' + tag + '>').join('') + '</tr>';
});
if (records && records.length > 0) {
html += '<div class="records-list">';
records.forEach(row => {
const time = field(row, 'record_time', 'recordTime');
const person = field(row, 'person');
const loc = field(row, 'location');
const cont = field(row, 'content');
const amount = field(row, 'amount');
const cat = field(row, 'category');
const cs = catStyle(cat);
// 包裹表格
html = html.replace(/(<tr>[\s\S]*?<\/tr>)/g, (match) => {
if (!match.includes('<table>')) {
return '<table class="table table-striped">' + match + '</table>';
}
return match;
});
const meta = [person, loc].filter(Boolean).map(escapeHtml).join(' · ');
// 金额汇总(如果未匹配到)
html = html.replace(/(?:合计|总计|金额汇总|总金额)[:]?\s*([\d,.]+)\s*元?/g,
'<div class="amount-summary">合计:$1 元</div>');
// 换行转 <br>
html = html.replace(/\n/g, '<br>');
html += '<div class="record-card" style="border-left-color:' + cs.c + ';background:' + cs.b + ';">' +
'<div class="card-top">' +
'<span class="card-time"><span class="cat-emoji">' + cs.e + '</span>' + escapeHtml(time || '') + '</span>' +
'<span class="card-amount">¥' + formatAmount(amount) + '</span>' +
'</div>' +
'<div class="card-detail">' +
'<span>' + escapeHtml(cont || '') + '</span>' +
(meta ? '<span class="meta-tag">' + meta + '</span>' : '') +
'</div>' +
'</div>';
});
html += '</div>';
}
html += '<div class="summary-footer">' + escapeHtml(result.message || '查询完成') + '</div>';
return html;
}
// ==================== 通知 ====================
let notifyTimer = null;
function showNotification(message, isError) {
if (notifyTimer) clearTimeout(notifyTimer);
notification.textContent = message;
notification.className = 'notification' + (isError ? ' error' : '');
// 强制回流
notification.offsetHeight;
notification.classList.add('show');
notifyTimer = setTimeout(() => {
notification.classList.remove('show');
}, 3000);
function field(row, ...keys) {
for (const k of keys) {
if (row[k] != null) return row[k];
}
return null;
}
function formatAmount(val) {
if (val == null) return '0.00';
const n = Number(val);
return isNaN(n) ? '0.00' : n.toFixed(2);
}
function hapticFeedback(style) {
if (navigator.vibrate) {
const patterns = { light: 10, success: [10, 50, 10, 50, 10], warning: [30, 80, 30] };
navigator.vibrate(Array.isArray(patterns[style]) ? patterns[style] : [patterns[style] || 10]);
}
}
let notifyTimer = null;
function showNotification(message, isError) {
if (notifyTimer) clearTimeout(notifyTimer);
notification.textContent = message;
notification.className = 'notification' + (isError ? ' error' : '');
void notification.offsetWidth;
notification.classList.add('show');
notifyTimer = setTimeout(() => notification.classList.remove('show'), 3000);
}
// ==================== 工具函数 ====================
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;

View File

@ -2,47 +2,163 @@
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<title>登录 - TraceCD</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
:root {
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--color-primary: #6366f1;
--color-bg: #f1f5f9;
--color-surface: #ffffff;
--color-text: #1e293b;
--color-text-secondary: #64748b;
--color-text-muted: #94a3b8;
--color-border: #e2e8f0;
--radius-lg: 24px;
--radius-sm: 12px;
--font-sm: 15px;
--font-base: 17px;
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
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;
background: var(--color-bg);
min-height: 100vh;
min-height: 100dvh;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
-webkit-font-smoothing: antialiased;
}
/* 顶部装饰 */
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
height: 40vh;
background: linear-gradient(180deg, #eef2ff 0%, #e0e7ff 40%, var(--color-bg) 100%);
z-index: -1;
}
.login-card {
background: white;
border-radius: 16px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
background: var(--color-surface);
border-radius: var(--radius-lg);
padding: 36px 24px 32px;
box-shadow: 0 4px 24px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04);
width: 100%;
max-width: 400px;
max-width: 380px;
animation: cardIn 0.5s ease;
}
@keyframes cardIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.login-card .app-icon {
text-align: center;
font-size: 48px;
margin-bottom: 8px;
}
.login-card h1 {
font-size: 24px;
font-size: 22px;
font-weight: 700;
text-align: center;
margin-bottom: 30px;
color: #333;
margin-bottom: 4px;
color: var(--color-text);
letter-spacing: -0.3px;
}
.login-card .subtitle {
text-align: center;
font-size: var(--font-sm);
color: var(--color-text-muted);
margin-bottom: 28px;
}
.login-card label {
font-size: var(--font-sm);
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: 6px;
display: block;
}
.login-card .form-control {
border: 1.5px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: var(--font-base);
padding: 12px 16px;
transition: border-color 0.2s, box-shadow 0.2s;
background: #f8fafc;
}
.login-card .form-control:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.1);
background: white;
}
.login-card .mb-3 { margin-bottom: 18px; }
.login-card .btn-login {
width: 100%;
background: var(--color-primary);
color: white;
border: none;
padding: 14px;
border-radius: var(--radius-sm);
font-size: var(--font-base);
font-weight: 700;
transition: background 0.2s, transform 0.1s;
cursor: pointer;
letter-spacing: 0.3px;
}
.login-card .btn-login:active {
background: #4f46e5;
transform: scale(0.98);
}
.login-card .alert {
border-radius: var(--radius-sm);
font-size: var(--font-sm);
padding: 12px 16px;
margin-bottom: 20px;
border: none;
}
.login-card .alert-danger {
background: #fef2f2;
color: #dc2626;
}
</style>
</head>
<body>
<div class="login-card">
<h1>TraceCD 日常记录</h1>
<div class="app-icon">📝</div>
<h1>TraceCD</h1>
<p class="subtitle">语音智能记账助手</p>
<div th:if="${error}" class="alert alert-danger" th:text="${error}"></div>
<form method="post" action="/login">
<form method="post" action="/login" autocomplete="off">
<div class="mb-3">
<label for="username" class="form-label">用户名</label>
<input type="text" class="form-control" id="username" name="username" required autofocus>
<label for="username">用户名</label>
<input type="text" class="form-control" id="username" name="username"
placeholder="请输入用户名" required autofocus autocomplete="username">
</div>
<div class="mb-3">
<label for="password" class="form-label">密码</label>
<input type="password" class="form-control" id="password" name="password" required>
<div class="mb-3" style="margin-bottom:24px;">
<label for="password">密码</label>
<input type="password" class="form-control" id="password" name="password"
placeholder="请输入密码" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary w-100">登 录</button>
<button type="submit" class="btn-login">登 录</button>
</form>
</div>
</body>