浏览代码

V2.4.2 映射账号检索,日志,下载二维码

liuq 16 小时之前
父节点
当前提交
29933df098

+ 30 - 1
backend/app/api/v1/endpoints/apps.py

@@ -15,7 +15,7 @@ from sqlalchemy import desc, or_, func
 from app.api.v1 import deps
 from app.core import security
 from app.models.application import Application, ProtocolType
-from app.models.user import User
+from app.models.user import User, UserStatus
 from app.models.mapping import AppUserMapping
 from app.models.app_category import AppCategory
 from app.core.utils import generate_english_name, get_client_ip
@@ -787,6 +787,9 @@ def read_mappings(
     app_id: int,
     skip: int = 0,
     limit: int = 10,
+    search: Optional[str] = Query(None, description="按手机号、映射账号、映射邮箱模糊匹配"),
+    mapping_is_active: Optional[bool] = Query(None, description="映射是否启用,不传不筛选"),
+    user_status: Optional[str] = Query(None, description="统一认证账号状态:ACTIVE|PENDING|DISABLED|DELETED"),
     current_user: User = Depends(deps.get_current_active_user),
 ):
     """
@@ -800,6 +803,32 @@ def read_mappings(
         raise HTTPException(status_code=403, detail="权限不足")
 
     query = db.query(AppUserMapping).filter(AppUserMapping.app_id == app_id)
+    if mapping_is_active is not None:
+        query = query.filter(AppUserMapping.is_active == mapping_is_active)
+
+    need_user_join = bool(search and search.strip()) or bool(user_status and user_status.strip())
+    if need_user_join:
+        query = query.outerjoin(User, AppUserMapping.user_id == User.id)
+
+    if search and search.strip():
+        term = f"%{search.strip()}%"
+        query = query.filter(
+            or_(
+                AppUserMapping.mapped_key.ilike(term),
+                AppUserMapping.mapped_email.ilike(term),
+                User.mobile.ilike(term),
+            )
+        )
+
+    if user_status and user_status.strip():
+        st = user_status.strip().upper()
+        if st == "DELETED":
+            query = query.filter(User.id.is_(None))
+        elif st in ("ACTIVE", "PENDING", "DISABLED"):
+            query = query.filter(User.status == UserStatus[st])
+        else:
+            raise HTTPException(status_code=400, detail="无效的 user_status")
+
     total = query.count()
     mappings = query.order_by(desc(AppUserMapping.id)).offset(skip).limit(limit).all()
     

+ 8 - 3
backend/app/services/backup_service.py

@@ -1,5 +1,6 @@
 import os
 import shutil
+import uuid
 import zipfile
 import pandas as pd
 import io
@@ -39,8 +40,10 @@ class BackupService:
         zip_filename = f"{base_filename}.zip"
         zip_filepath = os.path.join(BACKUP_DIR, zip_filename)
         
-        # Temp dir for csvs
-        temp_dir = os.path.join(BACKUP_DIR, f"temp_{timestamp}")
+        # Temp dir for csvs (unique per run to avoid cross-process rmtree races on same second)
+        temp_dir = os.path.join(
+            BACKUP_DIR, f"temp_{timestamp}_{uuid.uuid4().hex}"
+        )
         os.makedirs(temp_dir, exist_ok=True)
         
         try:
@@ -182,7 +185,9 @@ class BackupService:
                     BackupService.perform_auto_backup,
                     trigger=trigger,
                     id=job_id,
-                    replace_existing=True
+                    replace_existing=True,
+                    max_instances=1,
+                    coalesce=True,
                 )
             except ValueError:
                 # Handle invalid time format if necessary

+ 25 - 2
frontend/src/api/apps.ts

@@ -135,8 +135,31 @@ export const viewSecret = (id: number, password: string) => {
 }
 
 // Mappings
-export const getMappings = (appId: number, skip = 0, limit = 10) => {
-  return api.get<MappingListResponse>(`/apps/${appId}/mappings`, { params: { skip, limit } })
+export interface GetMappingsFilters {
+  search?: string
+  mapping_is_active?: boolean | null
+  user_status?: string
+}
+
+export const getMappings = (
+  appId: number,
+  skip = 0,
+  limit = 10,
+  filters?: GetMappingsFilters
+) => {
+  const params: Record<string, string | number | boolean> = { skip, limit }
+  if (filters) {
+    if (filters.search !== undefined && filters.search.trim() !== '') {
+      params.search = filters.search.trim()
+    }
+    if (filters.mapping_is_active === true || filters.mapping_is_active === false) {
+      params.mapping_is_active = filters.mapping_is_active
+    }
+    if (filters.user_status !== undefined && filters.user_status.trim() !== '') {
+      params.user_status = filters.user_status.trim()
+    }
+  }
+  return api.get<MappingListResponse>(`/apps/${appId}/mappings`, { params })
 }
 
 export const createMapping = (appId: number, data: { mobile: string, mapped_key?: string, mapped_email?: string, password: string }) => {

+ 146 - 0
frontend/src/views/Dashboard.vue

@@ -115,6 +115,40 @@
               <!-- Breadcrumb placeholder -->
             </div>
             <div class="user-actions">
+              <el-popover
+                placement="bottom"
+                :width="260"
+                trigger="hover"
+                @show="handleDownloadPopoverShow"
+              >
+                <template #reference>
+                  <el-button
+                    type="primary"
+                    link
+                    class="header-download-link"
+                    :disabled="downloadLinksLoading || !clientDownloadHref"
+                    @click="openClientDownload"
+                  >
+                    下载客户端
+                  </el-button>
+                </template>
+                <div class="download-popover">
+                  <div v-if="downloadQrDataUrl" class="download-qr-wrap">
+                    <img :src="downloadQrDataUrl" alt="客户端下载二维码" class="download-qr-img" />
+                  </div>
+                  <p v-else class="download-qr-empty">暂无可用下载链接</p>
+                  <div class="download-popover-actions">
+                    <el-button
+                      size="small"
+                      :disabled="!downloadQrDataUrl || copyDownloadQrLoading"
+                      :loading="copyDownloadQrLoading"
+                      @click="copyDownloadQrImage"
+                    >
+                      复制图片
+                    </el-button>
+                  </div>
+                </div>
+              </el-popover>
               <el-dropdown trigger="click" @command="handleCommand">
                 <span class="el-dropdown-link">
                   <span v-if="user" class="username">{{ user.mobile }}</span>
@@ -198,12 +232,95 @@ import { Grid, List, User, ArrowDown, Connection, Monitor, Document, Download, R
 import { ElMessage, FormInstance, FormRules } from 'element-plus'
 import QRCode from 'qrcode'
 import api from '../utils/request'
+import { getDownloadLinks, type DownloadLinksPublic } from '../api/public'
 import { useMessageStore } from '../store/message'
 
 const router = useRouter()
 const authStore = useAuthStore()
 const messageStore = useMessageStore()
 const user = computed(() => authStore.user)
+const downloadLinks = ref<DownloadLinksPublic | null>(null)
+const downloadLinksLoading = ref(false)
+const downloadQrDataUrl = ref('')
+const copyDownloadQrLoading = ref(false)
+
+const isMobileUa = () =>
+  /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
+
+const clientDownloadHref = computed(() => {
+  const links = downloadLinks.value
+  if (!links) return ''
+  if (isMobileUa()) {
+    return links.mobile || links.pc || ''
+  }
+  return links.pc || links.mobile || ''
+})
+
+const qrDownloadHref = computed(() => {
+  const links = downloadLinks.value
+  if (!links) return ''
+  // 扫码场景默认给移动端(安卓)下载地址
+  return links.mobile || links.pc || ''
+})
+
+const ensureDownloadLinksLoaded = async () => {
+  if (downloadLinks.value || downloadLinksLoading.value) return
+  downloadLinksLoading.value = true
+  try {
+    const res = await getDownloadLinks()
+    downloadLinks.value = res.data
+  } catch {
+    downloadLinks.value = null
+  } finally {
+    downloadLinksLoading.value = false
+  }
+}
+
+const ensureDownloadQrGenerated = async () => {
+  if (!qrDownloadHref.value) {
+    downloadQrDataUrl.value = ''
+    return
+  }
+  if (downloadQrDataUrl.value) return
+  downloadQrDataUrl.value = await QRCode.toDataURL(qrDownloadHref.value, {
+    width: 220,
+    margin: 2,
+    errorCorrectionLevel: 'M'
+  })
+}
+
+const handleDownloadPopoverShow = async () => {
+  await ensureDownloadLinksLoaded()
+  await ensureDownloadQrGenerated()
+}
+
+const openClientDownload = async () => {
+  await ensureDownloadLinksLoaded()
+  if (!clientDownloadHref.value) {
+    ElMessage.warning('暂无可用下载链接')
+    return
+  }
+  window.open(clientDownloadHref.value, '_blank', 'noopener,noreferrer')
+}
+
+const copyDownloadQrImage = async () => {
+  if (!downloadQrDataUrl.value) return
+  if (!window.isSecureContext || !('clipboard' in navigator) || !('ClipboardItem' in window)) {
+    ElMessage.warning('当前环境不支持复制图片,请手动保存二维码')
+    return
+  }
+
+  copyDownloadQrLoading.value = true
+  try {
+    const qrBlob = await (await fetch(downloadQrDataUrl.value)).blob()
+    await navigator.clipboard.write([new ClipboardItem({ 'image/png': qrBlob })])
+    ElMessage.success('二维码图片已复制')
+  } catch {
+    ElMessage.error('复制失败,请检查浏览器权限')
+  } finally {
+    copyDownloadQrLoading.value = false
+  }
+}
 
 // Logout & Menu Command
 const handleCommand = (command: string) => {
@@ -329,6 +446,7 @@ onMounted(() => {
   }
   messageStore.initWebSocket()
   messageStore.fetchUnreadCount()
+  ensureDownloadLinksLoaded()
 })
 
 onUnmounted(() => {
@@ -430,6 +548,10 @@ onUnmounted(() => {
 .user-actions {
   display: flex;
   align-items: center;
+  gap: 12px;
+}
+.header-download-link {
+  font-size: 14px;
 }
 .el-dropdown-link {
   cursor: pointer;
@@ -471,6 +593,30 @@ onUnmounted(() => {
   font-size: 13px;
   color: #909399;
 }
+.download-popover {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+.download-qr-wrap {
+  display: flex;
+  justify-content: center;
+}
+.download-qr-img {
+  width: 220px;
+  height: 220px;
+  display: block;
+}
+.download-qr-empty {
+  margin: 0;
+  text-align: center;
+  color: #909399;
+  font-size: 13px;
+}
+.download-popover-actions {
+  display: flex;
+  justify-content: center;
+}
 .el-main {
   background-color: #f0f2f5;
   padding: 20px;

+ 61 - 2
frontend/src/views/apps/MappingImport.vue

@@ -13,6 +13,39 @@
         <div class="toolbar">
           <el-button type="primary" icon="Plus" @click="openAddDialog">手动添加</el-button>
           <el-button type="success" icon="Download" @click="handleExport">导出 Excel</el-button>
+          <el-select
+            v-model="filterMappingActive"
+            clearable
+            placeholder="映射状态"
+            style="width: 130px"
+            @change="applyMappingSearch"
+          >
+            <el-option label="启用" :value="true" />
+            <el-option label="禁用" :value="false" />
+          </el-select>
+          <el-select
+            v-model="filterUserStatus"
+            clearable
+            placeholder="统一认证状态"
+            style="width: 150px"
+            @change="applyMappingSearch"
+          >
+            <el-option label="已激活" value="ACTIVE" />
+            <el-option label="待审核" value="PENDING" />
+            <el-option label="已禁用" value="DISABLED" />
+            <el-option label="无用户" value="DELETED" />
+          </el-select>
+          <div class="toolbar-search">
+            <el-input
+              v-model="searchKeyword"
+              clearable
+              placeholder="搜索手机号、第三方账号、邮箱"
+              style="width: 280px"
+              @clear="handleMappingSearchClear"
+              @keyup.enter="applyMappingSearch"
+            />
+            <el-button type="primary" :icon="Search" @click="applyMappingSearch">搜索</el-button>
+          </div>
         </div>
 
         <el-table :data="mappings" v-loading="loading" stripe border style="width: 100%; margin-top: 15px;">
@@ -410,7 +443,8 @@ const activeTab = ref('list')
 const USER_STATUS_LABELS: Record<string, string> = {
   ACTIVE: '已激活',
   PENDING: '待审核',
-  DISABLED: '已禁用'
+  DISABLED: '已禁用',
+  DELETED: '无用户'
 }
 
 function userStatusLabel(status: string | undefined | null): string {
@@ -431,12 +465,19 @@ const loading = ref(false)
 const total = ref(0)
 const currentPage = ref(1)
 const pageSize = ref(10)
+const searchKeyword = ref('')
+const filterMappingActive = ref<boolean | undefined>(undefined)
+const filterUserStatus = ref<string | undefined>(undefined)
 
 const fetchMappings = async () => {
   loading.value = true
   try {
     const skip = (currentPage.value - 1) * pageSize.value
-    const res = await getMappings(appId, skip, pageSize.value)
+    const res = await getMappings(appId, skip, pageSize.value, {
+      search: searchKeyword.value,
+      mapping_is_active: filterMappingActive.value,
+      user_status: filterUserStatus.value
+    })
     mappings.value = res.data.items
     total.value = res.data.total
   } catch (e) {
@@ -446,6 +487,16 @@ const fetchMappings = async () => {
   }
 }
 
+const applyMappingSearch = () => {
+  currentPage.value = 1
+  fetchMappings()
+}
+
+const handleMappingSearchClear = () => {
+  currentPage.value = 1
+  fetchMappings()
+}
+
 const handleSizeChange = (val: number) => {
   pageSize.value = val
   fetchMappings()
@@ -848,8 +899,16 @@ onMounted(() => {
 .toolbar {
   margin-bottom: 15px;
   display: flex;
+  flex-wrap: wrap;
+  align-items: center;
   gap: 10px;
 }
+.toolbar-search {
+  margin-left: auto;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
 .pagination {
   margin-top: 20px;
   display: flex;