优化页面
All checks were successful
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Successful in 1m22s
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:
parent
0476b72624
commit
172c2a098f
@ -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; }
|
||||
}
|
||||
|
||||
@ -139,12 +139,7 @@ public class LlmService {
|
||||
|
||||
## 查询意图处理
|
||||
当用户意图是查询时,**必须调用 query_daily_records 函数**执行 SQL 查询。
|
||||
- 人物、日期、内容、金额相关字段查询
|
||||
- 查询完成后根据返回数据生成回复:
|
||||
* 多条数据用 markdown 表格展示,表格上方展示金额汇总
|
||||
* 一条数据直接描述
|
||||
* 无数据告知用户未找到
|
||||
* 回复要友好自然
|
||||
- 查询结果的总结和格式化由后续系统自动处理,你只需调用函数即可
|
||||
|
||||
## 聊天意图处理
|
||||
当用户输入与记账无关时,直接友好回复。
|
||||
|
||||
@ -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": "没有找到匹配的记录哦"}
|
||||
""";
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user