lxylxy123321 10 часов назад
Родитель
Сommit
783526ee95

+ 63 - 2
backend/app/routers/domains.py

@@ -4,7 +4,7 @@ from sqlalchemy import select, func, desc
 from sqlalchemy.ext.asyncio import AsyncSession
 from app.database import get_db
 from app.models.domain import MonitoredDomain
-from app.models.monitoring import SuperAdmin, FetchScheduleConfig, FetchLog
+from app.models.monitoring import SuperAdmin, FetchScheduleConfig, FetchLog, SuperAdminTenant
 from app.schemas.domain import (
     MonitoredDomainCreate,
     MonitoredDomainResponse,
@@ -92,14 +92,75 @@ async def update_domain_remark(
 
 @router.delete("/{domain_id}", status_code=204)
 async def remove_domain(domain_id: int, db: AsyncSession = Depends(get_db)):
-    """移除指定 ID 的监控域名"""
+    """移除指定 ID 的监控域名,并级联清理所有关联数据"""
     result = await db.execute(
         select(MonitoredDomain).where(MonitoredDomain.id == domain_id)
     )
     record = result.scalar_one_or_none()
     if not record:
         raise HTTPException(status_code=404, detail="域名不存在")
+
+    domain_name = record.domain
+    sa_id = record.super_admin_id
+
+    # 1. 删除该域名相关的爬取日志
+    await db.execute(
+        FetchLog.__table__.delete().where(FetchLog.domain == domain_name)
+    )
+
+    # 2. 删除域名记录本身
     await db.delete(record)
+
+    # 3. 如果没有其他域名关联这个超管,级联清理超管及其所有关联数据
+    if sa_id is not None:
+        remaining = await db.execute(
+            select(MonitoredDomain).where(MonitoredDomain.super_admin_id == sa_id)
+        )
+        if not remaining.scalar_one_or_none():
+            # 3a. 删除该超管的所有 License
+            from app.models.license import SuperAdminLicense
+            await db.execute(
+                SuperAdminLicense.__table__.delete().where(SuperAdminLicense.super_admin_id == sa_id)
+            )
+            # 3b. 查找该超管关联的所有租户
+            tenant_result = await db.execute(
+                select(SuperAdminTenant.tenant_id).where(SuperAdminTenant.super_admin_id == sa_id)
+            )
+            tenant_ids = [row[0] for row in tenant_result.all()]
+            # 3c. 先删除超管-租户关联
+            await db.execute(
+                SuperAdminTenant.__table__.delete().where(SuperAdminTenant.super_admin_id == sa_id)
+            )
+            # 3d. 删除不再被任何超管关联的租户及其消费明细
+            from app.models.monitoring import Tenant, UserConsumptionDetail
+            if tenant_ids:
+                for tid in tenant_ids:
+                    # 检查是否还有其他超管关联此租户
+                    other = await db.execute(
+                        select(SuperAdminTenant).where(SuperAdminTenant.tenant_id == tid)
+                    )
+                    if not other.scalar_one_or_none():
+                        # 删除消费明细
+                        await db.execute(
+                            UserConsumptionDetail.__table__.delete().where(
+                                UserConsumptionDetail.tenant_id == tid
+                            )
+                        )
+                        # 删除租户
+                        tenant_row = await db.execute(
+                            select(Tenant).where(Tenant.id == tid)
+                        )
+                        t = tenant_row.scalar_one_or_none()
+                        if t:
+                            await db.delete(t)
+            # 3e. 删除超管本身
+            sa_result = await db.execute(
+                select(SuperAdmin).where(SuperAdmin.id == sa_id)
+            )
+            sa = sa_result.scalar_one_or_none()
+            if sa:
+                await db.delete(sa)
+
     await db.commit()
 
 

+ 9 - 0
backend/requirements.txt

@@ -0,0 +1,9 @@
+asyncpg>=0.31.0
+bcrypt>=5.0.0
+fastapi>=0.136.1
+httpx>=0.28.1
+pydantic-settings>=2.14.1
+python-dotenv>=1.2.2
+redis>=5.2.0
+sqlalchemy[asyncio]>=2.0.49
+uvicorn[standard]>=0.46.0

+ 1 - 0
frontend/.env

@@ -1 +1,2 @@
 VITE_API_BASE_URL=http://localhost:8000
+# VITE_API_BASE_URL=http://47.109.151.80:8000

+ 2 - 1
frontend/src/App.tsx

@@ -1,4 +1,4 @@
-import { Routes, Route } from "react-router-dom";
+import { Routes, Route, Navigate } from "react-router-dom";
 import { globalStyle } from "./theme";
 import { MainLayout } from "./layouts/MainLayout";
 import { ProtectedRoute } from "./pages/LoginPage";
@@ -22,6 +22,7 @@ function App() {
         <Route path="/login" element={<LoginPage />} />
         <Route element={<ProtectedRoute />}>
           <Route element={<MainLayout />}>
+            <Route path="/logs" element={<Navigate to="/fetch-logs" replace />} />
             <Route path="/" element={<DashboardPage />} />
             <Route path="/domains" element={<DomainsPage />} />
             <Route path="/monitoring" element={<MonitoringPage />} />

+ 1 - 0
frontend/src/api/client.ts

@@ -4,4 +4,5 @@ import axios from "axios";
 export const api = axios.create({
   baseURL: import.meta.env.VITE_API_BASE_URL, // 从环境变量读取后端地址
   headers: { "Content-Type": "application/json" },
+  timeout: 10000,
 });

+ 2 - 79
frontend/src/pages/DashboardPage.tsx

@@ -1,4 +1,4 @@
-import { useState, useEffect } from "react";
+import { useState } from "react";
 import { useNavigate } from "react-router-dom";
 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
 import { domainApi } from "../api/domains";
@@ -6,84 +6,7 @@ import { monitoringApi } from "../api/monitoring";
 import { licenseApi } from "../api/license";
 import type { MonitoredDomain } from "../types/domain";
 import { T } from "../theme";
-import { Card, StatCard, LoadingDots, Toast } from "../components/Shared";
-
-function FetchControls() {
-  const queryClient = useQueryClient();
-  const [autoFetch, setAutoFetch] = useState(false);
-  const [scheduleTime, setScheduleTime] = useState("02:00");
-  const [fetchDate, setFetchDate] = useState("");
-  const [fetchingByDate, setFetchingByDate] = useState(false);
-  const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
-
-  useEffect(() => {
-    domainApi.getSchedule().then(res => {
-      setAutoFetch(res.data.enabled);
-      setScheduleTime(res.data.schedule_time);
-    }).catch(() => {});
-  }, []);
-
-  const showToast = (message: string, type: "success" | "error") => {
-    setToast({ message, type });
-    setTimeout(() => setToast(null), 4000);
-  };
-
-  const handleSaveSchedule = () => {
-    domainApi.saveSchedule({ enabled: autoFetch, schedule_time: scheduleTime }).then(() => {
-      showToast("配置已保存", "success");
-    }).catch(() => showToast("保存失败", "error"));
-  };
-
-  const batchMutation = useMutation({
-    mutationFn: () => domainApi.fetchAll(),
-    onSuccess: (res: any) => {
-      queryClient.invalidateQueries({ queryKey: ["dashboard"] });
-      const data = res.data;
-      if (data.errors && data.errors.length > 0) showToast(`部分失败: ${data.errors.length}/${data.total} 个域名出错`, "error");
-      else if (data.total === 0) showToast("没有启用中的域名", "error");
-      else showToast(`全部爬取成功,共 ${data.total} 个域名`, "success");
-    },
-    onError: () => showToast("爬取请求失败,请稍后重试", "error"),
-  });
-
-  const fetchByDateMutation = useMutation({
-    mutationFn: (date: string) => domainApi.fetchAll(date),
-    onSuccess: (res: any) => {
-      queryClient.invalidateQueries({ queryKey: ["dashboard"] });
-      setFetchingByDate(false);
-      const data = res.data;
-      if (data.errors && data.errors.length > 0) showToast(`部分失败: ${data.errors.length}/${data.total} 个域名出错`, "error");
-      else if (data.total === 0) showToast("没有启用中的域名", "error");
-      else showToast(`按日期爬取成功,共 ${data.total} 个域名`, "success");
-    },
-    onError: () => {
-      setFetchingByDate(false);
-      showToast("爬取请求失败,请稍后重试", "error");
-    },
-  });
-
-  return (
-    <>
-      {toast && <Toast message={toast.message} type={toast.type} />}
-      <div style={{ display: "flex", alignItems: "center", gap: 16, flexWrap: "wrap" }}>
-        <Toggle checked={autoFetch} onChange={setAutoFetch} label="每日定时爬取" />
-        {autoFetch && (
-          <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
-            <span style={{ fontSize: 13, color: T.textSec }}>时间</span>
-            <input type="time" value={scheduleTime} onChange={(e) => setScheduleTime(e.target.value)} style={{ padding: "4px 8px", borderRadius: 6, border: `1px solid ${T.border}`, fontSize: 13, outline: "none" }} />
-          </div>
-        )}
-        <button onClick={handleSaveSchedule} style={{ padding: "6px 14px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: `1px solid ${T.primary}`, background: "transparent", color: T.primary, fontWeight: 500, whiteSpace: "nowrap" }}>保存配置</button>
-        <button onClick={() => batchMutation.mutate()} disabled={batchMutation.isPending} style={{ padding: "6px 14px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: "none", background: batchMutation.isPending ? "#f1f5f9" : T.primary, color: batchMutation.isPending ? T.textSec : "#fff", fontWeight: 500, whiteSpace: "nowrap" }}>{batchMutation.isPending ? "爬取中..." : "全部爬取"}</button>
-        <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
-          <span style={{ fontSize: 13, color: T.textSec }}>按日期爬取</span>
-          <input type="date" value={fetchDate} onChange={(e) => setFetchDate(e.target.value)} style={{ padding: "4px 8px", borderRadius: 6, border: `1px solid ${T.border}`, fontSize: 13, outline: "none" }} />
-          <button onClick={() => { if (fetchDate) { setFetchingByDate(true); fetchByDateMutation.mutate(fetchDate); } }} disabled={fetchingByDate || !fetchDate} style={{ padding: "6px 14px", borderRadius: 6, fontSize: 13, cursor: fetchingByDate || !fetchDate ? "default" : "pointer", border: `1px solid ${T.primary}`, background: fetchingByDate || !fetchDate ? "#f1f5f9" : "transparent", color: fetchingByDate || !fetchDate ? T.textSec : T.primary, fontWeight: 500, whiteSpace: "nowrap" }}>{fetchingByDate ? "爬取中..." : "爬取"}</button>
-        </div>
-      </div>
-    </>
-  );
-}
+import { Card, StatCard } from "../components/Shared";
 
 export function DashboardPage() {
   const navigate = useNavigate();

+ 1 - 1
frontend/src/pages/DomainsPage.tsx

@@ -199,7 +199,7 @@ export function DomainsPage() {
           )}
         </Card>
       </div>
-      {confirmDelete && <ConfirmDialog title="确认删除域名" message="删除后将无法恢复,确定要删除该域名吗?" onConfirm={() => { deleteMutation.mutate(confirmDelete); setConfirmDelete(null); }} onCancel={() => setConfirmDelete(null)} />}
+      {confirmDelete && <ConfirmDialog title="确认删除域名" message="删除后将同时移除该域名关联的超管、License、租户及爬取日志等所有数据,且无法恢复,确定要继续吗?" onConfirm={() => { deleteMutation.mutate(confirmDelete); setConfirmDelete(null); }} onCancel={() => setConfirmDelete(null)} />}
     </>
   );
 }

+ 1 - 1
frontend/src/pages/LicensePage.tsx

@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
 import { domainApi } from "../api/domains";
 import { licenseApi } from "../api/license";
 import { T } from "../theme";
-import { Card, Badge, LoadingDots, Toast, ConfirmDialog } from "../components/Shared";
+import { Card, LoadingDots, Toast, ConfirmDialog } from "../components/Shared";
 import { PageLayout } from "../components/PageLayout";
 
 export function LicensePage() {

+ 4 - 4
frontend/src/pages/LoginPage.tsx

@@ -1,6 +1,5 @@
 import { useState } from "react";
-import { useNavigate, Outlet } from "react-router-dom";
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { useNavigate, Outlet, Navigate } from "react-router-dom";
 import { T, globalStyle } from "../theme";
 import { loginApi } from "../api/domains";
 
@@ -21,9 +20,10 @@ export function LoginPage() {
     try {
       await loginApi.login(username, password);
       sessionStorage.setItem("auth", "1");
-      navigate("/");
+      navigate("/", { replace: true });
     } catch (err: any) {
       setError(err.response?.data?.detail || "用户名或密码错误");
+    } finally {
       setPending(false);
     }
   };
@@ -67,6 +67,6 @@ export function LoginPage() {
 
 export function ProtectedRoute() {
   const authenticated = sessionStorage.getItem("auth") === "1";
-  if (!authenticated) return <LoginPage />;
+  if (!authenticated) return <Navigate to="/login" replace />;
   return <Outlet />;
 }

+ 3 - 0
frontend/vite.config.ts

@@ -4,4 +4,7 @@ import react from '@vitejs/plugin-react'
 // https://vite.dev/config/
 export default defineConfig({
   plugins: [react()],
+  server: {
+    host: '0.0.0.0',
+  },
 })