mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-19 22:30:49 +08:00
feat(chat): 前端聊天页支持消息搜索与筛选
- 新增搜索侧边栏:会话内/全局搜索、时间范围、发送者与类型筛选 - 支持搜索结果高亮与上下文定位 - 对接后端索引构建状态与错误提示
This commit is contained in:
@@ -188,4 +188,735 @@
|
||||
.fade-enter-to {
|
||||
@apply opacity-100 transform scale-100;
|
||||
}
|
||||
|
||||
/* 现代化搜索面板样式 */
|
||||
.search-panel-container {
|
||||
@apply border-b border-gray-200;
|
||||
background: linear-gradient(to bottom, #f8f9fa, #f0f1f2);
|
||||
}
|
||||
|
||||
.search-panel {
|
||||
@apply px-5 py-4;
|
||||
}
|
||||
|
||||
/* 搜索输入行 */
|
||||
.search-input-row {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
@apply flex-1 flex items-center bg-white border border-gray-200 rounded-lg overflow-hidden transition-all duration-200;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.search-input-wrapper:hover {
|
||||
@apply border-gray-300;
|
||||
}
|
||||
|
||||
.search-input-focused {
|
||||
@apply border-[#03C160] ring-2 ring-[#03C160]/20;
|
||||
}
|
||||
|
||||
.search-input-icon {
|
||||
@apply w-4 h-4 text-gray-400 ml-3 flex-shrink-0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
@apply flex-1 px-3 py-2.5 text-sm bg-transparent border-none outline-none;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
|
||||
.search-clear-btn {
|
||||
@apply p-2 text-gray-400 hover:text-gray-600 transition-colors;
|
||||
}
|
||||
|
||||
.search-btn-primary {
|
||||
@apply px-4 py-2.5 text-sm font-medium text-white bg-[#03C160] rounded-lg hover:bg-[#02a650] transition-all duration-200 flex items-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed;
|
||||
box-shadow: 0 2px 4px rgba(3, 193, 96, 0.2);
|
||||
}
|
||||
|
||||
.search-btn-primary:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 8px rgba(3, 193, 96, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.search-btn-close {
|
||||
@apply p-2.5 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors;
|
||||
}
|
||||
|
||||
/* 搜索控制行 */
|
||||
.search-controls-row {
|
||||
@apply flex items-center gap-3 mt-3 flex-wrap;
|
||||
}
|
||||
|
||||
/* 搜索范围切换器 */
|
||||
.search-scope-switcher {
|
||||
@apply flex items-center bg-gray-100 rounded-lg p-0.5;
|
||||
}
|
||||
|
||||
.scope-btn {
|
||||
@apply flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 rounded-md transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.scope-btn:hover:not(:disabled) {
|
||||
@apply text-gray-800;
|
||||
}
|
||||
|
||||
.scope-btn-active {
|
||||
@apply bg-white text-[#03C160] shadow-sm;
|
||||
}
|
||||
|
||||
/* 快捷过滤标签 */
|
||||
.quick-filter-tags {
|
||||
@apply flex items-center gap-1.5 flex-wrap;
|
||||
}
|
||||
|
||||
.filter-tag {
|
||||
@apply flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium text-gray-600 bg-white border border-gray-200 rounded-full transition-all duration-200 hover:border-gray-300 hover:bg-gray-50;
|
||||
}
|
||||
|
||||
.filter-tag-icon {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
.filter-tag-active {
|
||||
@apply bg-[#03C160]/10 border-[#03C160] text-[#03C160];
|
||||
}
|
||||
|
||||
.filter-tag-active:hover {
|
||||
@apply bg-[#03C160]/15 border-[#03C160];
|
||||
}
|
||||
|
||||
/* 高级过滤器切换 */
|
||||
.advanced-filter-toggle {
|
||||
@apply flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 bg-white border border-gray-200 rounded-lg transition-all duration-200 hover:border-gray-300 hover:bg-gray-50 ml-auto;
|
||||
}
|
||||
|
||||
.advanced-filter-toggle-active {
|
||||
@apply bg-gray-100 border-gray-300;
|
||||
}
|
||||
|
||||
/* 高级过滤器面板 */
|
||||
.advanced-filters-panel {
|
||||
@apply overflow-hidden transition-all duration-300 ease-in-out;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.advanced-filters-expanded {
|
||||
max-height: 200px;
|
||||
opacity: 1;
|
||||
@apply mt-3;
|
||||
}
|
||||
|
||||
.advanced-filters-content {
|
||||
@apply flex items-center gap-4 p-3 bg-white rounded-lg border border-gray-200 flex-wrap;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
@apply text-xs font-medium text-gray-600;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
@apply text-xs px-3 py-1.5 rounded-md border border-gray-200 bg-white focus:outline-none focus:ring-2 focus:ring-[#03C160]/20 focus:border-[#03C160] transition-all;
|
||||
}
|
||||
|
||||
.filter-checkbox {
|
||||
@apply flex items-center gap-2 text-xs cursor-pointer;
|
||||
}
|
||||
|
||||
.filter-checkbox-input {
|
||||
@apply w-4 h-4 text-[#03C160] border-gray-300 rounded focus:ring-[#03C160]/20;
|
||||
}
|
||||
|
||||
.filter-checkbox-label {
|
||||
@apply font-medium text-gray-700;
|
||||
}
|
||||
|
||||
.filter-checkbox-hint {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
|
||||
/* 搜索历史面板 */
|
||||
.search-history-panel {
|
||||
@apply mt-3 p-3 bg-white rounded-lg border border-gray-200;
|
||||
}
|
||||
|
||||
.search-history-header {
|
||||
@apply flex items-center justify-between mb-2;
|
||||
}
|
||||
|
||||
.search-history-title {
|
||||
@apply text-xs font-medium text-gray-600;
|
||||
}
|
||||
|
||||
.search-history-clear {
|
||||
@apply text-xs text-gray-400 hover:text-gray-600 transition-colors;
|
||||
}
|
||||
|
||||
.search-history-list {
|
||||
@apply flex flex-wrap gap-2;
|
||||
}
|
||||
|
||||
.search-history-item {
|
||||
@apply flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-gray-600 bg-gray-50 rounded-full hover:bg-gray-100 transition-colors;
|
||||
}
|
||||
|
||||
/* 搜索状态栏 */
|
||||
.search-status-bar {
|
||||
@apply mt-3 text-xs;
|
||||
}
|
||||
|
||||
.search-status-error {
|
||||
@apply flex items-center gap-2 text-red-600 bg-red-50 px-3 py-2 rounded-lg;
|
||||
}
|
||||
|
||||
.search-status-info {
|
||||
@apply text-gray-600;
|
||||
}
|
||||
|
||||
.search-status-count {
|
||||
@apply text-gray-800;
|
||||
}
|
||||
|
||||
.search-status-count strong {
|
||||
@apply text-[#03C160] font-semibold;
|
||||
}
|
||||
|
||||
.search-status-detail {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
|
||||
.search-status-hint {
|
||||
@apply flex items-center gap-2 text-gray-400;
|
||||
}
|
||||
|
||||
/* 搜索结果容器 */
|
||||
.search-results-container {
|
||||
@apply bg-white border-b border-gray-200;
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.search-results-list {
|
||||
@apply overflow-y-auto;
|
||||
max-height: 260px;
|
||||
}
|
||||
|
||||
/* 搜索结果卡片 */
|
||||
.search-result-card {
|
||||
@apply px-5 py-3 border-b border-gray-100 cursor-pointer transition-all duration-150;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.search-result-card:hover {
|
||||
@apply bg-gray-50;
|
||||
border-left-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.search-result-card-selected {
|
||||
@apply bg-[#03C160]/5;
|
||||
border-left-color: #03C160;
|
||||
}
|
||||
|
||||
.search-result-card-selected:hover {
|
||||
@apply bg-[#03C160]/10;
|
||||
border-left-color: #03C160;
|
||||
}
|
||||
|
||||
.search-result-meta {
|
||||
@apply flex items-center gap-2 text-[11px] text-gray-500 mb-1;
|
||||
}
|
||||
|
||||
.search-result-contact {
|
||||
@apply font-medium text-gray-700;
|
||||
}
|
||||
|
||||
.search-result-contact::after {
|
||||
content: '·';
|
||||
@apply ml-2;
|
||||
}
|
||||
|
||||
.search-result-time {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
|
||||
.search-result-sender {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
|
||||
.search-result-sender::before {
|
||||
content: '·';
|
||||
@apply mr-2;
|
||||
}
|
||||
|
||||
.search-result-content {
|
||||
@apply text-sm text-gray-800 truncate leading-relaxed;
|
||||
}
|
||||
|
||||
/* 搜索高亮 */
|
||||
.search-highlight {
|
||||
@apply bg-[#03C160]/20 text-[#03C160] px-0.5 rounded font-medium;
|
||||
}
|
||||
|
||||
/* 搜索结果底部 */
|
||||
.search-results-footer {
|
||||
@apply px-5 py-3 flex items-center justify-between border-t border-gray-100 bg-gray-50/50;
|
||||
}
|
||||
|
||||
.search-load-more-btn {
|
||||
@apply flex items-center gap-2 text-xs px-3 py-1.5 rounded-md border border-gray-200 bg-white hover:bg-gray-50 text-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.search-results-hint {
|
||||
@apply text-xs text-gray-400;
|
||||
}
|
||||
|
||||
.search-results-hint kbd {
|
||||
@apply px-1.5 py-0.5 bg-gray-100 border border-gray-200 rounded text-[10px] font-mono;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.search-empty-state {
|
||||
@apply py-8 px-5 text-center bg-white border-b border-gray-200;
|
||||
}
|
||||
|
||||
.search-empty-icon {
|
||||
@apply w-16 h-16 mx-auto text-gray-300 mb-4;
|
||||
}
|
||||
|
||||
.search-empty-title {
|
||||
@apply text-base font-medium text-gray-600 mb-3;
|
||||
}
|
||||
|
||||
.search-empty-tips {
|
||||
@apply text-sm text-gray-500 text-left max-w-xs mx-auto;
|
||||
}
|
||||
|
||||
.search-empty-tips p {
|
||||
@apply mb-2;
|
||||
}
|
||||
|
||||
.search-empty-tips ul {
|
||||
@apply list-disc list-inside space-y-1 text-gray-400;
|
||||
}
|
||||
|
||||
/* 联系人搜索栏样式 */
|
||||
.contact-search-wrapper {
|
||||
@apply flex items-center bg-[#EAEAEA] rounded-lg overflow-hidden transition-all duration-200;
|
||||
}
|
||||
|
||||
.contact-search-wrapper:focus-within {
|
||||
@apply ring-2 ring-[#03C160]/20 bg-white;
|
||||
}
|
||||
|
||||
.contact-search-icon {
|
||||
@apply w-4 h-4 text-gray-400 ml-2.5 flex-shrink-0;
|
||||
}
|
||||
|
||||
.contact-search-input {
|
||||
@apply flex-1 px-2 py-2 text-sm bg-transparent border-none outline-none;
|
||||
}
|
||||
|
||||
.contact-search-input::placeholder {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
|
||||
.contact-search-clear {
|
||||
@apply p-2 text-gray-400 hover:text-gray-600 transition-colors;
|
||||
}
|
||||
|
||||
.account-select {
|
||||
@apply text-xs px-2 py-2 rounded-lg border border-gray-200 bg-[#EAEAEA] focus:outline-none focus:ring-2 focus:ring-[#03C160]/20 focus:border-[#03C160] transition-all;
|
||||
}
|
||||
|
||||
/* 骨架屏动画 */
|
||||
.skeleton-pulse {
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* 聊天头部样式 */
|
||||
.chat-header {
|
||||
@apply h-14 px-5 flex items-center border-b border-gray-200;
|
||||
background-color: #EDEDED;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
@apply flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg bg-white border border-gray-200 text-gray-700 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.header-btn:hover:not(:disabled) {
|
||||
@apply bg-gray-50 border-gray-300;
|
||||
}
|
||||
|
||||
.header-btn:active:not(:disabled) {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
|
||||
.header-btn-icon {
|
||||
@apply w-8 h-8 flex items-center justify-center rounded-lg bg-white border border-gray-200 text-gray-600 transition-all duration-200;
|
||||
}
|
||||
|
||||
.header-btn-icon:hover {
|
||||
@apply bg-gray-50 border-gray-300 text-gray-800;
|
||||
}
|
||||
|
||||
.header-btn-icon-active {
|
||||
@apply bg-[#03C160]/10 border-[#03C160] text-[#03C160];
|
||||
}
|
||||
|
||||
.header-btn-icon-active:hover {
|
||||
@apply bg-[#03C160]/15;
|
||||
}
|
||||
|
||||
/* 搜索侧边栏样式 */
|
||||
.search-sidebar {
|
||||
@apply w-[420px] h-full flex flex-col bg-white border-l border-gray-200 flex-shrink-0;
|
||||
}
|
||||
|
||||
.search-sidebar-header {
|
||||
@apply flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50;
|
||||
}
|
||||
|
||||
.search-sidebar-title {
|
||||
@apply flex items-center gap-2 text-sm font-medium text-gray-800;
|
||||
}
|
||||
|
||||
.search-sidebar-close {
|
||||
@apply p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-200 rounded-md transition-colors;
|
||||
}
|
||||
|
||||
.search-sidebar-input-section {
|
||||
@apply px-3 py-3 border-b border-gray-100;
|
||||
}
|
||||
|
||||
/* 整合搜索框样式 */
|
||||
.search-input-combined {
|
||||
@apply flex items-center bg-white border-2 border-gray-200 rounded-lg overflow-hidden transition-all duration-200;
|
||||
}
|
||||
|
||||
.search-input-combined:hover {
|
||||
@apply border-gray-300;
|
||||
}
|
||||
|
||||
.search-input-combined-focused {
|
||||
@apply border-[#03C160] ring-2 ring-[#03C160]/20;
|
||||
}
|
||||
|
||||
.search-scope-inline {
|
||||
@apply flex items-center px-2 border-r border-gray-200 bg-gray-50;
|
||||
}
|
||||
|
||||
.scope-inline-btn {
|
||||
@apply text-[11px] font-medium text-gray-400 hover:text-gray-600 transition-colors px-1 py-2.5 disabled:opacity-40 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.scope-inline-btn-active {
|
||||
@apply text-[#03C160] font-semibold;
|
||||
}
|
||||
|
||||
.scope-inline-divider {
|
||||
@apply text-gray-300 text-[10px];
|
||||
}
|
||||
|
||||
.search-input-inline {
|
||||
@apply flex-1 min-w-0 px-3 py-2.5 text-sm bg-transparent border-none outline-none;
|
||||
}
|
||||
|
||||
.search-input-inline::placeholder {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
|
||||
.search-clear-inline {
|
||||
@apply p-2 text-gray-400 hover:text-gray-600 transition-colors;
|
||||
}
|
||||
|
||||
.search-btn-inline {
|
||||
@apply flex items-center justify-center w-10 h-10 bg-[#03C160] text-white hover:bg-[#02a650] transition-colors disabled:opacity-60 disabled:cursor-not-allowed flex-shrink-0;
|
||||
}
|
||||
|
||||
/* 筛选条件行 */
|
||||
.search-filters-row {
|
||||
@apply flex items-center gap-2 mt-2;
|
||||
}
|
||||
|
||||
.search-session-type-row {
|
||||
@apply flex items-center gap-1 mt-2;
|
||||
}
|
||||
|
||||
.search-session-type-btn {
|
||||
@apply flex-1 text-[11px] font-medium px-2 py-1.5 rounded-md border border-gray-200 bg-gray-50 text-gray-500 hover:bg-gray-100 hover:text-gray-700 transition-colors;
|
||||
}
|
||||
|
||||
.search-session-type-btn-active {
|
||||
@apply bg-[#03C160]/10 border-[#03C160] text-[#03C160] font-semibold;
|
||||
}
|
||||
|
||||
.search-filter-select {
|
||||
@apply flex-1 text-xs px-2 py-1.5 bg-gray-50 border border-gray-200 rounded-md outline-none cursor-pointer transition-all hover:border-gray-300 focus:border-[#03C160] focus:ring-1 focus:ring-[#03C160]/20;
|
||||
}
|
||||
|
||||
.search-filter-select-time {
|
||||
@apply flex-none w-28;
|
||||
}
|
||||
|
||||
.search-filter-input {
|
||||
@apply flex-1 text-xs px-2 py-1.5 bg-gray-50 border border-gray-200 rounded-md outline-none transition-all hover:border-gray-300 focus:border-[#03C160] focus:ring-1 focus:ring-[#03C160]/20;
|
||||
}
|
||||
|
||||
.search-filter-input::placeholder {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
|
||||
/* 自定义日期行 */
|
||||
.search-custom-date-row {
|
||||
@apply flex items-center gap-2 mt-2;
|
||||
}
|
||||
|
||||
.search-date-input {
|
||||
@apply flex-1 text-xs px-2 py-1.5 bg-gray-50 border border-gray-200 rounded-md outline-none transition-all hover:border-gray-300 focus:border-[#03C160] focus:ring-1 focus:ring-[#03C160]/20;
|
||||
}
|
||||
|
||||
.search-date-separator {
|
||||
@apply text-xs text-gray-400;
|
||||
}
|
||||
|
||||
.search-sidebar-scope {
|
||||
@apply px-4 py-3 border-b border-gray-100;
|
||||
}
|
||||
|
||||
.search-sidebar-filters {
|
||||
@apply px-4 py-3 border-b border-gray-100;
|
||||
}
|
||||
|
||||
.quick-filter-tags-vertical {
|
||||
@apply flex flex-wrap gap-1.5 mt-2;
|
||||
}
|
||||
|
||||
.filter-tag-vertical {
|
||||
@apply flex items-center gap-1 px-2 py-1 text-xs font-medium text-gray-600 bg-gray-50 border border-gray-200 rounded-md transition-all duration-200 hover:border-gray-300 hover:bg-gray-100;
|
||||
}
|
||||
|
||||
.search-sidebar-advanced {
|
||||
@apply px-4 py-3 border-b border-gray-100;
|
||||
}
|
||||
|
||||
.sidebar-section-toggle {
|
||||
@apply flex items-center justify-between w-full text-left;
|
||||
}
|
||||
|
||||
.sidebar-section-title {
|
||||
@apply text-xs font-medium text-gray-500 uppercase tracking-wide;
|
||||
}
|
||||
|
||||
.sidebar-advanced-content {
|
||||
@apply overflow-hidden transition-all duration-300 ease-in-out;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar-advanced-expanded {
|
||||
max-height: 200px;
|
||||
opacity: 1;
|
||||
@apply mt-3;
|
||||
}
|
||||
|
||||
.sidebar-filter-group {
|
||||
@apply mb-3;
|
||||
}
|
||||
|
||||
.sidebar-filter-label {
|
||||
@apply block text-xs text-gray-600 mb-1;
|
||||
}
|
||||
|
||||
.sidebar-filter-select {
|
||||
@apply w-full text-xs px-2.5 py-1.5 rounded-md border border-gray-200 bg-white focus:outline-none focus:ring-2 focus:ring-[#03C160]/20 focus:border-[#03C160] transition-all;
|
||||
}
|
||||
|
||||
.sidebar-checkbox {
|
||||
@apply flex items-center gap-2 text-xs text-gray-600 cursor-pointer;
|
||||
}
|
||||
|
||||
.sidebar-checkbox input {
|
||||
@apply w-3.5 h-3.5 text-[#03C160] border-gray-300 rounded focus:ring-[#03C160]/20;
|
||||
}
|
||||
|
||||
.search-sidebar-history {
|
||||
@apply px-4 py-3 border-b border-gray-100;
|
||||
}
|
||||
|
||||
.sidebar-section-header {
|
||||
@apply flex items-center justify-between mb-2;
|
||||
}
|
||||
|
||||
.sidebar-clear-btn {
|
||||
@apply text-xs text-gray-400 hover:text-gray-600 transition-colors;
|
||||
}
|
||||
|
||||
.sidebar-index-btn {
|
||||
@apply px-2 py-1 text-[10px] font-medium text-gray-600 bg-gray-50 border border-gray-200 rounded-md hover:bg-gray-100 hover:border-gray-300 transition-colors disabled:opacity-60 disabled:cursor-not-allowed whitespace-nowrap;
|
||||
}
|
||||
|
||||
.sidebar-history-list {
|
||||
@apply space-y-1;
|
||||
}
|
||||
|
||||
.sidebar-history-item {
|
||||
@apply flex items-center gap-2 w-full px-2 py-1.5 text-xs text-gray-600 rounded-md hover:bg-gray-50 transition-colors text-left;
|
||||
}
|
||||
|
||||
.search-sidebar-status {
|
||||
@apply px-4 py-2 text-xs border-b border-gray-100;
|
||||
}
|
||||
|
||||
.sidebar-status-error {
|
||||
@apply text-red-600;
|
||||
}
|
||||
|
||||
.sidebar-status-info {
|
||||
@apply text-gray-600;
|
||||
}
|
||||
|
||||
.sidebar-status-info strong {
|
||||
@apply text-[#03C160] font-semibold;
|
||||
}
|
||||
|
||||
.sidebar-status-detail {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
|
||||
.search-sidebar-results {
|
||||
@apply flex-1 overflow-y-auto min-h-0;
|
||||
}
|
||||
|
||||
.sidebar-results-list {
|
||||
@apply divide-y divide-gray-100;
|
||||
}
|
||||
|
||||
.sidebar-result-card {
|
||||
@apply px-4 py-3 cursor-pointer transition-all duration-150 hover:bg-gray-50;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.sidebar-result-row {
|
||||
@apply flex items-start gap-3;
|
||||
}
|
||||
|
||||
.sidebar-result-avatar {
|
||||
@apply w-9 h-9 rounded-md overflow-hidden bg-gray-300 flex-shrink-0;
|
||||
}
|
||||
|
||||
.sidebar-result-avatar-fallback {
|
||||
@apply w-full h-full flex items-center justify-center text-white text-[10px] font-bold bg-gray-600;
|
||||
}
|
||||
|
||||
.sidebar-result-body {
|
||||
@apply min-w-0 flex-1;
|
||||
}
|
||||
|
||||
.sidebar-result-card-selected {
|
||||
@apply bg-[#03C160]/5;
|
||||
border-left-color: #03C160;
|
||||
}
|
||||
|
||||
.sidebar-result-card-selected:hover {
|
||||
@apply bg-[#03C160]/10;
|
||||
}
|
||||
|
||||
.sidebar-result-header {
|
||||
@apply flex items-center gap-2 text-[10px] text-gray-400 mb-0.5;
|
||||
}
|
||||
|
||||
.sidebar-result-contact {
|
||||
@apply font-medium text-gray-600;
|
||||
}
|
||||
|
||||
.sidebar-result-time {
|
||||
@apply text-gray-400;
|
||||
}
|
||||
|
||||
.sidebar-result-sender {
|
||||
@apply text-[11px] text-gray-500 mb-1;
|
||||
}
|
||||
|
||||
.sidebar-result-content {
|
||||
@apply text-xs text-gray-700 line-clamp-2 leading-relaxed;
|
||||
}
|
||||
|
||||
.sidebar-load-more {
|
||||
@apply px-4 py-3;
|
||||
}
|
||||
|
||||
.sidebar-load-more-btn {
|
||||
@apply w-full text-xs px-3 py-2 rounded-md border border-gray-200 bg-white hover:bg-gray-50 text-gray-600 transition-colors disabled:opacity-50;
|
||||
}
|
||||
|
||||
.sidebar-empty-state {
|
||||
@apply flex flex-col items-center justify-center py-12 px-4 text-center;
|
||||
}
|
||||
|
||||
.sidebar-empty-icon {
|
||||
@apply w-12 h-12 text-gray-300 mb-3;
|
||||
}
|
||||
|
||||
.sidebar-empty-text {
|
||||
@apply text-sm font-medium text-gray-500 mb-1;
|
||||
}
|
||||
|
||||
.sidebar-empty-hint {
|
||||
@apply text-xs text-gray-400;
|
||||
}
|
||||
|
||||
.sidebar-initial-state {
|
||||
@apply flex flex-col items-center justify-center py-12 px-4 text-center;
|
||||
}
|
||||
|
||||
.sidebar-initial-icon {
|
||||
@apply w-16 h-16 text-gray-200 mb-4;
|
||||
}
|
||||
|
||||
.sidebar-initial-text {
|
||||
@apply text-sm text-gray-500 mb-2;
|
||||
}
|
||||
|
||||
.sidebar-initial-hint {
|
||||
@apply text-xs text-gray-400;
|
||||
}
|
||||
|
||||
.sidebar-initial-hint kbd {
|
||||
@apply px-1.5 py-0.5 bg-gray-100 border border-gray-200 rounded text-[10px] font-mono;
|
||||
}
|
||||
|
||||
/* 侧边栏滑入动画 */
|
||||
.sidebar-slide-enter-active,
|
||||
.sidebar-slide-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-slide-enter-from,
|
||||
.sidebar-slide-leave-to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar-slide-enter-to,
|
||||
.sidebar-slide-leave-from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,70 @@ export const useApi = () => {
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
const searchChatMessages = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
if (params && params.q) query.set('q', params.q)
|
||||
if (params && params.username) query.set('username', params.username)
|
||||
if (params && params.sender) query.set('sender', params.sender)
|
||||
if (params && params.session_type) query.set('session_type', params.session_type)
|
||||
if (params && params.limit != null) query.set('limit', String(params.limit))
|
||||
if (params && params.offset != null) query.set('offset', String(params.offset))
|
||||
if (params && params.start_time != null) query.set('start_time', String(params.start_time))
|
||||
if (params && params.end_time != null) query.set('end_time', String(params.end_time))
|
||||
if (params && params.render_types) query.set('render_types', params.render_types)
|
||||
if (params && params.include_hidden != null) query.set('include_hidden', String(!!params.include_hidden))
|
||||
if (params && params.include_official != null) query.set('include_official', String(!!params.include_official))
|
||||
if (params && params.session_limit != null) query.set('session_limit', String(params.session_limit))
|
||||
if (params && params.per_chat_scan != null) query.set('per_chat_scan', String(params.per_chat_scan))
|
||||
if (params && params.scan_limit != null) query.set('scan_limit', String(params.scan_limit))
|
||||
const url = '/chat/search' + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
const listChatSearchSenders = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
if (params && params.username) query.set('username', params.username)
|
||||
if (params && params.session_type) query.set('session_type', params.session_type)
|
||||
if (params && params.limit != null) query.set('limit', String(params.limit))
|
||||
if (params && params.q) query.set('q', params.q)
|
||||
if (params && params.message_q) query.set('message_q', params.message_q)
|
||||
if (params && params.start_time != null) query.set('start_time', String(params.start_time))
|
||||
if (params && params.end_time != null) query.set('end_time', String(params.end_time))
|
||||
if (params && params.render_types) query.set('render_types', params.render_types)
|
||||
if (params && params.include_hidden != null) query.set('include_hidden', String(!!params.include_hidden))
|
||||
if (params && params.include_official != null) query.set('include_official', String(!!params.include_official))
|
||||
const url = '/chat/search-index/senders' + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
const getChatSearchIndexStatus = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
const url = '/chat/search-index/status' + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
const buildChatSearchIndex = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
if (params && params.rebuild != null) query.set('rebuild', String(!!params.rebuild))
|
||||
const url = '/chat/search-index/build' + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url, { method: 'POST' })
|
||||
}
|
||||
|
||||
const getChatMessagesAround = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
if (params && params.username) query.set('username', params.username)
|
||||
if (params && params.anchor_id) query.set('anchor_id', params.anchor_id)
|
||||
if (params && params.before != null) query.set('before', String(params.before))
|
||||
if (params && params.after != null) query.set('after', String(params.after))
|
||||
const url = '/chat/messages/around' + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
const openChatMediaFolder = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
@@ -108,23 +172,16 @@ export const useApi = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// 获取图片解密密钥
|
||||
const getMediaKeys = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
if (params && params.force_extract) query.set('force_extract', 'true')
|
||||
const url = '/media/keys' + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
// 保存图片解密密钥
|
||||
const saveMediaKeys = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
if (params && params.xor_key) query.set('xor_key', params.xor_key)
|
||||
if (params && params.aes_key) query.set('aes_key', params.aes_key)
|
||||
const url = '/media/keys' + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url, { method: 'POST', body: { account: params.account, force_extract: false } })
|
||||
return await request('/media/keys', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
account: params.account || null,
|
||||
xor_key: params.xor_key || '',
|
||||
aes_key: params.aes_key || null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 批量解密所有图片
|
||||
@@ -183,9 +240,13 @@ export const useApi = () => {
|
||||
listChatAccounts,
|
||||
listChatSessions,
|
||||
listChatMessages,
|
||||
searchChatMessages,
|
||||
getChatSearchIndexStatus,
|
||||
buildChatSearchIndex,
|
||||
listChatSearchSenders,
|
||||
getChatMessagesAround,
|
||||
openChatMediaFolder,
|
||||
downloadChatEmoji,
|
||||
getMediaKeys,
|
||||
saveMediaKeys,
|
||||
decryptAllMedia,
|
||||
createChatExport,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,7 +55,7 @@
|
||||
<svg class="w-4 h-4 mr-1 text-[#10AEEF]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
使用 <a href="https://github.com/gzygood/DbkeyHook" target="_blank" class="text-[#07C160] hover:text-[#06AD56]">DbkeyHook</a> 等工具获取的64位十六进制字符串
|
||||
使用 <a href="https://github.com/ycccccccy/wx_key" target="_blank" class="text-[#07C160] hover:text-[#06AD56]">wx_key</a> 等工具获取的64位十六进制字符串
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤2: 图片密钥获取 -->
|
||||
<!-- 步骤2: 填写图片密钥 -->
|
||||
<div v-if="currentStep === 1" class="bg-white rounded-2xl border border-[#EDEDED]">
|
||||
<div class="p-8">
|
||||
<div class="flex items-center mb-6">
|
||||
@@ -124,7 +124,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-[#000000e6]">图片密钥</h2>
|
||||
<p class="text-sm text-[#7F7F7F]">获取图片解密所需的密钥</p>
|
||||
<p class="text-sm text-[#7F7F7F]">请使用 wx_key 获取后在此填写</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -136,152 +136,64 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<div class="text-sm text-blue-800">
|
||||
<div class="font-medium mb-2">获取密钥小提示</div>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li><strong>AES 密钥</strong>仅在部分图片(V4-V2)解密时需要;仅有 XOR 也可以先继续下一步,失败原因会提示。</li>
|
||||
<li>获取 AES 需要微信正在运行;部分环境需<strong>以管理员身份运行后端</strong>(否则可能无法读取微信进程内存)。</li>
|
||||
<li>若一直获取不到 AES:完全退出微信 → 重新启动并登录 → 打开朋友圈图片并点开大图 2-3 次 → 回到本页点<strong>强制重新提取</strong>。</li>
|
||||
</ul>
|
||||
使用 <a href="https://github.com/ycccccccy/wx_key" target="_blank" class="underline">wx_key</a> 获取密钥;AES 可选(V4-V2 需要)。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 密钥信息显示 -->
|
||||
<div class="space-y-4 mb-6">
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-sm font-medium text-[#000000e6]">XOR 密钥</span>
|
||||
<button
|
||||
type="button"
|
||||
class="font-mono text-sm px-3 py-1 bg-white rounded border border-[#EDEDED] transition-colors"
|
||||
:class="mediaKeys.xor_key ? 'cursor-pointer hover:bg-gray-50' : 'cursor-not-allowed opacity-60'"
|
||||
:title="mediaKeys.xor_key ? '点击复制' : ''"
|
||||
@click="copyKey('XOR 密钥', mediaKeys.xor_key)"
|
||||
>
|
||||
{{ mediaKeys.xor_key || '未获取' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm font-medium text-[#000000e6]">AES 密钥</span>
|
||||
<button
|
||||
type="button"
|
||||
class="font-mono text-sm px-3 py-1 bg-white rounded border border-[#EDEDED] transition-colors"
|
||||
:class="mediaKeys.aes_key ? 'cursor-pointer hover:bg-gray-50' : 'cursor-not-allowed opacity-60'"
|
||||
:title="mediaKeys.aes_key ? '点击复制' : ''"
|
||||
@click="copyKey('AES 密钥', mediaKeys.aes_key)"
|
||||
>
|
||||
{{ mediaKeys.aes_key || '未获取' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="mediaKeys.message" class="text-sm text-[#7F7F7F] flex items-start">
|
||||
<svg class="w-4 h-4 mr-2 mt-0.5 flex-shrink-0 text-[#10AEEF]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ mediaKeys.message }}
|
||||
</div>
|
||||
|
||||
<div v-if="copyMessage" class="text-sm text-[#07C160] flex items-start">
|
||||
<svg class="w-4 h-4 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
{{ copyMessage }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手动输入密钥(自动获取失败时) -->
|
||||
<!-- 填写密钥 -->
|
||||
<div class="mb-6">
|
||||
<details class="text-sm">
|
||||
<summary class="cursor-pointer text-[#7F7F7F] hover:text-[#000000e6]">
|
||||
<span class="ml-1">手动输入密钥(自动获取失败时)</span>
|
||||
</summary>
|
||||
<div class="mt-3 bg-gray-50 rounded-lg p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[#000000e6] mb-2">XOR 密钥 <span class="text-red-500">*</span></label>
|
||||
<input
|
||||
v-model="manualKeys.xor_key"
|
||||
type="text"
|
||||
placeholder="例如:0xA5 或 A5"
|
||||
class="w-full px-4 py-2 border border-[#EDEDED] rounded-lg focus:ring-2 focus:ring-[#10AEEF] focus:border-transparent font-mono"
|
||||
/>
|
||||
<p v-if="manualKeyErrors.xor_key" class="text-xs text-red-500 mt-1">{{ manualKeyErrors.xor_key }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[#000000e6] mb-2">AES 密钥(可选)</label>
|
||||
<input
|
||||
v-model="manualKeys.aes_key"
|
||||
type="text"
|
||||
placeholder="16 个字符(V4-V2 需要)"
|
||||
class="w-full px-4 py-2 border border-[#EDEDED] rounded-lg focus:ring-2 focus:ring-[#10AEEF] focus:border-transparent font-mono"
|
||||
/>
|
||||
<p v-if="manualKeyErrors.aes_key" class="text-xs text-red-500 mt-1">{{ manualKeyErrors.aes_key }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[#000000e6] mb-2">XOR(必填)</label>
|
||||
<input
|
||||
v-model="manualKeys.xor_key"
|
||||
type="text"
|
||||
placeholder="例如:0xA5"
|
||||
class="w-full px-4 py-2 border border-[#EDEDED] rounded-lg focus:ring-2 focus:ring-[#10AEEF] focus:border-transparent font-mono"
|
||||
/>
|
||||
<p v-if="manualKeyErrors.xor_key" class="text-xs text-red-500 mt-1">{{ manualKeyErrors.xor_key }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="applyManualKeys({ save: false })"
|
||||
class="inline-flex items-center px-4 py-2 bg-white text-[#10AEEF] border border-[#10AEEF] rounded-lg font-medium hover:bg-gray-50 transition-all duration-200"
|
||||
>
|
||||
使用手动密钥
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="applyManualKeys({ save: true })"
|
||||
:disabled="manualSaving"
|
||||
class="inline-flex items-center px-4 py-2 bg-[#10AEEF] text-white rounded-lg font-medium hover:bg-[#0D9BD9] transition-all duration-200 disabled:opacity-50"
|
||||
>
|
||||
{{ manualSaving ? '保存中...' : '保存并使用' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="clearManualKeys"
|
||||
class="inline-flex items-center px-4 py-2 bg-white text-[#7F7F7F] border border-[#EDEDED] rounded-lg font-medium hover:bg-gray-50 transition-all duration-200"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-[#7F7F7F] mt-3">
|
||||
<p>说明:</p>
|
||||
<ul class="list-disc list-inside space-y-1 mt-1">
|
||||
<li>XOR 是 1 字节十六进制(00-FF)。</li>
|
||||
<li>AES 仅在部分图片(V4-V2)解密时需要;输入任意 16 个字符即可(会自动截取前 16 位)。</li>
|
||||
</ul>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[#000000e6] mb-2">AES(可选)</label>
|
||||
<input
|
||||
v-model="manualKeys.aes_key"
|
||||
type="text"
|
||||
placeholder="16 个字符(V4-V2 需要)"
|
||||
class="w-full px-4 py-2 border border-[#EDEDED] rounded-lg focus:ring-2 focus:ring-[#10AEEF] focus:border-transparent font-mono"
|
||||
/>
|
||||
<p v-if="manualKeyErrors.aes_key" class="text-xs text-red-500 mt-1">{{ manualKeyErrors.aes_key }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="flex flex-wrap gap-3 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="applyManualKeys({ save: true })"
|
||||
:disabled="manualSaving"
|
||||
class="inline-flex items-center px-4 py-2 bg-[#10AEEF] text-white rounded-lg font-medium hover:bg-[#0D9BD9] transition-all duration-200 disabled:opacity-50"
|
||||
>
|
||||
{{ manualSaving ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="clearManualKeys"
|
||||
class="inline-flex items-center px-4 py-2 bg-white text-[#7F7F7F] border border-[#EDEDED] rounded-lg font-medium hover:bg-gray-50 transition-all duration-200"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="mediaKeys.message" class="text-xs text-[#7F7F7F] mt-3">{{ mediaKeys.message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex gap-3 justify-center pt-4 border-t border-[#EDEDED]">
|
||||
<button
|
||||
@click="fetchMediaKeys(false)"
|
||||
:disabled="mediaLoading"
|
||||
class="inline-flex items-center px-6 py-3 bg-[#10AEEF] text-white rounded-lg font-medium hover:bg-[#0D9BD9] transition-all duration-200 disabled:opacity-50"
|
||||
>
|
||||
<svg v-if="mediaLoading" class="w-5 h-5 mr-2 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
{{ mediaLoading ? '获取中...' : '获取密钥' }}
|
||||
</button>
|
||||
<button
|
||||
@click="fetchMediaKeys(true)"
|
||||
:disabled="mediaLoading"
|
||||
class="inline-flex items-center px-6 py-3 bg-white text-[#10AEEF] border border-[#10AEEF] rounded-lg font-medium hover:bg-gray-50 transition-all duration-200 disabled:opacity-50"
|
||||
>
|
||||
强制重新提取
|
||||
</button>
|
||||
<button
|
||||
@click="goToStep(2)"
|
||||
@click="goToMediaDecryptStep"
|
||||
class="inline-flex items-center px-6 py-3 bg-[#07C160] text-white rounded-lg font-medium hover:bg-[#06AD56] transition-all duration-200"
|
||||
>
|
||||
下一步
|
||||
@@ -403,7 +315,7 @@
|
||||
<p class="mb-2">可能的失败原因:</p>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li><strong>解密后非有效图片</strong>:文件不是图片格式(如视频缩略图损坏)</li>
|
||||
<li><strong>V4-V2版本需要AES密钥</strong>:需要微信运行,且部分环境需以管理员身份运行后端才能提取;可尝试打开朋友圈图片并点开大图 2-3 次后再提取</li>
|
||||
<li><strong>V4-V2版本需要AES密钥</strong>:请使用 wx_key 获取 AES 密钥后再重试解密</li>
|
||||
<li><strong>未知加密版本</strong>:新版微信使用了不支持的加密方式</li>
|
||||
<li><strong>文件为空</strong>:原始文件损坏或为空文件</li>
|
||||
</ul>
|
||||
@@ -482,16 +394,17 @@
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
const { decryptDatabase, getMediaKeys, decryptAllMedia, saveMediaKeys } = useApi()
|
||||
const { decryptDatabase, saveMediaKeys } = useApi()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const currentStep = ref(0)
|
||||
const mediaAccount = ref('')
|
||||
|
||||
// 步骤定义
|
||||
const steps = [
|
||||
{ title: '数据库解密' },
|
||||
{ title: '图片密钥' },
|
||||
{ title: '填写图片密钥' },
|
||||
{ title: '图片解密' }
|
||||
]
|
||||
|
||||
@@ -513,11 +426,8 @@ const mediaKeys = reactive({
|
||||
aes_key: '',
|
||||
message: ''
|
||||
})
|
||||
const mediaLoading = ref(false)
|
||||
const copyMessage = ref('')
|
||||
let copyMessageTimer = null
|
||||
|
||||
// 手动输入密钥(自动获取失败时使用)
|
||||
// 手动输入密钥(从 wx_key 获取)
|
||||
const manualKeys = reactive({
|
||||
xor_key: '',
|
||||
aes_key: ''
|
||||
@@ -550,36 +460,39 @@ const applyManualKeys = async (options = { save: false }) => {
|
||||
manualKeyErrors.aes_key = ''
|
||||
error.value = ''
|
||||
|
||||
const xor = normalizeXorKey(manualKeys.xor_key)
|
||||
if (!xor.ok) {
|
||||
manualKeyErrors.xor_key = xor.message
|
||||
return
|
||||
}
|
||||
|
||||
const aes = normalizeAesKey(manualKeys.aes_key)
|
||||
if (!aes.ok) {
|
||||
manualKeyErrors.aes_key = aes.message
|
||||
return
|
||||
}
|
||||
|
||||
mediaKeys.xor_key = xor.value
|
||||
mediaKeys.aes_key = aes.value
|
||||
mediaKeys.message = options?.save ? '已保存并使用手动密钥' : '已使用手动密钥(仅本次)'
|
||||
const hasXor = !!String(manualKeys.xor_key || '').trim()
|
||||
if (options?.save || hasXor) {
|
||||
const xor = normalizeXorKey(manualKeys.xor_key)
|
||||
if (!xor.ok) {
|
||||
manualKeyErrors.xor_key = xor.message
|
||||
return
|
||||
}
|
||||
mediaKeys.xor_key = xor.value
|
||||
}
|
||||
|
||||
if (aes.value) {
|
||||
mediaKeys.aes_key = aes.value
|
||||
}
|
||||
|
||||
mediaKeys.message = options?.save ? '已保存' : '已应用'
|
||||
|
||||
if (!options?.save) return
|
||||
if (!aes.value) {
|
||||
mediaKeys.message = '已使用手动密钥(未保存:AES 为空)'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
manualSaving.value = true
|
||||
await saveMediaKeys({
|
||||
xor_key: xor.value,
|
||||
aes_key: aes.value
|
||||
account: mediaAccount.value || null,
|
||||
xor_key: mediaKeys.xor_key,
|
||||
aes_key: aes.value || null
|
||||
})
|
||||
} catch (e) {
|
||||
mediaKeys.message = '已使用手动密钥(保存失败,可继续解密)'
|
||||
mediaKeys.message = '保存失败(可继续解密)'
|
||||
} finally {
|
||||
manualSaving.value = false
|
||||
}
|
||||
@@ -590,6 +503,9 @@ const clearManualKeys = () => {
|
||||
manualKeys.aes_key = ''
|
||||
manualKeyErrors.xor_key = ''
|
||||
manualKeyErrors.aes_key = ''
|
||||
mediaKeys.xor_key = ''
|
||||
mediaKeys.aes_key = ''
|
||||
mediaKeys.message = ''
|
||||
}
|
||||
|
||||
// 图片解密相关
|
||||
@@ -665,10 +581,18 @@ const handleDecrypt = async () => {
|
||||
if (process.client && typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('decryptResult', JSON.stringify(result))
|
||||
}
|
||||
// 进入图片密钥获取步骤
|
||||
// 记录当前账号(用于图片解密/密钥保存)
|
||||
try {
|
||||
const accounts = Object.keys(result.account_results || {})
|
||||
if (accounts.length > 0) mediaAccount.value = accounts[0]
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// 进入图片密钥填写步骤
|
||||
clearManualKeys()
|
||||
currentStep.value = 1
|
||||
// 自动尝试获取图片密钥
|
||||
fetchMediaKeys(false)
|
||||
mediaKeys.message = ''
|
||||
} else if (result.status === 'failed') {
|
||||
if (result.failure_count > 0 && result.success_count === 0) {
|
||||
error.value = result.message || '所有文件解密失败'
|
||||
@@ -685,75 +609,6 @@ const handleDecrypt = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取图片密钥
|
||||
const fetchMediaKeys = async (forceExtract = false) => {
|
||||
mediaLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const result = await getMediaKeys({ force_extract: forceExtract })
|
||||
|
||||
if (result.status === 'success') {
|
||||
mediaKeys.xor_key = result.xor_key || ''
|
||||
mediaKeys.aes_key = result.aes_key || ''
|
||||
mediaKeys.message = result.message || ''
|
||||
} else {
|
||||
error.value = result.message || '获取密钥失败'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message || '获取密钥过程中发生错误'
|
||||
} finally {
|
||||
mediaLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const _copyToClipboard = async (text) => {
|
||||
if (!process.client || typeof window === 'undefined') return false
|
||||
if (!text) return false
|
||||
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore and fallback below
|
||||
}
|
||||
|
||||
try {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
textarea.style.left = '-9999px'
|
||||
textarea.style.top = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
textarea.setSelectionRange(0, textarea.value.length)
|
||||
const ok = document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
return ok
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const _setCopyMessage = (message) => {
|
||||
copyMessage.value = message
|
||||
if (copyMessageTimer) clearTimeout(copyMessageTimer)
|
||||
copyMessageTimer = setTimeout(() => {
|
||||
copyMessage.value = ''
|
||||
copyMessageTimer = null
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const copyKey = async (label, value) => {
|
||||
if (!value) return
|
||||
const ok = await _copyToClipboard(value)
|
||||
_setCopyMessage(ok ? `${label}已复制` : `${label}复制失败,请手动复制`)
|
||||
}
|
||||
|
||||
// 批量解密所有图片(使用SSE实时进度)
|
||||
const decryptAllImages = async () => {
|
||||
mediaDecrypting.value = true
|
||||
@@ -773,6 +628,7 @@ const decryptAllImages = async () => {
|
||||
try {
|
||||
// 构建SSE URL
|
||||
const params = new URLSearchParams()
|
||||
if (mediaAccount.value) params.set('account', mediaAccount.value)
|
||||
if (mediaKeys.xor_key) params.set('xor_key', mediaKeys.xor_key)
|
||||
if (mediaKeys.aes_key) params.set('aes_key', mediaKeys.aes_key)
|
||||
const url = `http://localhost:8000/api/media/decrypt_all_stream?${params.toString()}`
|
||||
@@ -830,10 +686,15 @@ const decryptAllImages = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到指定步骤
|
||||
const goToStep = (step) => {
|
||||
currentStep.value = step
|
||||
// 从密钥步骤进入图片解密步骤
|
||||
const goToMediaDecryptStep = async () => {
|
||||
error.value = ''
|
||||
// 用户填写了任一项时,尝试校验并应用(未填写则允许直接进入,后端会使用已保存密钥或报错提示)
|
||||
if (manualKeys.xor_key || manualKeys.aes_key) {
|
||||
await applyManualKeys({ save: false })
|
||||
if (manualKeyErrors.xor_key || manualKeyErrors.aes_key) return
|
||||
}
|
||||
currentStep.value = 2
|
||||
}
|
||||
|
||||
// 跳过图片解密,直接查看聊天记录
|
||||
@@ -852,6 +713,9 @@ onMounted(() => {
|
||||
if (account.data_dir) {
|
||||
formData.db_storage_path = account.data_dir + '\\db_storage'
|
||||
}
|
||||
if (account.account_name) {
|
||||
mediaAccount.value = account.account_name
|
||||
}
|
||||
// 清除sessionStorage
|
||||
sessionStorage.removeItem('selectedAccount')
|
||||
} catch (e) {
|
||||
|
||||
@@ -208,11 +208,12 @@ import { ref, onMounted, computed } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useAppStore } from '~/stores/app'
|
||||
|
||||
const { detectWechat, detectCurrentAccount } = useApi()
|
||||
const { detectWechat } = useApi()
|
||||
const appStore = useAppStore()
|
||||
const loading = ref(false)
|
||||
const detectionResult = ref(null)
|
||||
const customPath = ref('')
|
||||
const STORAGE_KEY = 'wechat_data_root_path'
|
||||
|
||||
// 计算属性:将当前登录账号排在第一位
|
||||
const sortedAccounts = computed(() => {
|
||||
@@ -244,24 +245,51 @@ const startDetection = async () => {
|
||||
}
|
||||
|
||||
// 检测微信安装信息
|
||||
const result = await detectWechat(params)
|
||||
let result = await detectWechat(params)
|
||||
|
||||
// 如果用户提供/缓存的路径不可用,自动回退到“自动检测”(避免因错误缓存导致一直检测不到)
|
||||
const hasCustomPath = !!(params.data_root_path && String(params.data_root_path).trim())
|
||||
const accounts0 = Array.isArray(result?.data?.accounts) ? result.data.accounts : []
|
||||
if (hasCustomPath && (result?.status !== 'success' || accounts0.length === 0)) {
|
||||
try {
|
||||
const fallback = await detectWechat({})
|
||||
const accounts1 = Array.isArray(fallback?.data?.accounts) ? fallback.data.accounts : []
|
||||
if (fallback?.status === 'success' && accounts1.length > 0) {
|
||||
result = fallback
|
||||
if (process.client) {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
} catch {}
|
||||
}
|
||||
customPath.value = ''
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
detectionResult.value = result
|
||||
|
||||
// 如果检测成功,同时检测当前登录账号
|
||||
if (result.status === 'success') {
|
||||
try {
|
||||
const currentAccountResult = await detectCurrentAccount(params)
|
||||
if (currentAccountResult.status === 'success') {
|
||||
// 保存当前账号信息到状态管理
|
||||
appStore.setCurrentAccount(currentAccountResult.data)
|
||||
const current = result?.data?.current_account || null
|
||||
if (current) {
|
||||
appStore.setCurrentAccount(current)
|
||||
}
|
||||
|
||||
// 同时更新检测结果中的当前账号信息
|
||||
if (detectionResult.value.data) {
|
||||
detectionResult.value.data.current_account = currentAccountResult.data
|
||||
if (process.client) {
|
||||
try {
|
||||
let toSave = String(customPath.value || '').trim()
|
||||
if (!toSave) {
|
||||
const accounts = Array.isArray(result?.data?.accounts) ? result.data.accounts : []
|
||||
for (const acc of accounts) {
|
||||
const dataDir = String(acc?.data_dir || '').trim()
|
||||
if (!dataDir) continue
|
||||
toSave = dataDir.replace(/[\\/][^\\/]+$/, '')
|
||||
if (toSave) break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (accountErr) {
|
||||
console.error('检测当前登录账号失败:', accountErr)
|
||||
if (toSave) {
|
||||
localStorage.setItem(STORAGE_KEY, toSave)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -322,6 +350,12 @@ const formatTime = (timeString) => {
|
||||
|
||||
// 页面加载时自动检测
|
||||
onMounted(() => {
|
||||
if (process.client) {
|
||||
try {
|
||||
const saved = String(localStorage.getItem(STORAGE_KEY) || '').trim()
|
||||
if (saved) customPath.value = saved
|
||||
} catch {}
|
||||
}
|
||||
startDetection()
|
||||
|
||||
// 调试:检查各元素高度
|
||||
|
||||
Reference in New Issue
Block a user