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 = ` - - - - - + + + + + @@ -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 / 位置
匹配内容单元格
- - - - - + + + + + - +
序号文件名 / 路径类型Sheet / 位置匹配内容单元格序号文件名 / 路径类型Sheet / 位置匹配内容单元格
请输入路径与关键字开始搜索请输入路径与关键字开始搜索
@@ -64,24 +101,208 @@ - + \ No newline at end of file diff --git a/go/cmd/webui/static/style.css b/go/cmd/webui/static/style.css index 8fc8040..5c0efe9 100644 --- a/go/cmd/webui/static/style.css +++ b/go/cmd/webui/static/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/frontend/dist/style.css b/go/frontend/dist/style.css index 8fc8040..5c0efe9 100644 --- a/go/frontend/dist/style.css +++ b/go/frontend/dist/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/internal/search/matcher.go b/go/internal/search/matcher.go new file mode 100644 index 0000000..25a47e6 --- /dev/null +++ b/go/internal/search/matcher.go @@ -0,0 +1,86 @@ +package search + +import ( + "strings" + "unicode" + "unicode/utf8" + + "golang.org/x/text/cases" + "golang.org/x/text/unicode/norm" +) + +// matcher 封装一次搜索的关键字匹配语义: +// 区分大小写、完整单词、Unicode 规范化与大小写折叠。 +// 供 Excel / 文本 / Word 三类解析器共用,保证行为一致。 +type matcher struct { + keyword string // 已 NFC 规范化(且在不区分大小写模式下已折叠) + ci bool // 大小写不敏感 + whole bool // 完整单词(要求前后均为单词边界) +} + +// newMatcher 构造匹配器。keyword 在构造期完成规范化与折叠,避免在热路径重复处理。 +func newMatcher(keyword string, opts SearchOptions) *matcher { + m := &matcher{ci: !opts.CaseSensitive, whole: opts.WholeWord} + k := norm.NFC.String(keyword) + if m.ci { + k = cases.Fold().String(k) + } + m.keyword = k + return m +} + +// match 报告 text 是否命中关键字。 +func (m *matcher) match(text string) bool { + if m.keyword == "" { + return false + } + t := norm.NFC.String(text) + if m.ci { + t = cases.Fold().String(t) + } + if !m.whole { + return strings.Contains(t, m.keyword) + } + return m.wholeWordMatch(t) +} + +// wholeWordMatch 要求命中位置的前后字符均不是单词字符。 +// 边界规则:单词字符 = 字母 / 数字 / 组合标记 / 下划线。 +// 该规则对中文亦成立——汉字是字母,故中文子串匹配需关闭完整单词选项。 +func (m *matcher) wholeWordMatch(text string) bool { + start := 0 + for { + idx := strings.Index(text[start:], m.keyword) + if idx < 0 { + return false + } + at := start + idx + if m.boundaryBefore(text, at) && m.boundaryAfter(text, at+len(m.keyword)) { + return true + } + start = at + 1 + } +} + +// boundaryBefore 报告 text 中 at 字节位置左侧是否为单词边界(at==0 视为边界)。 +func (m *matcher) boundaryBefore(text string, at int) bool { + if at == 0 { + return true + } + r, _ := utf8.DecodeLastRuneInString(text[:at]) + return !isWordRune(r) +} + +// boundaryAfter 报告 text 中 at 字节位置右侧是否为单词边界(at==len 视为边界)。 +func (m *matcher) boundaryAfter(text string, at int) bool { + if at >= len(text) { + return true + } + r, _ := utf8.DecodeRuneInString(text[at:]) + return !isWordRune(r) +} + +// isWordRune 判断 rune 是否构成"单词"的一部分。 +func isWordRune(r rune) bool { + return unicode.IsLetter(r) || unicode.IsNumber(r) || unicode.IsMark(r) || r == '_' +} diff --git a/go/internal/search/matcher_test.go b/go/internal/search/matcher_test.go new file mode 100644 index 0000000..27fc1d7 --- /dev/null +++ b/go/internal/search/matcher_test.go @@ -0,0 +1,100 @@ +package search + +import ( + "testing" +) + +func TestMatcherCaseSensitivity(t *testing.T) { + opts := DefaultSearchOptions() // 默认不区分大小写 + ci := newMatcher("Alpha", opts) + if !ci.match("alpha") { + t.Fatal("case-insensitive default should match 'Alpha' against 'alpha'") + } + cs := newMatcher("Alpha", SearchOptions{CaseSensitive: true}) + if cs.match("alpha") { + t.Fatal("case-sensitive mode must NOT match 'Alpha' against 'alpha'") + } + if !cs.match("Alpha") { + t.Fatal("case-sensitive mode should match 'Alpha' against 'Alpha'") + } +} + +func TestMatcherWholeWord(t *testing.T) { + whole := newMatcher("cat", SearchOptions{WholeWord: true}) + for text, want := range map[string]bool{ + "a cat.": true, + "cat": true, + "cat jumped": true, + "concatenate": false, + "scatter": false, + "cat2": false, + "_cat": false, + "cat_": false, + "catalog": false, + } { + if got := whole.match(text); got != want { + t.Errorf("whole-word 'cat' vs %q: got %v want %v", text, got, want) + } + } + + fuzzy := newMatcher("cat", SearchOptions{WholeWord: false}) + if !fuzzy.match("concatenate") || !fuzzy.match("cat2") { + t.Fatal("fuzzy mode should match substrings") + } +} + +func TestMatcherPunctuationKeyword(t *testing.T) { + whole := newMatcher("C++", SearchOptions{WholeWord: true}) + if !whole.match("learn C++ today") { + t.Fatal("keyword with punctuation should match at boundaries") + } + // 边界规则:'+' 非单词字符,故 "C++++" 中 "C++" 的前后皆非单词字符 → 命中 + if !whole.match("C++++") { + t.Fatal("punctuation is a boundary; keyword ending in punctuation may match inside a longer punctuation run") + } + // 但相邻单词字符仍阻断:"abcC++" 中 "C++" 左侧是字母 → 不命中 + if whole.match("abcC++") { + t.Fatal("letter-adjacent match must be rejected") + } +} + +func TestMatcherUnicodeCaseFold(t *testing.T) { + opts := DefaultSearchOptions() + m := newMatcher("ÅNGSTRÖM", opts) + if !m.match("ångström") { + t.Fatal("unicode case folding should match ÅNGSTRÖM vs ångström") + } +} + +func TestMatcherNFCComposition(t *testing.T) { + opts := DefaultSearchOptions() + // "café" 组合形式 vs "cafe" + 组合字符 u+0301 + m := newMatcher("café", opts) + if !m.match("café") { + t.Fatal("NFC normalization should equate café with cafe + combining accent") + } +} + +func TestMatcherCJKWholeWord(t *testing.T) { + whole := newMatcher("手机", SearchOptions{WholeWord: true}) + if whole.match("苹果手机壳") { + t.Fatal("whole-word 手机 must not match inside 苹果手机壳 (Han chars are letters)") + } + if whole.match("苹果手机") { + t.Fatal("whole-word 手机 must not match when preceded by a letter like 机") + } + if !whole.match("苹果 手机") { + t.Fatal("whole-word 手机 should match after whitespace separator") + } + fuzzy := newMatcher("手机", SearchOptions{WholeWord: false}) + if !fuzzy.match("苹果手机壳") { + t.Fatal("fuzzy mode should match 手机 inside 苹果手机壳") + } +} + +func TestMatcherEmptyKeyword(t *testing.T) { + m := newMatcher("", DefaultSearchOptions()) + if m.match("anything") { + t.Fatal("empty keyword should never match") + } +} diff --git a/go/internal/search/options.go b/go/internal/search/options.go new file mode 100644 index 0000000..66e2cc7 --- /dev/null +++ b/go/internal/search/options.go @@ -0,0 +1,100 @@ +package search + +import ( + "path/filepath" + "strings" +) + +// FileType 限定可搜索的文件类型范围,与 UI 下拉选项一一对应。 +type FileType string + +const ( + FileTypeAll FileType = "all" + FileTypeExcel FileType = "excel" + FileTypeWord FileType = "word" + FileTypeCSV FileType = "csv" +) + +// Valid 报告 fileType 是否为受支持的取值。 +func (f FileType) Valid() bool { + switch f { + case FileTypeAll, FileTypeExcel, FileTypeWord, FileTypeCSV: + return true + } + return false +} + +// SearchOptions 携带一次搜索的全部匹配与遍历选项。 +type SearchOptions struct { + CaseSensitive bool + WholeWord bool + IncludeSubdirectories bool + FileType FileType +} + +// DefaultSearchOptions 返回产品默认值: +// 不区分大小写、完整单词匹配、递归子目录、扫描全部支持的文件类型。 +func DefaultSearchOptions() SearchOptions { + return SearchOptions{ + CaseSensitive: false, + WholeWord: false, + IncludeSubdirectories: true, + FileType: FileTypeAll, + } +} + +// allowedExts 返回当前选项允许扫描的扩展名集合(含前导点,小写)。 +// "all" 额外包含既有支持的 .txt,保证旧行为不回归。 +func (o SearchOptions) allowedExts() map[string]bool { + all := map[string]bool{ + ".xlsx": true, ".xlsm": true, ".xltx": true, ".xltm": true, + ".docx": true, ".docm": true, + ".csv": true, + ".txt": true, + } + switch o.FileType { + case FileTypeExcel: + return map[string]bool{".xlsx": true, ".xlsm": true, ".xltx": true, ".xltm": true} + case FileTypeWord: + return map[string]bool{".docx": true, ".docm": true} + case FileTypeCSV: + return map[string]bool{".csv": true} + default: + return all + } +} + +// extAllowed 判断指定路径是否属于当前选项允许的文件类型(扩展名大小写不敏感)。 +func (o SearchOptions) extAllowed(path string) bool { + return o.allowedExts()[strings.ToLower(filepath.Ext(path))] +} + +// fileTypeOf 返回路径所属的展示用文件类型(无点小写扩展名)。 +func (o SearchOptions) fileTypeOf(path string) string { + return strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".") +} + +// isExcelExt / isWordExt / isTextExt 供 worker 分发解析器使用。 +func (o SearchOptions) isExcelExt(path string) bool { + switch strings.ToLower(filepath.Ext(path)) { + case ".xlsx", ".xlsm", ".xltx", ".xltm": + return true + } + return false +} + +func (o SearchOptions) isWordExt(path string) bool { + switch strings.ToLower(filepath.Ext(path)) { + case ".docx", ".docm": + return true + } + return false +} + +func (o SearchOptions) isTextExt(path string) bool { + switch strings.ToLower(filepath.Ext(path)) { + case ".txt", ".csv": + return true + } + return false +} diff --git a/go/internal/search/scanner.go b/go/internal/search/scanner.go index 19e6b13..b07cceb 100644 --- a/go/internal/search/scanner.go +++ b/go/internal/search/scanner.go @@ -22,6 +22,10 @@ import ( "github.com/xuri/excelize/v2" ) +// cacheSchemaVersion 缓存结构版本号。匹配语义发生变化时递增, +// 旧版本缓存整体失效,避免不同选项组合串用结果。 +const cacheSchemaVersion = 2 + // FileCacheEntry 记录单个文件对某个关键字的历史搜索结果。 // 命中条件为文件路径、关键字、修改时间和大小均一致,避免缓存脏数据。 type FileCacheEntry struct { @@ -31,7 +35,8 @@ type FileCacheEntry struct { } type globalCache struct { - Cache map[string]FileCacheEntry `json:"cache"` + Version int `json:"version"` + Cache map[string]FileCacheEntry `json:"cache"` } // getCacheFilePath 根据搜索根目录生成稳定的缓存文件路径, @@ -56,22 +61,23 @@ func loadCache(cacheFile string) map[string]FileCacheEntry { return entries } var gc globalCache - if json.Unmarshal(data, &gc) == nil && gc.Cache != nil { + if json.Unmarshal(data, &gc) == nil && gc.Cache != nil && gc.Version == cacheSchemaVersion { return gc.Cache } return entries } func saveCache(cacheFile string, entries map[string]FileCacheEntry) { - gc := globalCache{Cache: entries} + gc := globalCache{Version: cacheSchemaVersion, Cache: entries} if data, err := json.Marshal(gc); err == nil { _ = os.WriteFile(cacheFile, data, 0o644) } } // SearchDirectory 并发扫描目录,返回匹配结果。 +// opts 控制大小写、完整单词、子目录递归与文件类型过滤。 // 全程响应 ctx 的取消/超时;取消时返回已收集的部分结果与 ctx.Err()。 -func SearchDirectory(ctx context.Context, rootPath string, keyword string, maxWorkers int) ([]model.SearchResult, error) { +func SearchDirectory(ctx context.Context, rootPath string, keyword string, maxWorkers int, opts SearchOptions) ([]model.SearchResult, error) { if maxWorkers <= 0 { maxWorkers = 4 } @@ -79,6 +85,8 @@ func SearchDirectory(ctx context.Context, rootPath string, keyword string, maxWo return nil, err } + m := newMatcher(keyword, opts) + cacheKeyPart := fmt.Sprintf("%t|%t|%d", opts.CaseSensitive, opts.WholeWord, cacheSchemaVersion) cacheFile := getCacheFilePath(rootPath) cacheMap := loadCache(cacheFile) var cacheLock sync.Mutex @@ -106,7 +114,7 @@ func SearchDirectory(ctx context.Context, rootPath string, keyword string, maxWo } modTime := info.ModTime().UnixNano() size := info.Size() - cacheKey := path + "|" + keyword + cacheKey := path + "|" + keyword + "|" + cacheKeyPart cacheLock.Lock() entry, cached := cacheMap[cacheKey] @@ -116,13 +124,14 @@ func SearchDirectory(ctx context.Context, rootPath string, keyword string, maxWo if cached && entry.ModTime == modTime && entry.Size == size { results = entry.Results } else { - ext := strings.ToLower(filepath.Ext(path)) var searchErr error - switch ext { - case ".xlsx": - results, searchErr = searchExcel(ctx, path, keyword) - case ".txt", ".csv": - results, searchErr = searchTextFile(ctx, path, keyword) + switch { + case opts.isExcelExt(path): + results, searchErr = searchExcel(ctx, path, m) + case opts.isWordExt(path): + results, searchErr = searchWordDocument(ctx, path, keyword, m) + case opts.isTextExt(path): + results, searchErr = searchTextFile(ctx, path, m) } if searchErr == nil { cacheLock.Lock() @@ -154,18 +163,20 @@ func SearchDirectory(ctx context.Context, rootPath string, keyword string, maxWo if err != nil { return nil } - if d.IsDir() && d.Name() == ".cache" { - return filepath.SkipDir + if d.IsDir() { + if d.Name() == ".cache" { + return filepath.SkipDir + } + if !opts.IncludeSubdirectories && s != rootPath { + return filepath.SkipDir + } } - if !d.IsDir() { - ext := strings.ToLower(filepath.Ext(s)) - if ext == ".xlsx" || ext == ".txt" || ext == ".csv" { - count++ - select { - case filesChan <- s: - case <-ctx.Done(): - return ctx.Err() - } + if !d.IsDir() && opts.extAllowed(s) { + count++ + select { + case filesChan <- s: + case <-ctx.Done(): + return ctx.Err() } } return nil @@ -189,7 +200,7 @@ func SearchDirectory(ctx context.Context, rootPath string, keyword string, maxWo return allResults, nil } -func searchExcel(ctx context.Context, filePath string, keyword string) ([]model.SearchResult, error) { +func searchExcel(ctx context.Context, filePath string, m *matcher) ([]model.SearchResult, error) { f, err := excelize.OpenFile(filePath, excelize.Options{UnzipSizeLimit: 1024 * 1024 * 100}) if err != nil { return nil, err @@ -197,6 +208,7 @@ func searchExcel(ctx context.Context, filePath string, keyword string) ([]model. defer f.Close() var results []model.SearchResult + ft := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") for _, sheet := range f.GetSheetList() { if ctx.Err() != nil { return results, ctx.Err() @@ -218,14 +230,14 @@ func searchExcel(ctx context.Context, filePath string, keyword string) ([]model. continue } for colIdx, cellVal := range row { - if strings.Contains(cellVal, keyword) { + if m.match(cellVal) { colName, _ := excelize.ColumnNumberToName(colIdx + 1) results = append(results, model.SearchResult{ FilePath: filePath, - FileType: "xlsx", + FileType: ft, SheetName: sheet, Location: fmt.Sprintf("%s%d", colName, rowIdx), - MatchedKey: keyword, + MatchedKey: m.keyword, CellContent: cellVal, }) } @@ -239,7 +251,7 @@ func searchExcel(ctx context.Context, filePath string, keyword string) ([]model. return results, nil } -func searchTextFile(ctx context.Context, filePath string, keyword string) ([]model.SearchResult, error) { +func searchTextFile(ctx context.Context, filePath string, m *matcher) ([]model.SearchResult, error) { file, err := os.Open(filePath) if err != nil { return nil, err @@ -247,6 +259,7 @@ func searchTextFile(ctx context.Context, filePath string, keyword string) ([]mod defer file.Close() var results []model.SearchResult + ft := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") reader := bufio.NewReader(file) lineIdx := 0 for { @@ -255,13 +268,13 @@ func searchTextFile(ctx context.Context, filePath string, keyword string) ([]mod } line, err := reader.ReadString('\n') lineIdx++ - if strings.Contains(line, keyword) { + if m.match(line) { results = append(results, model.SearchResult{ FilePath: filePath, - FileType: filepath.Ext(filePath), + FileType: ft, SheetName: "-", Location: fmt.Sprintf("Line %d", lineIdx), - MatchedKey: keyword, + MatchedKey: m.keyword, CellContent: strings.TrimSpace(line), }) } diff --git a/go/internal/search/scanner_test.go b/go/internal/search/scanner_test.go index e7ced77..0606708 100644 --- a/go/internal/search/scanner_test.go +++ b/go/internal/search/scanner_test.go @@ -1,12 +1,17 @@ package search import ( + "archive/zip" + "bytes" "context" "os" "path/filepath" + "strings" "testing" "time" + "excel-search/internal/model" + "github.com/xuri/excelize/v2" ) @@ -23,13 +28,14 @@ func TestMain(m *testing.M) { } // writeTestXlsx 在指定目录生成一个含关键字单元格的测试 Excel 文件。 +// 默认开启完整单词匹配,因此关键字两侧必须是非单词字符(空格/标点/行首行尾)。 func writeTestXlsx(t *testing.T, dir string) string { t.Helper() f := excelize.NewFile() - if err := f.SetCellValue("Sheet1", "A1", "苹果手机"); err != nil { + if err := f.SetCellValue("Sheet1", "A1", "手机 通讯录"); err != nil { t.Fatalf("SetCellValue A1: %v", err) } - if err := f.SetCellValue("Sheet1", "A2", "华为手机"); err != nil { + if err := f.SetCellValue("Sheet1", "A2", "华为 手机"); err != nil { t.Fatalf("SetCellValue A2: %v", err) } path := filepath.Join(dir, "test.xlsx") @@ -40,36 +46,238 @@ func writeTestXlsx(t *testing.T, dir string) string { return path } -func TestSearchDirectory(t *testing.T) { - dir := t.TempDir() - writeTestXlsx(t, dir) +// writeTestDocx 生成一个最小可解析的 .docx(包含 word/document.xml)用于解析测试。 +func writeTestDocx(t *testing.T, dir, name string, paragraphs ...string) string { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create("word/document.xml") + if err != nil { + t.Fatalf("zip create: %v", err) + } + var xml strings.Builder + xml.WriteString(``) + xml.WriteString(``) + for _, p := range paragraphs { + xml.WriteString(`` + p + ``) + } + xml.WriteString(``) + if _, err := w.Write([]byte(xml.String())); err != nil { + t.Fatalf("zip write: %v", err) + } + if err := zw.Close(); err != nil { + t.Fatalf("zip close: %v", err) + } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil { + t.Fatalf("write docx: %v", err) + } + return path +} +func searchDir(t *testing.T, dir, keyword string, opts SearchOptions) []model.SearchResult { + t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - - results, err := SearchDirectory(ctx, dir, "手机", 4) + results, err := SearchDirectory(ctx, dir, keyword, 4, opts) if err != nil { t.Fatalf("SearchDirectory: %v", err) } + return results +} + +func TestSearchDirectoryDefaults(t *testing.T) { + dir := t.TempDir() + writeTestXlsx(t, dir) + + // 默认选项:不区分大小写 + 完整单词 + 递归 + all。 + // "手机" 在单元格内位于行尾,前后为边界 → 完整单词模式命中。 + results := searchDir(t, dir, "手机", DefaultSearchOptions()) if len(results) != 2 { t.Fatalf("expected 2 results for keyword '手机', got %d", len(results)) } - - // 校验返回字段完整性 first := results[0] if first.FilePath == "" || first.SheetName == "" || first.Location == "" { t.Fatalf("incomplete result: %+v", first) } + if first.FileType != "xlsx" { + t.Fatalf("expected FileType xlsx, got %q", first.FileType) + } +} + +func TestSearchDirectoryCaseSensitive(t *testing.T) { + dir := t.TempDir() + writeTestASCIIXlsx(t, dir) + + // 默认不区分大小写:小写关键字命中包含大写的文本。 + results := searchDir(t, dir, "alpha", DefaultSearchOptions()) + if len(results) != 2 { + t.Fatalf("case-insensitive: expected 2 matches, got %d", len(results)) + } + + cs := DefaultSearchOptions() + cs.CaseSensitive = true + results = searchDir(t, dir, "alpha", cs) + if len(results) != 0 { + t.Fatalf("case-sensitive: expected 0 matches for 'alpha', got %d", len(results)) + } + results = searchDir(t, dir, "Alpha", cs) + if len(results) != 2 { + t.Fatalf("case-sensitive: expected 2 matches for 'Alpha', got %d", len(results)) + } +} + +func writeTestASCIIXlsx(t *testing.T, dir string) string { + t.Helper() + f := excelize.NewFile() + _ = f.SetCellValue("Sheet1", "A1", "Alpha Beta") + _ = f.SetCellValue("Sheet1", "A2", "Alpha Gamma") + path := filepath.Join(dir, "alpha.xlsx") + if err := f.SaveAs(path); err != nil { + t.Fatalf("SaveAs: %v", err) + } + _ = f.Close() + return path +} + +func TestSearchDirectoryWholeWord(t *testing.T) { + dir := t.TempDir() + writeTestASCIIXlsx(t, dir) + + // 默认完整单词:"cat" 不在"Alpha Beta/Gamma"中,用关键字 Alpha 验证边界。 + results := searchDir(t, dir, "Alpha", DefaultSearchOptions()) + if len(results) != 2 { + t.Fatalf("whole-word: expected 2 matches, got %d", len(results)) + } + + fuzzy := DefaultSearchOptions() + fuzzy.WholeWord = false + results = searchDir(t, dir, "Alph", fuzzy) + if len(results) != 2 { + t.Fatalf("fuzzy: expected 2 matches for 'Alph', got %d", len(results)) + } + results = searchDir(t, dir, "Alph", DefaultSearchOptions()) + if len(results) != 0 { + t.Fatalf("whole-word: 'Alph' must not match 'Alpha', got %d", len(results)) + } +} + +func TestSearchDirectoryFileTypeFilter(t *testing.T) { + dir := t.TempDir() + writeTestXlsx(t, dir) // 两个 xlsx 单元格含 "手机"(处于边界) + writeTestTextFile(t, dir, "notes.txt", "记录:手机。\n另一行\n") // txt 属于 all;句号构成边界 + writeTestTextFile(t, dir, "data.csv", "name,phone\n手机,123\n") // 逗号构成边界 + + all := DefaultSearchOptions() + results := searchDir(t, dir, "手机", all) + if len(results) < 3 { + t.Fatalf("all types: expected at least 3 matches (xlsx/txt/csv), got %d", len(results)) + } + + csv := DefaultSearchOptions() + csv.FileType = FileTypeCSV + results = searchDir(t, dir, "手机", csv) + if len(results) != 1 { + t.Fatalf("csv filter: expected 1 match, got %d", len(results)) + } + if results[0].FileType != "csv" { + t.Fatalf("csv filter: expected FileType csv, got %q", results[0].FileType) + } + + excel := DefaultSearchOptions() + excel.FileType = FileTypeExcel + results = searchDir(t, dir, "手机", excel) + if len(results) != 2 { + t.Fatalf("excel filter: expected 2 matches (two xlsx), got %d", len(results)) + } + for _, r := range results { + if r.FileType != "xlsx" { + t.Fatalf("excel filter: unexpected type %q", r.FileType) + } + } +} + +func writeTestTextFile(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } + return path +} + +func TestSearchDirectorySubdirectories(t *testing.T) { + dir := t.TempDir() + writeTestXlsx(t, dir) + sub := filepath.Join(dir, "nested") + _ = os.MkdirAll(sub, 0o755) + writeTestXlsx(t, sub) + + rec := DefaultSearchOptions() + if got := len(searchDir(t, dir, "手机", rec)); got != 4 { + t.Fatalf("recursive: expected 4 matches (2 root + 2 nested), got %d", got) + } + + top := DefaultSearchOptions() + top.IncludeSubdirectories = false + if got := len(searchDir(t, dir, "手机", top)); got != 2 { + t.Fatalf("non-recursive: expected 2 root matches, got %d", got) + } +} + +func TestSearchDirectoryWordDocument(t *testing.T) { + dir := t.TempDir() + writeTestDocx(t, dir, "sample.docx", "Intro paragraph", "这里是 手机。", "Tail paragraph") + + opts := DefaultSearchOptions() + results := searchDir(t, dir, "手机", opts) + if len(results) != 1 { + t.Fatalf("docx: expected 1 match, got %d", len(results)) + } + r := results[0] + if r.FileType != "docx" { + t.Fatalf("docx: expected FileType docx, got %q", r.FileType) + } + if r.Location != "Paragraph 2" { + t.Fatalf("docx: expected Location 'Paragraph 2', got %q", r.Location) + } + if r.SheetName != "Document" { + t.Fatalf("docx: expected SheetName Document, got %q", r.SheetName) + } + + // 损坏的 docx(非 zip)不 panic + bad := filepath.Join(dir, "bad.docx") + _ = os.WriteFile(bad, []byte("not a zip"), 0o644) + _ = searchDir(t, dir, "手机", opts) // 应被静默跳过 +} + +func TestSearchDirectoryCacheIsolation(t *testing.T) { + dir := t.TempDir() + writeTestASCIIXlsx(t, dir) + + // 区分大小写与不区分大小写使用不同缓存键,互不串结果。 + cs := DefaultSearchOptions() + cs.CaseSensitive = true + if got := len(searchDir(t, dir, "alpha", cs)); got != 0 { + t.Fatalf("sensitive 'alpha': expected 0, got %d", got) + } + + ci := DefaultSearchOptions() + if got := len(searchDir(t, dir, "alpha", ci)); got != 2 { + t.Fatalf("insensitive 'alpha': expected 2, got %d", got) + } + + // 再次以区分大小写运行,应仍为 0(未命中缓存脏数据)。 + if got := len(searchDir(t, dir, "alpha", cs)); got != 0 { + t.Fatalf("sensitive 'alpha' after insensitive: expected 0, got %d", got) + } } func TestSearchDirectoryNoMatch(t *testing.T) { dir := t.TempDir() writeTestXlsx(t, dir) - results, err := SearchDirectory(context.Background(), dir, "不存在的关键字", 4) - if err != nil { - t.Fatalf("SearchDirectory: %v", err) - } + results := searchDir(t, dir, "不存在的关键字", DefaultSearchOptions()) if len(results) != 0 { t.Fatalf("expected 0 results, got %d", len(results)) } @@ -82,13 +290,24 @@ func TestSearchDirectoryCanceled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() // 提前取消 - if _, err := SearchDirectory(ctx, dir, "手机", 4); err == nil { + if _, err := SearchDirectory(ctx, dir, "手机", 4, DefaultSearchOptions()); err == nil { t.Fatal("expected error for canceled context, got nil") } } func TestSearchDirectoryNonexistentDir(t *testing.T) { - if _, err := SearchDirectory(context.Background(), filepath.Join(os.TempDir(), "no_such_dir_9f3k"), "x", 4); err != nil { + if _, err := SearchDirectory(context.Background(), filepath.Join(os.TempDir(), "no_such_dir_9f3k"), "x", 4, DefaultSearchOptions()); err != nil { t.Fatalf("missing directory should not be fatal: %v", err) } } + +func TestFileTypeValid(t *testing.T) { + for _, ok := range []FileType{FileTypeAll, FileTypeExcel, FileTypeWord, FileTypeCSV} { + if !ok.Valid() { + t.Fatalf("%q should be valid", ok) + } + } + if FileType("pdf").Valid() { + t.Fatal("pdf should be invalid") + } +} diff --git a/go/internal/search/word.go b/go/internal/search/word.go new file mode 100644 index 0000000..985d9b1 --- /dev/null +++ b/go/internal/search/word.go @@ -0,0 +1,117 @@ +package search + +import ( + "archive/zip" + "context" + "encoding/xml" + "fmt" + "io" + "path/filepath" + "strings" + + "excel-search/internal/model" +) + +// searchWordDocument 解析 .docx/.docm 的 word/document.xml, +// 逐段落提取文本并做关键字匹配。表格中的段落同样位于 document.xml,故一并覆盖。 +// 位置定位到段落编号,格式为 "Paragraph N"。 +func searchWordDocument(ctx context.Context, filePath, keyword string, m *matcher) ([]model.SearchResult, error) { + zipReader, err := zip.OpenReader(filePath) + if err != nil { + return nil, err + } + defer zipReader.Close() + + var docFile io.ReadCloser + for _, f := range zipReader.File { + if f.Name == "word/document.xml" { + docFile, err = f.Open() + if err != nil { + return nil, err + } + defer docFile.Close() + break + } + } + if docFile == nil { + return nil, fmt.Errorf("word document.xml not found in %s", filePath) + } + + paras, err := parseDocumentParagraphs(ctx, docFile) + if err != nil { + return nil, err + } + + var results []model.SearchResult + ft := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") + for i, p := range paras { + if ctx.Err() != nil { + return results, ctx.Err() + } + if p == "" { + continue + } + if m.match(p) { + results = append(results, model.SearchResult{ + FilePath: filePath, + FileType: ft, + SheetName: "Document", + Location: fmt.Sprintf("Paragraph %d", i+1), + MatchedKey: keyword, + CellContent: p, + }) + } + } + return results, nil +} + +// parseDocumentParagraphs 从 document.xml 中按文档顺序提取全部段落文本。 +// 仅收集 内的字符数据; 起始收集、结束封存。 +func parseDocumentParagraphs(ctx context.Context, r io.Reader) ([]string, error) { + dec := xml.NewDecoder(r) + var paras []string + var cur strings.Builder + var inPara bool + var inText bool + + for { + tok, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + if ctx.Err() != nil { + return paras, ctx.Err() + } + + switch t := tok.(type) { + case xml.StartElement: + switch t.Name.Local { + case "p": // 段落开始 + inPara = true + cur.Reset() + case "t": // 文本开始,进入收集模式 + if inPara { + inText = true + } + } + case xml.EndElement: + switch t.Name.Local { + case "t": + inText = false + case "p": + if inPara { + paras = append(paras, strings.TrimSpace(cur.String())) + inPara = false + } + } + case xml.CharData: + if inPara && inText { + cur.Write(t) + } + } + } + return paras, nil +} diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..c2dec24 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "excel-search-rust" +version = "1.0.0" +edition = "2021" + +[dependencies] +calamine = "0.22" +rayon = "1.8" +walkdir = "2.4" +clap = { version = "4.4", features = ["derive"] } +zip = "0.6" +quick-xml = "0.31" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000..95d3c91 --- /dev/null +++ b/rust/README.md @@ -0,0 +1,58 @@ +# Rust 实现方案与开发指南 + +本文档为智能体(Agent)和开发者提供基于 Rust 语言重构 Excel 关键字搜索工具的详细设计、Cargo 配置和实施指南。 + +## 1. 架构设计目标 +- **极致的性能与最小体积**:利用 Rust 的内存安全、零成本抽象及编译优化(`opt-level = "z"`、`strip = true`),生成的独立二进制文件极小(通常在 5MB ~ 15MB 之间),运行速度达到极致。 +- **全格式 Excel 解析**:基于 `calamine` 库,完美支持 `.xlsx`, `.xls`, `.xlsb`, `.ods`。 +- **并行计算加速**:利用 `rayon` 库进行多核并行文件扫描与内容匹配。 + +## 2. 目录结构 +```text +tool/rust/ +├── Cargo.toml +├── README.md +├── build.rs +└── src/ + ├── main.rs + ├── error.rs + └── engine/ + ├── mod.rs + ├── walker.rs + └── parser.rs +``` + +## 3. 核心配置与实现规范 + +### 3.1 `Cargo.toml`(关键:体积与性能优化配置) +```toml +[package] +name = "excel-search-rust" +version = "1.0.0" +edition = "2021" + +[dependencies] +calamine = "0.22" +rayon = "1.8" +walkdir = "2.4" +clap = { version = "4.4", features = ["derive"] } +zip = "0.6" +quick-xml = "0.31" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[profile.release] +opt-level = "z" # 针对二进制文件大小进行优化 +lto = true # 链接时优化 +codegen-units = 1 # 单代码生成单元,最大化优化和压缩 +panic = "abort" # 移除栈展开开销 +strip = true # 剥离所有调试符号 +``` + +### 3.2 核心搜索逻辑要点 (`src/main.rs`) +- 使用 `clap` 定义命令行参数(目录、关键字)。 +- 使用 `walkdir` 收集文件列表。 +- 使用 `rayon` 的 `par_iter()` 并行遍历所有文件: + - 对 Excel 调用 `calamine::open_workbook_auto` 解析。 + - 对文本/Word 文件进行文本匹配。 +- 将匹配结果序列化为 JSON 或友好打印。 diff --git a/rust/src/main.rs b/rust/src/main.rs new file mode 100644 index 0000000..d1e4de8 --- /dev/null +++ b/rust/src/main.rs @@ -0,0 +1,21 @@ +use std::env; +use std::path::Path; + +fn main() { + println!("Excel Keyword Search Tool (Rust Implementation)"); + let args: Vec = env::args().collect(); + if args.len() < 3 { + println!("Usage: excel-search-rust "); + std::process::exit(1); + } + let dir_path = &args[1]; + let keyword = &args[2]; + println!("Searching in: {} for keyword: {}", dir_path, keyword); + + if !Path::new(dir_path).exists() { + eprintln!("Error: Directory does not exist: {}", dir_path); + std::process::exit(1); + } + + // TODO: Implement Rayon parallel walker and Calamine Excel parser +}