diff --git a/.gitignore b/.gitignore
index 8450f40..aa6bad2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
python/build/
.vscode/
+go/frontend/package-lock.json
diff --git a/go/README.md b/go/README.md
index 68d7804..707cfed 100644
--- a/go/README.md
+++ b/go/README.md
@@ -1,6 +1,6 @@
# Excel 关键字搜索工具(Go 版)
-跨 Excel 文件批量关键字检索工具,纯 Go 实现,编译为独立可执行文件,无需 Python/Pandas 运行时。支持 `.xlsx`、`.txt`、`.csv` 三种格式的并发扫描,内置文件级缓存加速重复搜索。
+跨 Excel / Word / CSV / 文本 文件批量关键字检索工具,纯 Go 实现,编译为独立可执行文件,无需 Python/Pandas 运行时。支持 OOXML 格式(`.xlsx/.xlsm/.xltx/.xltm`、`.docx/.docm`)与 `.csv`、`.txt` 的并发扫描,内置文件级缓存加速重复搜索,并提供区分大小写、完整单词、子目录递归、文件类型过滤等搜索选项。
- 模块名:`excel-search`(单 Go module)
- Go 版本要求:≥ 1.22(开发机实测 1.27.1)
@@ -52,9 +52,12 @@ go/
│ ├── model/
│ │ └── result.go # 数据模型:SearchResult 结构体(含 JSON tag)
│ └── search/
-│ ├── scanner.go # 搜索引擎核心:gtoroutine worker pool + 全链路 context 取消
-│ │ # + 文件级缓存(命中条件 ModTime+Size)
-│ └── scanner_test.go # 单元测试:真实临时 xlsx、取消、无匹配、不存在目录
+│ ├── scanner.go # 搜索引擎核心:worker pool + 全链路 context 取消 + 文件级缓存
+│ ├── options.go # 搜索选项模型:SearchOptions + FileType + 扩展名分类
+│ ├── matcher.go # 匹配器:Unicode NFC 规范化 + 大小写折叠 + 完整单词边界
+│ ├── word.go # Word 解析:.docx/.docm 的 word/document.xml 段落提取
+│ ├── scanner_test.go # 单元测试:大小写/完整单词/文件类型/递归/docx/缓存隔离
+│ └── matcher_test.go # 匹配器单元测试
│
└── frontend/ # 前端构建工具链(仅在需要修改样式时使用)
├── package.json # npm 依赖声明(devDependency: tailwindcss ^3.4)
@@ -231,12 +234,24 @@ golangci-lint run ./...
### CLI 版(`excel-searcher.exe`)
```bash
-# 基本用法
+# 基本用法(默认:不区分大小写 + 完整单词 + 递归子目录 + 全部文件类型)
excel-searcher -dir <目录> -keyword <关键字>
# 输出 JSON(便于脚本处理)
excel-searcher -dir ./data -keyword 关键词 -json
+# 区分大小写(默认不区分)
+excel-searcher -dir ./data -keyword Phone -case-sensitive
+
+# 模糊匹配(关闭完整单词,默认开启完整单词)
+excel-searcher -dir ./data -keyword 手机 -whole-word=false
+
+# 仅搜索根目录,不递归子目录(默认递归)
+excel-searcher -dir ./data -keyword 关键词 -include-subdirectories=false
+
+# 按文件类型过滤:all | excel | word | csv
+excel-searcher -dir ./data -keyword 关键词 -file-type excel
+
# 指定并发 worker 数
excel-searcher -dir ./data -keyword 关键词 -workers 16
@@ -244,6 +259,17 @@ excel-searcher -dir ./data -keyword 关键词 -workers 16
excel-searcher -version
```
+### 搜索选项说明
+
+| 选项 | 默认值 | 含义 |
+|---|---|---|
+| 区分大小写 `-case-sensitive` | 不勾选 | 勾选后按大小写精确匹配;不勾选按 Unicode 大小写折叠匹配 |
+| 完整单词 `-whole-word` | 勾选 | 勾选后要求关键字前后均为单词边界(非字母/数字/下划线);不勾选为模糊子串匹配 |
+| 包含子目录 `-include-subdirectories` | 勾选 | 勾选后递归扫描子目录;不勾选仅扫描根目录 |
+| 文件类型 `-file-type` | all | `all`(全部支持格式)/ `excel` / `word` / `csv` |
+
+> 完整单词边界说明:边界字符为字母、数字、Unicode 组合标记与下划线。中文/日文汉字属于字母,因此如搜索「手机」,默认完整单词模式**不会**匹配「苹果手机壳」这类内部子串;需要模糊匹配时取消勾选完整单词即可。WebUI/桌面版的 HTTP 请求中,这些选项通过 `options` 对象传递(字段名与上文相同,布尔字段可显式传 `false`)。
+
---
## 五、配置项(环境变量)
diff --git a/go/cmd/desktop/main.go b/go/cmd/desktop/main.go
index 5892240..9125465 100644
--- a/go/cmd/desktop/main.go
+++ b/go/cmd/desktop/main.go
@@ -32,8 +32,40 @@ var styleCSS []byte
var version = "dev"
type SearchRequest struct {
- Dir string `json:"dir"`
- Keyword string `json:"keyword"`
+ Dir string `json:"dir"`
+ Keyword string `json:"keyword"`
+ Options SearchOptionsRequest `json:"options"`
+}
+
+// SearchOptionsRequest 是 HTTP 传输层选项体。
+// 布尔字段用指针,以区分“未提供(使用默认值)”与“显式 false”。
+type SearchOptionsRequest struct {
+ CaseSensitive *bool `json:"case_sensitive"`
+ WholeWord *bool `json:"whole_word"`
+ IncludeSubdirectories *bool `json:"include_subdirectories"`
+ FileType *string `json:"file_type"`
+}
+
+// resolveSearchOptions 将请求选项合并到默认值上,并对 file_type 做合法性校验。
+func resolveSearchOptions(so SearchOptionsRequest) (search.SearchOptions, error) {
+ opts := search.DefaultSearchOptions()
+ if so.CaseSensitive != nil {
+ opts.CaseSensitive = *so.CaseSensitive
+ }
+ if so.WholeWord != nil {
+ opts.WholeWord = *so.WholeWord
+ }
+ if so.IncludeSubdirectories != nil {
+ opts.IncludeSubdirectories = *so.IncludeSubdirectories
+ }
+ if so.FileType != nil {
+ ft := search.FileType(*so.FileType)
+ if !ft.Valid() {
+ return opts, fmt.Errorf("invalid file_type %q (allowed: all, excel, word, csv)", *so.FileType)
+ }
+ opts.FileType = ft
+ }
+ return opts, nil
}
type SearchResponse struct {
@@ -199,8 +231,17 @@ func main() {
req.Dir = "."
}
+ opts, err := resolveSearchOptions(req.Options)
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(SearchResponse{Error: err.Error()})
+ return
+ }
+
reqID := logging.NewRequestID()
- slog.Info("search request received", "request_id", reqID, "dir", req.Dir, "keyword", req.Keyword)
+ slog.Info("search request received", "request_id", reqID, "dir", req.Dir, "keyword", req.Keyword,
+ "options", opts)
history.mu.Lock()
addHistoryItem(&history.Dirs, req.Dir, 10)
@@ -211,7 +252,7 @@ func main() {
searchCtx, cancel := context.WithTimeout(r.Context(), cfg.SearchTimeout)
defer cancel()
- results, err := search.SearchDirectory(searchCtx, req.Dir, req.Keyword, cfg.Workers)
+ results, err := search.SearchDirectory(searchCtx, req.Dir, req.Keyword, cfg.Workers, opts)
if err != nil {
slog.Error("search error", "request_id", reqID, "err", err)
msg := err.Error()
@@ -396,13 +437,20 @@ const htmlUI = `
right: 0;
top: 0;
bottom: 0;
- width: 5px;
+ width: 8px;
cursor: col-resize;
user-select: none;
touch-action: none;
+ opacity: 0;
+ transition: opacity 0.15s ease;
}
- .resizer:hover, .resizer.resizing {
- background-color: #3b82f6;
+ .resizable-th:hover .resizer {
+ opacity: 1;
+ background: linear-gradient(to right, transparent 5px, #3b82f6 5px, #3b82f6 8px, transparent 8px);
+ }
+ .resizer.resizing {
+ opacity: 1;
+ background: linear-gradient(to right, transparent 5px, #2563eb 5px, #2563eb 8px, transparent 8px);
}
.highlight {
background-color: #fde047;
@@ -466,8 +514,11 @@ const htmlUI = `
-
-
+
+
+ (不输入默认当前文件夹)
+
+
@@ -482,6 +533,32 @@ const htmlUI = `
+
+
+
+
+
+
+
+
正在搜索中...
@@ -496,11 +573,11 @@ const htmlUI = `
- | 序号 |
- 文件路径 |
- 类型 |
- Sheet / 位置 |
- 匹配内容单元格 |
+ 序号 |
+ 文件路径 |
+ 类型 |
+ Sheet / 位置 |
+ 匹配内容单元格 |
@@ -567,6 +644,10 @@ const htmlUI = `
search_done: '搜索完成!耗时 {duration} 秒,共找到 {count} 个匹配项 (已启用本地缓存加速)。',
no_result: '未找到匹配的结果',
error_prefix: '错误: ',
+ th_index: '序号',
+ dir_hint: '(不输入默认当前文件夹)',
+ dir_placeholder: '不输入默认当前文件夹',
+ key_placeholder: '输入要查找的关键字...',
alert_key: '请输入搜索关键字',
pageSize_label: '每页显示:',
pageInfo: '共 {total} 条 / {pages} 页',
@@ -575,7 +656,16 @@ const htmlUI = `
nextBtn: '下一页',
lastBtn: '末页',
health_ok: '服务正常',
- health_err: '启动失败'
+ health_err: '启动失败',
+ opt_case: '区分大小写',
+ opt_word: '完整单词匹配',
+ opt_word_hint: '(不勾选则为模糊匹配)',
+ opt_subdir: '包含子目录',
+ opt_type: '文件类型:',
+ type_all: '所有文件',
+ type_excel: 'Excel 文件',
+ type_word: 'Word 文件',
+ type_csv: 'CSV 文件'
},
'zh-TW': {
title: '📊 Excel 關鍵字智能搜尋工具',
@@ -597,6 +687,10 @@ const htmlUI = `
search_done: '搜尋完成!耗時 {duration} 秒,共找到 {count} 個匹配項 (已啟用本地快取加速)。',
no_result: '未找到匹配的結果',
error_prefix: '錯誤: ',
+ th_index: '序號',
+ dir_hint: '(不輸入預設當前資料夾)',
+ dir_placeholder: '不輸入預設當前資料夾',
+ key_placeholder: '輸入要查找的關鍵字...',
alert_key: '請輸入搜尋關鍵字',
pageSize_label: '每頁顯示:',
pageInfo: '共 {total} 條 / {pages} 頁',
@@ -605,7 +699,16 @@ const htmlUI = `
nextBtn: '下一頁',
lastBtn: '末頁',
health_ok: '服務正常',
- health_err: '服務連線失敗'
+ health_err: '啓動失敗',
+ opt_case: '區分大小寫',
+ opt_word: '完整單字比對',
+ opt_word_hint: '(不勾選則為模糊比對)',
+ opt_subdir: '包含子目錄',
+ opt_type: '檔案類型:',
+ type_all: '所有檔案',
+ type_excel: 'Excel 檔案',
+ type_word: 'Word 檔案',
+ type_csv: 'CSV 檔案'
},
'en': {
title: '📊 Excel Keyword Smart Search Tool',
@@ -627,6 +730,10 @@ const htmlUI = `
search_done: 'Search completed in {duration}s, found {count} matches (cached).',
no_result: 'No matching results found',
error_prefix: 'Error: ',
+ th_index: 'No.',
+ dir_hint: '(leave blank for current folder)',
+ dir_placeholder: 'Leave blank for current folder',
+ key_placeholder: 'Enter a keyword to search...',
alert_key: 'Please enter a search keyword',
pageSize_label: 'Per Page:',
pageInfo: 'Total {total} / {pages} Pages',
@@ -635,7 +742,16 @@ const htmlUI = `
nextBtn: 'Next',
lastBtn: 'Last',
health_ok: 'Connected',
- health_err: 'Connection Failed'
+ health_err: 'Connection Failed',
+ opt_case: 'Case sensitive',
+ opt_word: 'Whole word',
+ opt_word_hint: '(uncheck for fuzzy match)',
+ opt_subdir: 'Include subdirectories',
+ opt_type: 'File type:',
+ type_all: 'All files',
+ type_excel: 'Excel files',
+ type_word: 'Word files',
+ type_csv: 'CSV files'
},
'ja': {
title: '📊 Excel キーワードスマート検索ツール',
@@ -657,6 +773,10 @@ const htmlUI = `
search_done: '検索完了! {duration} 秒、{count} 件ヒットしました (キャッシュ済)。',
no_result: '一致する結果が見つかりませんでした',
error_prefix: 'エラー: ',
+ th_index: '番号',
+ dir_hint: '(未入力の場合は現在のフォルダ)',
+ dir_placeholder: '未入力の場合は現在のフォルダ',
+ key_placeholder: '検索キーワードを入力...',
alert_key: '検索キーワードを入力してください',
pageSize_label: '表示件数:',
pageInfo: '全 {total} 件 / {pages} ページ',
@@ -665,7 +785,16 @@ const htmlUI = `
nextBtn: '次へ',
lastBtn: '最後',
health_ok: '接続正常',
- health_err: '接続失敗'
+ health_err: '接続失敗',
+ opt_case: '大文字と小文字を区別',
+ opt_word: '単語全体で一致',
+ opt_word_hint: '(未選択はあいまい検索)',
+ opt_subdir: 'サブディレクトリを含む',
+ opt_type: 'ファイルタイプ:',
+ type_all: 'すべてのファイル',
+ type_excel: 'Excel ファイル',
+ type_word: 'Word ファイル',
+ type_csv: 'CSV ファイル'
}
};
@@ -791,13 +920,17 @@ const htmlUI = `
const t = i18n[lang];
document.getElementById('i18n_title').textContent = t.title;
document.getElementById('i18n_dir_label').textContent = t.dir_label;
+ document.getElementById('i18n_dir_hint').textContent = t.dir_hint;
+ document.getElementById('dirPath').placeholder = t.dir_placeholder;
document.getElementById('i18n_key_label').textContent = t.key_label;
+ document.getElementById('keyword').placeholder = t.key_placeholder;
document.getElementById('searchBtn').textContent = t.search_btn;
document.getElementById('i18n_result_title').textContent = t.result_title;
document.getElementById('i18n_th_path').textContent = t.th_path;
document.getElementById('i18n_th_type').textContent = t.th_type;
document.getElementById('i18n_th_sheet').textContent = t.th_sheet;
document.getElementById('i18n_th_content').textContent = t.th_content;
+ document.getElementById('i18n_th_index').textContent = t.th_index;
document.getElementById('i18n_tip').textContent = t.tip;
document.getElementById('i18n_menu_copy_name').textContent = t.menu_copy_name;
document.getElementById('i18n_menu_copy_path').textContent = t.menu_copy_path;
@@ -809,6 +942,15 @@ const htmlUI = `
document.getElementById('prevBtn').textContent = t.prevBtn;
document.getElementById('nextBtn').textContent = t.nextBtn;
document.getElementById('lastBtn').textContent = t.lastBtn;
+ document.getElementById('i18n_opt_case').textContent = t.opt_case;
+ document.getElementById('i18n_opt_word').textContent = t.opt_word;
+ document.getElementById('i18n_opt_word_hint').textContent = t.opt_word_hint;
+ document.getElementById('i18n_opt_subdir').textContent = t.opt_subdir;
+ document.getElementById('i18n_opt_type').textContent = t.opt_type;
+ document.getElementById('optFileType').options[0].text = t.type_all;
+ document.getElementById('optFileType').options[1].text = t.type_excel;
+ document.getElementById('optFileType').options[2].text = t.type_word;
+ document.getElementById('optFileType').options[3].text = t.type_csv;
checkHealth();
updatePaginationUI();
}
@@ -846,7 +988,8 @@ const htmlUI = `
if (!text || !keyword) return escapeHtml(text);
const safeText = escapeHtml(text);
const safeKeyword = escapeHtml(keyword);
- const regex = new RegExp('(' + escapeRegExp(safeKeyword) + ')', 'gi');
+ const flags = document.getElementById('optCaseSensitive').checked ? 'g' : 'gi';
+ const regex = new RegExp('(' + escapeRegExp(safeKeyword) + ')', flags);
return safeText.replace(regex, '$1');
}
@@ -976,7 +1119,16 @@ const htmlUI = `
const response = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ dir, keyword })
+ body: JSON.stringify({
+ dir,
+ keyword,
+ options: {
+ case_sensitive: document.getElementById('optCaseSensitive').checked,
+ whole_word: document.getElementById('optWholeWord').checked,
+ include_subdirectories: document.getElementById('optSubdirs').checked,
+ file_type: document.getElementById('optFileType').value
+ }
+ })
});
const data = await response.json();
const duration = ((performance.now() - startTime) / 1000).toFixed(2);
diff --git a/go/cmd/desktop/options_test.go b/go/cmd/desktop/options_test.go
new file mode 100644
index 0000000..924e23a
--- /dev/null
+++ b/go/cmd/desktop/options_test.go
@@ -0,0 +1,32 @@
+package main
+
+import (
+ "testing"
+
+ "excel-search/internal/search"
+)
+
+func bptr(b bool) *bool { return &b }
+
+func TestDesktopResolveSearchOptionsDefaults(t *testing.T) {
+ opts, err := resolveSearchOptions(SearchOptionsRequest{})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ def := search.DefaultSearchOptions()
+ if opts != def {
+ t.Fatalf("defaults mismatch: got %+v want %+v", opts, def)
+ }
+}
+
+func TestDesktopResolveSearchOptionsOverrides(t *testing.T) {
+ tVal := bptr(true)
+ ft := "word"
+ opts, err := resolveSearchOptions(SearchOptionsRequest{CaseSensitive: tVal, FileType: &ft})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !opts.CaseSensitive || opts.FileType != search.FileTypeWord {
+ t.Fatalf("overrides not applied: %+v", opts)
+ }
+}
diff --git a/go/cmd/desktop/style.css b/go/cmd/desktop/style.css
index 8fc8040..5c0efe9 100644
--- a/go/cmd/desktop/style.css
+++ b/go/cmd/desktop/style.css
@@ -1 +1 @@
-*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.top-0{top:0}.z-10{z-index:10}.z-50{z-index:50}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-8{margin-bottom:2rem}.ml-2{margin-left:.5rem}.mr-1\.5{margin-right:.375rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.box-border{box-sizing:border-box}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-2{height:.5rem}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-14{width:3.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-20{width:5rem}.w-36{width:9rem}.w-48{width:12rem}.w-full{width:100%}.max-w-7xl{max-width:80rem}.max-w-md{max-width:28rem}.max-w-xs{max-width:20rem}.flex-shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.table-fixed{table-layout:fixed}.border-collapse{border-collapse:collapse}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-gray-100>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(243 244 246/var(--tw-divide-opacity,1))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-yellow-400{--tw-bg-opacity:1;background-color:rgb(250 204 21/var(--tw-bg-opacity,1))}.bg-yellow-50{--tw-bg-opacity:1;background-color:rgb(254 252 232/var(--tw-bg-opacity,1))}.bg-yellow-50\/50{background-color:hsla(55,92%,95%,.5)}.p-0{padding:0}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.text-left{text-align:left}.text-center{text-align:center}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.tracking-wider{letter-spacing:.05em}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-yellow-600{--tw-text-opacity:1;color:rgb(202 138 4/var(--tw-text-opacity,1))}.opacity-50{opacity:.5}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.hover\:text-blue-600:hover{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}@media (min-width:768px){.md\:col-span-2{grid-column:span 2/span 2}.md\:col-span-5{grid-column:span 5/span 5}.md\:grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}}
\ No newline at end of file
+*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.top-0{top:0}.z-10{z-index:10}.z-50{z-index:50}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-8{margin-bottom:2rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.mr-1\.5{margin-right:.375rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.box-border{box-sizing:border-box}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-2{height:.5rem}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-14{width:3.5rem}.w-2{width:.5rem}.w-48{width:12rem}.w-full{width:100%}.max-w-7xl{max-width:80rem}.max-w-md{max-width:28rem}.max-w-xs{max-width:20rem}.flex-shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.table-fixed{table-layout:fixed}.border-collapse{border-collapse:collapse}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-x-5{-moz-column-gap:1.25rem;column-gap:1.25rem}.gap-y-1{row-gap:.25rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-gray-100>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(243 244 246/var(--tw-divide-opacity,1))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-yellow-400{--tw-bg-opacity:1;background-color:rgb(250 204 21/var(--tw-bg-opacity,1))}.bg-yellow-50{--tw-bg-opacity:1;background-color:rgb(254 252 232/var(--tw-bg-opacity,1))}.bg-yellow-50\/50{background-color:hsla(55,92%,95%,.5)}.p-0{padding:0}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.text-left{text-align:left}.text-center{text-align:center}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.tracking-wider{letter-spacing:.05em}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-yellow-600{--tw-text-opacity:1;color:rgb(202 138 4/var(--tw-text-opacity,1))}.opacity-50{opacity:.5}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.hover\:text-blue-600:hover{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}@media (min-width:768px){.md\:col-span-2{grid-column:span 2/span 2}.md\:col-span-5{grid-column:span 5/span 5}.md\:grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}}
\ No newline at end of file
diff --git a/go/cmd/searcher/main.go b/go/cmd/searcher/main.go
index 69f9317..7d7d4a3 100644
--- a/go/cmd/searcher/main.go
+++ b/go/cmd/searcher/main.go
@@ -27,6 +27,10 @@ func main() {
keywordPtr := flag.String("keyword", "", "Keyword to search for")
jsonPtr := flag.Bool("json", false, "Output results in JSON format")
workersPtr := flag.Int("workers", cfg.Workers, "Number of concurrent workers")
+ casePtr := flag.Bool("case-sensitive", false, "Match case sensitively (default: case-insensitive)")
+ wordPtr := flag.Bool("whole-word", true, "Match whole words only (disable for fuzzy matching)")
+ subdirPtr := flag.Bool("include-subdirectories", true, "Scan subdirectories recursively")
+ fileTypePtr := flag.String("file-type", string(search.FileTypeAll), "File type filter: all, excel, word, csv")
versionPtr := flag.Bool("version", false, "Print version and exit")
flag.Parse()
@@ -42,17 +46,30 @@ func main() {
} else {
fmt.Println("Usage: excel-searcher -dir -keyword [-json] [-workers N]")
fmt.Println(" or: excel-searcher ")
+ fmt.Println("Options: -case-sensitive -whole-word -include-subdirectories -file-type (all|excel|word|csv)")
os.Exit(1)
}
}
+ opts := search.DefaultSearchOptions()
+ opts.CaseSensitive = *casePtr
+ opts.WholeWord = *wordPtr
+ opts.IncludeSubdirectories = *subdirPtr
+ ft := search.FileType(*fileTypePtr)
+ if !ft.Valid() {
+ fmt.Fprintf(os.Stderr, "[Error] invalid file-type %q (allowed: all, excel, word, csv)\n", *fileTypePtr)
+ os.Exit(1)
+ }
+ opts.FileType = ft
+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
- fmt.Printf("[Info] Starting search in '%s' for keyword '%s'...\n", *dirPtr, *keywordPtr)
+ fmt.Printf("[Info] Starting search in '%s' for keyword '%s' (case-sensitive=%t whole-word=%t subdirs=%t file-type=%s)...\n",
+ *dirPtr, *keywordPtr, opts.CaseSensitive, opts.WholeWord, opts.IncludeSubdirectories, opts.FileType)
startTime := time.Now()
- results, err := search.SearchDirectory(ctx, *dirPtr, *keywordPtr, *workersPtr)
+ results, err := search.SearchDirectory(ctx, *dirPtr, *keywordPtr, *workersPtr, opts)
if err != nil {
slog.Error("search failed", "err", err)
os.Exit(1)
diff --git a/go/cmd/webui/main.go b/go/cmd/webui/main.go
index 23d45ad..07c568f 100644
--- a/go/cmd/webui/main.go
+++ b/go/cmd/webui/main.go
@@ -31,8 +31,40 @@ var content embed.FS
var version = "dev"
type SearchRequest struct {
- Dir string `json:"dir"`
- Keyword string `json:"keyword"`
+ Dir string `json:"dir"`
+ Keyword string `json:"keyword"`
+ Options SearchOptionsRequest `json:"options"`
+}
+
+// SearchOptionsRequest 是 HTTP 传输层选项体。
+// 布尔字段用指针,以区分“未提供(使用默认值)”与“显式 false”。
+type SearchOptionsRequest struct {
+ CaseSensitive *bool `json:"case_sensitive"`
+ WholeWord *bool `json:"whole_word"`
+ IncludeSubdirectories *bool `json:"include_subdirectories"`
+ FileType *string `json:"file_type"`
+}
+
+// resolveSearchOptions 将请求选项合并到默认值上,并对 file_type 做合法性校验。
+func resolveSearchOptions(so SearchOptionsRequest) (search.SearchOptions, error) {
+ opts := search.DefaultSearchOptions()
+ if so.CaseSensitive != nil {
+ opts.CaseSensitive = *so.CaseSensitive
+ }
+ if so.WholeWord != nil {
+ opts.WholeWord = *so.WholeWord
+ }
+ if so.IncludeSubdirectories != nil {
+ opts.IncludeSubdirectories = *so.IncludeSubdirectories
+ }
+ if so.FileType != nil {
+ ft := search.FileType(*so.FileType)
+ if !ft.Valid() {
+ return opts, fmt.Errorf("invalid file_type %q (allowed: all, excel, word, csv)", *so.FileType)
+ }
+ opts.FileType = ft
+ }
+ return opts, nil
}
type SearchResponse struct {
@@ -76,12 +108,21 @@ func main() {
req.Dir = "."
}
+ opts, err := resolveSearchOptions(req.Options)
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(SearchResponse{Error: err.Error()})
+ return
+ }
+
reqID := logging.NewRequestID()
- slog.Info("search request received", "request_id", reqID, "dir", req.Dir, "keyword", req.Keyword)
+ slog.Info("search request received", "request_id", reqID, "dir", req.Dir, "keyword", req.Keyword,
+ "options", opts)
searchCtx, cancel := context.WithTimeout(r.Context(), cfg.SearchTimeout)
defer cancel()
- results, err := search.SearchDirectory(searchCtx, req.Dir, req.Keyword, cfg.Workers)
+ results, err := search.SearchDirectory(searchCtx, req.Dir, req.Keyword, cfg.Workers, opts)
if err != nil {
slog.Error("search error", "request_id", reqID, "err", err)
msg := err.Error()
diff --git a/go/cmd/webui/options_test.go b/go/cmd/webui/options_test.go
new file mode 100644
index 0000000..b145da4
--- /dev/null
+++ b/go/cmd/webui/options_test.go
@@ -0,0 +1,41 @@
+package main
+
+import (
+ "testing"
+
+ "excel-search/internal/search"
+)
+
+func boolPtr(b bool) *bool { return &b }
+
+func TestResolveSearchOptionsDefaults(t *testing.T) {
+ opts, err := resolveSearchOptions(SearchOptionsRequest{})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ def := search.DefaultSearchOptions()
+ if opts != def {
+ t.Fatalf("defaults mismatch: got %+v want %+v", opts, def)
+ }
+}
+
+func TestResolveSearchOptionsExplicitFalse(t *testing.T) {
+ f := boolPtr(false)
+ opts, err := resolveSearchOptions(SearchOptionsRequest{WholeWord: f, IncludeSubdirectories: f})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if opts.WholeWord || opts.IncludeSubdirectories {
+ t.Fatalf("explicit false must override defaults: %+v", opts)
+ }
+ if opts.CaseSensitive || opts.FileType != search.FileTypeAll {
+ t.Fatalf("untouched fields must keep defaults: %+v", opts)
+ }
+}
+
+func TestResolveSearchOptionsInvalidFileType(t *testing.T) {
+ s := "pdf"
+ if _, err := resolveSearchOptions(SearchOptionsRequest{FileType: &s}); err == nil {
+ t.Fatal("expected error for invalid file_type")
+ }
+}
diff --git a/go/cmd/webui/static/index.html b/go/cmd/webui/static/index.html
index be4d9aa..e2aff8e 100644
--- a/go/cmd/webui/static/index.html
+++ b/go/cmd/webui/static/index.html
@@ -10,28 +10,65 @@
-
📊 Excel 关键字智能搜索工具
-
高性能本地多文件检索系统(支持 .xlsx, .txt, .csv,纯 Go 独立运行,无 Python/Pandas 依赖)
+
+
📊 Excel 关键字智能搜索工具
+
+ 语言
+
+
+
+
高性能本地多文件检索系统(支持 .xlsx, .txt, .csv,纯 Go 独立运行,无 Python/Pandas 依赖)
+
+
+
+
+
+
+
+
-
@@ -39,23 +76,23 @@
-
搜索结果
+ 搜索结果
0 条匹配
- | 序号 |
- 文件名 / 路径 |
- 类型 |
- Sheet / 位置 |
- 匹配内容单元格 |
+ 序号 |
+ 文件名 / 路径 |
+ 类型 |
+ Sheet / 位置 |
+ 匹配内容单元格 |
- | 请输入路径与关键字开始搜索 |
+ 请输入路径与关键字开始搜索 |
@@ -64,24 +101,208 @@