update: index

This commit is contained in:
Kerwin
2025-11-24 18:18:19 +08:00
parent 09baf0cb21
commit b893f30558
13 changed files with 2309 additions and 1428 deletions

View File

@@ -51,6 +51,8 @@ type ResourceRepository interface {
FindByResourceKey(key string) ([]entity.Resource, error) FindByResourceKey(key string) ([]entity.Resource, error)
FindByKey(key string) ([]entity.Resource, error) FindByKey(key string) ([]entity.Resource, error)
GetHotResources(limit int) ([]entity.Resource, error) GetHotResources(limit int) ([]entity.Resource, error)
GetTotalCount() (int64, error)
GetAllValidResources() ([]entity.Resource, error)
} }
// ResourceRepositoryImpl Resource的Repository实现 // ResourceRepositoryImpl Resource的Repository实现
@@ -799,3 +801,18 @@ func (r *ResourceRepositoryImpl) FindByResourceKey(key string) ([]entity.Resourc
} }
return resources, nil return resources, nil
} }
// GetTotalCount 获取资源总数
func (r *ResourceRepositoryImpl) GetTotalCount() (int64, error) {
var count int64
err := r.GetDB().Model(&entity.Resource{}).Count(&count).Error
return count, err
}
// GetAllValidResources 获取所有有效的公开资源
func (r *ResourceRepositoryImpl) GetAllValidResources() ([]entity.Resource, error) {
var resources []entity.Resource
err := r.GetDB().Where("is_valid = ? AND is_public = ?", true, true).
Find(&resources).Error
return resources, err
}

View File

@@ -20,6 +20,7 @@ type TaskItemRepository interface {
UpdateStatus(id uint, status string) error UpdateStatus(id uint, status string) error
UpdateStatusAndOutput(id uint, status, outputData string) error UpdateStatusAndOutput(id uint, status, outputData string) error
GetStatsByTaskID(taskID uint) (map[string]int, error) GetStatsByTaskID(taskID uint) (map[string]int, error)
GetIndexStats() (map[string]int, error)
ResetProcessingItems(taskID uint) error ResetProcessingItems(taskID uint) error
} }
@@ -210,3 +211,30 @@ func (r *TaskItemRepositoryImpl) ResetProcessingItems(taskID uint) error {
utils.Debug("ResetProcessingItems成功: 任务ID=%d, 更新耗时=%v", taskID, updateDuration) utils.Debug("ResetProcessingItems成功: 任务ID=%d, 更新耗时=%v", taskID, updateDuration)
return nil return nil
} }
// GetIndexStats 获取索引统计信息
func (r *TaskItemRepositoryImpl) GetIndexStats() (map[string]int, error) {
stats := make(map[string]int)
// 统计各种状态的数量
statuses := []string{"completed", "failed", "pending"}
for _, status := range statuses {
var count int64
err := r.db.Model(&entity.TaskItem{}).Where("status = ?", status).Count(&count).Error
if err != nil {
return nil, err
}
switch status {
case "completed":
stats["indexed"] = int(count)
case "failed":
stats["error"] = int(count)
case "pending":
stats["not_indexed"] = int(count)
}
}
return stats, nil
}

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -18,6 +19,7 @@ import (
"github.com/ctwj/urldb/task" "github.com/ctwj/urldb/task"
"github.com/ctwj/urldb/utils" "github.com/ctwj/urldb/utils"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
goauth "golang.org/x/oauth2/google"
) )
// GoogleIndexHandler Google索引处理程序 // GoogleIndexHandler Google索引处理程序
@@ -725,23 +727,17 @@ func (h *GoogleIndexHandler) makeSafeFileName(filename string) string {
// ValidateCredentials 验证Google索引凭据 // ValidateCredentials 验证Google索引凭据
func (h *GoogleIndexHandler) ValidateCredentials(c *gin.Context) { func (h *GoogleIndexHandler) ValidateCredentials(c *gin.Context) {
var req struct { // 使用固定的凭据文件路径
CredentialsFile string `json:"credentials_file" binding:"required"` credentialsFile := "data/google_credentials.json"
}
if err := c.ShouldBindJSON(&req); err != nil {
ErrorResponse(c, "请求参数错误: "+err.Error(), http.StatusBadRequest)
return
}
// 检查凭据文件是否存在 // 检查凭据文件是否存在
if _, err := os.Stat(req.CredentialsFile); os.IsNotExist(err) { if _, err := os.Stat(credentialsFile); os.IsNotExist(err) {
ErrorResponse(c, "凭据文件不存在", http.StatusBadRequest) ErrorResponse(c, "凭据文件不存在", http.StatusBadRequest)
return return
} }
// 尝试创建Google客户端并验证凭据 // 尝试创建Google客户端并验证凭据
config, err := h.loadCredentials(req.CredentialsFile) config, err := h.loadCredentials(credentialsFile)
if err != nil { if err != nil {
ErrorResponse(c, "凭据格式错误: "+err.Error(), http.StatusBadRequest) ErrorResponse(c, "凭据格式错误: "+err.Error(), http.StatusBadRequest)
return return
@@ -792,17 +788,34 @@ func (h *GoogleIndexHandler) GetStatus(c *gin.Context) {
} }
} }
// 获取统计信息(从任务管理器或数据库获取相关统计 // 获取统计信息(从数据库查询实际的资源数量
// 这里简化处理,返回一个基本的状态响应 totalURLs := 0
// 在实际实现中,可能需要查询数据库获取更详细的统计信息 indexedURLs := 0
notIndexedURLs := 0
errorURLs := 0
// 查询resources表获取总URL数
totalResources, err := h.repoMgr.ResourceRepository.GetTotalCount()
if err == nil {
totalURLs = int(totalResources)
}
// 查询任务项统计获取索引状态
taskStats, err := h.repoMgr.TaskItemRepository.GetIndexStats()
if err == nil {
indexedURLs = taskStats["indexed"]
notIndexedURLs = taskStats["not_indexed"]
errorURLs = taskStats["error"]
}
statusResponse := dto.GoogleIndexStatusResponse{ statusResponse := dto.GoogleIndexStatusResponse{
Enabled: enabled, Enabled: enabled,
SiteURL: siteURL, SiteURL: siteURL,
LastCheckTime: time.Now(), LastCheckTime: time.Now(),
TotalURLs: 0, TotalURLs: totalURLs,
IndexedURLs: 0, IndexedURLs: indexedURLs,
NotIndexedURLs: 0, NotIndexedURLs: notIndexedURLs,
ErrorURLs: 0, ErrorURLs: errorURLs,
LastSitemapSubmit: time.Time{}, LastSitemapSubmit: time.Time{},
AuthValid: authValid, AuthValid: authValid,
} }
@@ -812,10 +825,7 @@ func (h *GoogleIndexHandler) GetStatus(c *gin.Context) {
// loadCredentials 从文件加载凭据 // loadCredentials 从文件加载凭据
func (h *GoogleIndexHandler) loadCredentials(credentialsFile string) (*google.Config, error) { func (h *GoogleIndexHandler) loadCredentials(credentialsFile string) (*google.Config, error) {
// 从pkg/google/client.go导入的Config // 读取凭据文件
// 注意:我们需要一个方法来安全地加载凭据
// 为了简化,我们只是检查文件是否可以读取以及格式是否正确
data, err := os.ReadFile(credentialsFile) data, err := os.ReadFile(credentialsFile)
if err != nil { if err != nil {
return nil, fmt.Errorf("无法读取凭据文件: %v", err) return nil, fmt.Errorf("无法读取凭据文件: %v", err)
@@ -835,10 +845,32 @@ func (h *GoogleIndexHandler) loadCredentials(credentialsFile string) (*google.Co
} }
} }
// 返回配置(简化处理,实际实现可能需要更复杂的逻辑) // 检查凭据类型
return &google.Config{ if temp["type"] != "service_account" {
return nil, fmt.Errorf("仅支持服务账号类型的凭据")
}
// 尝试从JSON数据加载凭据需要指定作用域
scopes := []string{
"https://www.googleapis.com/auth/webmasters",
"https://www.googleapis.com/auth/indexing",
}
jwtConfig, err := goauth.JWTConfigFromJSON(data, scopes...)
if err != nil {
return nil, fmt.Errorf("创建JWT配置失败: %v", err)
}
// 创建一个简单的配置对象暂时只存储JWT配置
config := &google.Config{
CredentialsFile: credentialsFile, CredentialsFile: credentialsFile,
}, nil }
// 为了验证凭据我们尝试获取token源
ctx := context.Background()
tokenSource := jwtConfig.TokenSource(ctx)
_ = tokenSource // 实际验证在getValidToken中进行
return config, nil
} }
// getValidToken 获取有效的token // getValidToken 获取有效的token
@@ -972,20 +1004,26 @@ func (h *GoogleIndexHandler) UpdateGoogleIndexConfig(c *gin.Context) {
} }
func (h *GoogleIndexHandler) getValidToken(config *google.Config) error { func (h *GoogleIndexHandler) getValidToken(config *google.Config) error {
// 这里应该使用Google的验证逻辑 // 为了简单验证我们只尝试读取凭据文件并确保JWT配置可以正常工作
// 为了简化我们返回一个模拟的验证过程 // 在实际实现中这里应该尝试获取一个实际的token
// 在实际实现中应该使用Google API进行验证
// 尝试初始化Google客户端 // 重新读取凭据文件进行验证
client, err := google.NewClient(config) data, err := os.ReadFile(config.CredentialsFile)
if err != nil { if err != nil {
return fmt.Errorf("创建Google客户端失败: %v", err) return fmt.Errorf("无法读取凭据文件: %v", err)
} }
// 简单的验证:尝试获取网站列表,如果成功说明凭据有效 // 尝试从JSON数据加载凭据需要指定作用域
// 这里我们只检查客户端是否能成功初始化 scopes := []string{
// 在实际实现中应该尝试执行一个API调用以验证凭据 "https://www.googleapis.com/auth/webmasters",
_ = client // 使用client变量避免未使用警告 "https://www.googleapis.com/auth/indexing",
}
_, err = goauth.JWTConfigFromJSON(data, scopes...)
if err != nil {
return fmt.Errorf("创建JWT配置失败: %v", err)
}
// 如果能成功创建JWT配置我们认为凭据格式是正确的
// 在实际环境中这里应该尝试获取token来验证凭据的有效性
return nil return nil
} }

View File

@@ -499,3 +499,36 @@ func (h *PublicAPIHandler) recordAPIAccessToDB(ip, userAgent, endpoint, method s
} }
}() }()
} }
// GetPublicSiteVerificationCode 获取网站验证代码(公开访问)
func GetPublicSiteVerificationCode(c *gin.Context) {
// 获取站点URL配置
siteURL, err := repoManager.SystemConfigRepository.GetConfigValue("site_url")
if err != nil || siteURL == "" {
c.JSON(400, gin.H{
"success": false,
"message": "站点URL未配置",
})
return
}
// 生成Google Search Console验证代码示例
verificationCode := map[string]interface{}{
"site_url": siteURL,
"verification_methods": map[string]string{
"html_tag": `<meta name="google-site-verification" content="your-verification-code">`,
"dns_txt": `google-site-verification=your-verification-code`,
"html_file": `google1234567890abcdef.html`,
},
"instructions": map[string]string{
"html_tag": "请将以下meta标签添加到您网站的首页<head>部分中",
"dns_txt": "请添加以下TXT记录到您的DNS配置中",
"html_file": "请在网站根目录创建包含指定内容的HTML文件",
},
}
c.JSON(200, gin.H{
"success": true,
"data": verificationCode,
})
}

16
main.go
View File

@@ -198,6 +198,7 @@ func main() {
autoProcessReadyResources, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoProcessReadyResources) autoProcessReadyResources, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoProcessReadyResources)
autoTransferEnabled, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoTransferEnabled) autoTransferEnabled, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoTransferEnabled)
autoSitemapEnabled, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeySitemapAutoGenerateEnabled) autoSitemapEnabled, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeySitemapAutoGenerateEnabled)
autoGoogleIndexEnabled, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.GoogleIndexConfigKeyEnabled)
globalScheduler.UpdateSchedulerStatusWithAutoTransfer( globalScheduler.UpdateSchedulerStatusWithAutoTransfer(
autoFetchHotDrama, autoFetchHotDrama,
@@ -213,6 +214,14 @@ func main() {
utils.Info("系统配置禁用Sitemap自动生成功能") utils.Info("系统配置禁用Sitemap自动生成功能")
} }
// 根据系统配置启动Google索引调度器
if autoGoogleIndexEnabled {
globalScheduler.StartGoogleIndexScheduler()
utils.Info("系统配置启用Google索引自动提交功能启动定时任务")
} else {
utils.Info("系统配置禁用Google索引自动提交功能")
}
utils.Info("调度器初始化完成") utils.Info("调度器初始化完成")
// 设置公开API中间件的Repository管理器 // 设置公开API中间件的Repository管理器
@@ -374,7 +383,7 @@ func main() {
api.GET("/system/config/status", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetConfigStatus) api.GET("/system/config/status", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetConfigStatus)
api.POST("/system/config/toggle-auto-process", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.ToggleAutoProcess) api.POST("/system/config/toggle-auto-process", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.ToggleAutoProcess)
api.GET("/public/system-config", handlers.GetPublicSystemConfig) api.GET("/public/system-config", handlers.GetPublicSystemConfig)
api.GET("/public/site-verification", handlers.GetSiteVerificationCode) // 网站验证代码(公开访问) api.GET("/public/site-verification", handlers.GetPublicSiteVerificationCode) // 网站验证代码(公开访问)
// 热播剧管理路由(查询接口无需认证) // 热播剧管理路由(查询接口无需认证)
api.GET("/hot-dramas", handlers.GetHotDramaList) api.GET("/hot-dramas", handlers.GetHotDramaList)
@@ -536,9 +545,10 @@ api.GET("/public/site-verification", handlers.GetSiteVerificationCode) // 网
// Google索引管理API // Google索引管理API
api.GET("/google-index/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.GetConfig) api.GET("/google-index/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.GetConfig)
api.GET("/google-index/config-all", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.GetAllConfig) // 获取所有配置
api.POST("/google-index/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.UpdateConfig) api.POST("/google-index/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.UpdateConfig)
api.GET("/google-index/config/:key", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.GetConfigByKey) // 根据键获取配置 api.POST("/google-index/config/update", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.UpdateGoogleIndexConfig) // 分组配置更新
api.POST("/google-index/config/update", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.UpdateGoogleIndexConfig) // 分组配置更新 api.GET("/google-index/status", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.GetStatus) // 获取状态
api.POST("/google-index/tasks", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.CreateTask) api.POST("/google-index/tasks", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.CreateTask)
api.GET("/google-index/tasks", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.GetTasks) api.GET("/google-index/tasks", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.GetTasks)
api.GET("/google-index/tasks/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.GetTaskStatus) api.GET("/google-index/tasks/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), googleIndexHandler.GetTaskStatus)

View File

@@ -201,3 +201,39 @@ func (gs *GlobalScheduler) TriggerSitemapGeneration() {
gs.manager.TriggerSitemapGeneration() gs.manager.TriggerSitemapGeneration()
} }
// StartGoogleIndexScheduler 启动Google索引调度任务
func (gs *GlobalScheduler) StartGoogleIndexScheduler() {
gs.mutex.Lock()
defer gs.mutex.Unlock()
if gs.manager.IsGoogleIndexRunning() {
utils.Debug("Google索引调度任务已在运行中")
return
}
gs.manager.StartGoogleIndexScheduler()
utils.Info("Google索引调度任务已启动")
}
// StopGoogleIndexScheduler 停止Google索引调度任务
func (gs *GlobalScheduler) StopGoogleIndexScheduler() {
gs.mutex.Lock()
defer gs.mutex.Unlock()
if !gs.manager.IsGoogleIndexRunning() {
utils.Debug("Google索引调度任务未在运行")
return
}
gs.manager.StopGoogleIndexScheduler()
utils.Info("Google索引调度任务已停止")
}
// IsGoogleIndexSchedulerRunning 检查Google索引调度任务是否在运行
func (gs *GlobalScheduler) IsGoogleIndexSchedulerRunning() bool {
gs.mutex.RLock()
defer gs.mutex.RUnlock()
return gs.manager.IsGoogleIndexRunning()
}

382
scheduler/google_index.go Normal file
View File

@@ -0,0 +1,382 @@
package scheduler
import (
"context"
"fmt"
"strconv"
"time"
"github.com/ctwj/urldb/db/entity"
"github.com/ctwj/urldb/pkg/google"
"github.com/ctwj/urldb/utils"
)
// GoogleIndexScheduler Google索引调度器
type GoogleIndexScheduler struct {
*BaseScheduler
config entity.SystemConfig
stopChan chan bool
isRunning bool
enabled bool
checkInterval time.Duration
googleClient *google.Client
}
// NewGoogleIndexScheduler 创建Google索引调度器
func NewGoogleIndexScheduler(baseScheduler *BaseScheduler) *GoogleIndexScheduler {
return &GoogleIndexScheduler{
BaseScheduler: baseScheduler,
stopChan: make(chan bool),
isRunning: false,
}
}
// Start 启动Google索引调度任务
func (s *GoogleIndexScheduler) Start() {
if s.isRunning {
utils.Debug("Google索引调度任务已在运行中")
return
}
// 加载配置
if err := s.loadConfig(); err != nil {
utils.Error("加载Google索引配置失败: %v", err)
return
}
if !s.enabled {
utils.Debug("Google索引功能未启用跳过调度任务")
return
}
s.isRunning = true
utils.Info("开始启动Google索引调度任务检查间隔: %v", s.checkInterval)
go s.run()
}
// Stop 停止Google索引调度任务
func (s *GoogleIndexScheduler) Stop() {
if !s.isRunning {
return
}
utils.Info("正在停止Google索引调度任务...")
s.stopChan <- true
s.isRunning = false
}
// IsRunning 检查调度器是否正在运行
func (s *GoogleIndexScheduler) IsRunning() bool {
return s.isRunning
}
// run 运行调度器主循环
func (s *GoogleIndexScheduler) run() {
ticker := time.NewTicker(s.checkInterval)
defer ticker.Stop()
// 启动时立即执行一次
s.performScheduledTasks()
for {
select {
case <-s.stopChan:
utils.Info("Google索引调度任务已停止")
return
case <-ticker.C:
s.performScheduledTasks()
}
}
}
// loadConfig 加载配置
func (s *GoogleIndexScheduler) loadConfig() error {
// 获取启用状态
enabledStr, err := s.systemConfigRepo.GetConfigValue(entity.GoogleIndexConfigKeyEnabled)
if err != nil {
s.enabled = false
} else {
s.enabled = enabledStr == "true" || enabledStr == "1"
}
// 获取检查间隔
intervalStr, err := s.systemConfigRepo.GetConfigValue(entity.GoogleIndexConfigKeyCheckInterval)
if err != nil {
s.checkInterval = 60 * time.Minute // 默认60分钟
} else {
if interval, parseErr := time.ParseDuration(intervalStr + "m"); parseErr == nil {
s.checkInterval = interval
} else {
s.checkInterval = 60 * time.Minute
}
}
// 初始化Google客户端
if s.enabled {
if err := s.initGoogleClient(); err != nil {
utils.Error("初始化Google客户端失败: %v", err)
s.enabled = false
}
}
return nil
}
// initGoogleClient 初始化Google客户端
func (s *GoogleIndexScheduler) initGoogleClient() error {
// 获取凭据文件路径
credentialsFile, err := s.systemConfigRepo.GetConfigValue(entity.GoogleIndexConfigKeyCredentialsFile)
if err != nil {
return fmt.Errorf("获取凭据文件路径失败: %v", err)
}
// 获取站点URL
siteURL, err := s.systemConfigRepo.GetConfigValue(entity.GoogleIndexConfigKeySiteURL)
if err != nil {
return fmt.Errorf("获取站点URL失败: %v", err)
}
// 创建Google客户端配置
config := &google.Config{
CredentialsFile: credentialsFile,
SiteURL: siteURL,
}
client, err := google.NewClient(config)
if err != nil {
return fmt.Errorf("创建Google客户端失败: %v", err)
}
s.googleClient = client
return nil
}
// performScheduledTasks 执行调度任务
func (s *GoogleIndexScheduler) performScheduledTasks() {
if !s.enabled {
return
}
ctx := context.Background()
// 任务1: 扫描未索引的URL并自动提交
if err := s.scanAndSubmitUnindexedURLs(ctx); err != nil {
utils.Error("扫描未索引URL失败: %v", err)
}
// 任务2: 定期检查已索引URL的状态
if err := s.checkIndexedURLsStatus(ctx); err != nil {
utils.Error("检查索引状态失败: %v", err)
}
utils.Debug("Google索引调度任务执行完成")
}
// scanAndSubmitUnindexedURLs 扫描并提交未索引的URL
func (s *GoogleIndexScheduler) scanAndSubmitUnindexedURLs(ctx context.Context) error {
utils.Info("开始扫描未索引的URL...")
// 1. 获取所有资源URL
resources, err := s.resourceRepo.GetAllValidResources()
if err != nil {
return fmt.Errorf("获取资源列表失败: %v", err)
}
// 2. 获取已索引的URL记录
indexedURLs, err := s.getIndexedURLs()
if err != nil {
return fmt.Errorf("获取已索引URL列表失败: %v", err)
}
// 3. 找出未索引的URL
var unindexedURLs []string
indexedURLSet := make(map[string]bool)
for _, url := range indexedURLs {
indexedURLSet[url] = true
}
for _, resource := range resources {
if resource.IsPublic && resource.IsValid && resource.URL != "" {
if !indexedURLSet[resource.URL] {
unindexedURLs = append(unindexedURLs, resource.URL)
}
}
}
utils.Info("发现 %d 个未索引的URL", len(unindexedURLs))
// 4. 批量提交未索引的URL
if len(unindexedURLs) > 0 {
if err := s.batchSubmitURLs(ctx, unindexedURLs); err != nil {
return fmt.Errorf("批量提交URL失败: %v", err)
}
}
return nil
}
// getIndexedURLs 获取已索引的URL列表
func (s *GoogleIndexScheduler) getIndexedURLs() ([]string, error) {
// 这里需要通过TaskItemRepository获取已索引的URL
// 由于BaseScheduler没有TaskItemRepository我们暂时返回空列表
// 后续可以通过扩展BaseScheduler或创建专门的方法来处理
return []string{}, nil
}
// batchSubmitURLs 批量提交URL
func (s *GoogleIndexScheduler) batchSubmitURLs(ctx context.Context, urls []string) error {
utils.Info("开始批量提交 %d 个URL到Google索引...", len(urls))
// 获取批量大小配置
batchSizeStr, _ := s.systemConfigRepo.GetConfigValue(entity.GoogleIndexConfigKeyBatchSize)
batchSize := 100 // 默认值
if batchSizeStr != "" {
if size, err := strconv.Atoi(batchSizeStr); err == nil && size > 0 {
batchSize = size
}
}
// 获取并发数配置
concurrencyStr, _ := s.systemConfigRepo.GetConfigValue(entity.GoogleIndexConfigKeyConcurrency)
concurrency := 5 // 默认值
if concurrencyStr != "" {
if conc, err := strconv.Atoi(concurrencyStr); err == nil && conc > 0 {
concurrency = conc
}
}
// 分批处理
for i := 0; i < len(urls); i += batchSize {
end := i + batchSize
if end > len(urls) {
end = len(urls)
}
batch := urls[i:end]
if err := s.processBatch(ctx, batch, concurrency); err != nil {
utils.Error("处理批次失败 (批次 %d-%d): %v", i+1, end, err)
continue
}
// 避免API限制批次间稍作延迟
time.Sleep(1 * time.Second)
}
utils.Info("批量URL提交完成")
return nil
}
// processBatch 处理单个批次
func (s *GoogleIndexScheduler) processBatch(ctx context.Context, urls []string, concurrency int) error {
semaphore := make(chan struct{}, concurrency)
errChan := make(chan error, len(urls))
for _, url := range urls {
go func(u string) {
semaphore <- struct{}{}
defer func() { <-semaphore }()
// 检查URL索引状态
result, err := s.googleClient.InspectURL(u)
if err != nil {
utils.Error("检查URL失败: %s, 错误: %v", u, err)
errChan <- err
return
}
// Google Search Console API 不直接支持URL提交
// 这里只记录URL状态实际的URL索引需要通过sitemap或其他方式
if result.IndexStatusResult.IndexingState == "NOT_SUBMITTED" {
utils.Debug("URL未提交需要通过sitemap提交: %s", u)
// TODO: 可以考虑将未提交的URL加入到sitemap中
}
// 记录索引状态
s.recordURLStatus(u, result)
errChan <- nil
}(url)
}
// 等待所有goroutine完成
for i := 0; i < len(urls); i++ {
if err := <-errChan; err != nil {
return err
}
}
return nil
}
// checkIndexedURLsStatus 检查已索引URL的状态
func (s *GoogleIndexScheduler) checkIndexedURLsStatus(ctx context.Context) error {
utils.Info("开始检查已索引URL的状态...")
// 暂时跳过状态检查因为需要TaskItemRepository访问权限
// TODO: 后续通过扩展BaseScheduler来支持TaskItemRepository
urlsToCheck := []string{}
utils.Info("检查 %d 个已索引URL的状态", len(urlsToCheck))
// 并发检查状态
concurrencyStr, _ := s.systemConfigRepo.GetConfigValue(entity.GoogleIndexConfigKeyConcurrency)
concurrency := 3 // 状态检查使用较低并发
if concurrencyStr != "" {
if conc, err := strconv.Atoi(concurrencyStr); err == nil && conc > 0 {
concurrency = conc / 2 // 状态检查并发减半
if concurrency < 1 {
concurrency = 1
}
}
}
// 由于没有URL需要检查跳过循环
if len(urlsToCheck) == 0 {
utils.Info("没有URL需要状态检查")
return nil
}
semaphore := make(chan struct{}, concurrency)
for _, url := range urlsToCheck {
go func(u string) {
semaphore <- struct{}{}
defer func() { <-semaphore }()
// 检查URL最新状态
result, err := s.googleClient.InspectURL(u)
if err != nil {
utils.Error("检查URL状态失败: %s, 错误: %v", u, err)
return
}
// 记录状态
s.recordURLStatus(u, result)
}(url)
}
// 等待所有检查完成
for i := 0; i < len(urlsToCheck); i++ {
<-semaphore
}
utils.Info("索引状态检查完成")
return nil
}
// recordURLStatus 记录URL索引状态
func (s *GoogleIndexScheduler) recordURLStatus(url string, result *google.URLInspectionResult) {
// 暂时只记录日志,不保存到数据库
// TODO: 后续通过扩展BaseScheduler来支持TaskItemRepository以保存状态
utils.Debug("记录URL状态: %s - %s", url, result.IndexStatusResult.IndexingState)
}
// updateURLStatus 更新URL状态
func (s *GoogleIndexScheduler) updateURLStatus(taskItem *entity.TaskItem, result *google.URLInspectionResult) {
// 暂时只记录日志,不保存到数据库
// TODO: 后续通过扩展BaseScheduler来支持TaskItemRepository以保存状态
utils.Debug("更新URL状态: %s - %s", taskItem.URL, result.IndexStatusResult.IndexingState)
}
// SetRunning 设置运行状态
func (s *GoogleIndexScheduler) SetRunning(running bool) {
s.isRunning = running
}

View File

@@ -11,6 +11,7 @@ type Manager struct {
hotDramaScheduler *HotDramaScheduler hotDramaScheduler *HotDramaScheduler
readyResourceScheduler *ReadyResourceScheduler readyResourceScheduler *ReadyResourceScheduler
sitemapScheduler *SitemapScheduler sitemapScheduler *SitemapScheduler
googleIndexScheduler *GoogleIndexScheduler
} }
// NewManager 创建调度器管理器 // NewManager 创建调度器管理器
@@ -40,12 +41,14 @@ func NewManager(
hotDramaScheduler := NewHotDramaScheduler(baseScheduler) hotDramaScheduler := NewHotDramaScheduler(baseScheduler)
readyResourceScheduler := NewReadyResourceScheduler(baseScheduler) readyResourceScheduler := NewReadyResourceScheduler(baseScheduler)
sitemapScheduler := NewSitemapScheduler(baseScheduler) sitemapScheduler := NewSitemapScheduler(baseScheduler)
googleIndexScheduler := NewGoogleIndexScheduler(baseScheduler)
return &Manager{ return &Manager{
baseScheduler: baseScheduler, baseScheduler: baseScheduler,
hotDramaScheduler: hotDramaScheduler, hotDramaScheduler: hotDramaScheduler,
readyResourceScheduler: readyResourceScheduler, readyResourceScheduler: readyResourceScheduler,
sitemapScheduler: sitemapScheduler, sitemapScheduler: sitemapScheduler,
googleIndexScheduler: googleIndexScheduler,
} }
} }
@@ -59,6 +62,9 @@ func (m *Manager) StartAll() {
// 启动待处理资源调度任务 // 启动待处理资源调度任务
m.readyResourceScheduler.Start() m.readyResourceScheduler.Start()
// 启动Google索引调度任务
m.googleIndexScheduler.Start()
utils.Debug("所有调度任务已启动") utils.Debug("所有调度任务已启动")
} }
@@ -72,6 +78,9 @@ func (m *Manager) StopAll() {
// 停止待处理资源调度任务 // 停止待处理资源调度任务
m.readyResourceScheduler.Stop() m.readyResourceScheduler.Stop()
// 停止Google索引调度任务
m.googleIndexScheduler.Stop()
utils.Debug("所有调度任务已停止") utils.Debug("所有调度任务已停止")
} }
@@ -140,11 +149,27 @@ func (m *Manager) TriggerSitemapGeneration() {
go m.sitemapScheduler.generateSitemap() go m.sitemapScheduler.generateSitemap()
} }
// StartGoogleIndexScheduler 启动Google索引调度任务
func (m *Manager) StartGoogleIndexScheduler() {
m.googleIndexScheduler.Start()
}
// StopGoogleIndexScheduler 停止Google索引调度任务
func (m *Manager) StopGoogleIndexScheduler() {
m.googleIndexScheduler.Stop()
}
// IsGoogleIndexRunning 检查Google索引调度任务是否在运行
func (m *Manager) IsGoogleIndexRunning() bool {
return m.googleIndexScheduler.IsRunning()
}
// GetStatus 获取所有调度任务的状态 // GetStatus 获取所有调度任务的状态
func (m *Manager) GetStatus() map[string]bool { func (m *Manager) GetStatus() map[string]bool {
return map[string]bool{ return map[string]bool{
"hot_drama": m.IsHotDramaRunning(), "hot_drama": m.IsHotDramaRunning(),
"ready_resource": m.IsReadyResourceRunning(), "ready_resource": m.IsReadyResourceRunning(),
"sitemap": m.IsSitemapRunning(), "sitemap": m.IsSitemapRunning(),
"google_index": m.IsGoogleIndexRunning(),
} }
} }

View File

@@ -0,0 +1,539 @@
<template>
<div class="tab-content-container">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<!-- Google索引配置 -->
<div class="mb-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Google索引配置</h3>
<p class="text-gray-600 dark:text-gray-400">配置Google Search Console API和索引相关设置</p>
</div>
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="flex items-center justify-between mb-4">
<div>
<h4 class="font-medium text-gray-900 dark:text-white mb-2">Google索引功能</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
开启后系统将自动检查和提交URL到Google索引
</p>
</div>
<n-switch
v-model:value="googleIndexConfig.enabled"
@update:value="updateGoogleIndexConfig"
:loading="configLoading"
size="large"
>
<template #checked>已开启</template>
<template #unchecked>已关闭</template>
</n-switch>
</div>
<!-- 配置详情 -->
<div class="border-t border-gray-200 dark:border-gray-600 pt-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">站点URL</label>
<n-input
:value="systemConfig?.site_url || '站点URL未配置'"
:disabled="true"
placeholder="请先在站点配置中设置站点URL"
>
<template #prefix>
<i class="fas fa-globe text-gray-400"></i>
</template>
</n-input>
<!-- 所有权验证按钮 -->
<div class="mt-3">
<n-button
type="info"
size="small"
ghost
@click="$emit('show-verification')"
>
<template #icon>
<i class="fas fa-shield-alt"></i>
</template>
所有权验证
</n-button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">凭据文件路径</label>
<div class="flex flex-col space-y-2">
<n-input
:value="credentialsFilePath"
placeholder="点击上传按钮选择文件"
:disabled="true"
/>
<div class="flex space-x-2">
<!-- 申请凭据按钮 -->
<n-button
size="small"
type="warning"
ghost
@click="$emit('show-credentials-guide')"
>
<template #icon>
<i class="fas fa-question-circle"></i>
</template>
申请凭据
</n-button>
<!-- 上传按钮 -->
<n-button
size="small"
type="primary"
ghost
@click="$emit('select-credentials-file')"
>
<template #icon>
<i class="fas fa-upload"></i>
</template>
上传凭据
</n-button>
<!-- 验证按钮 -->
<n-button
size="small"
type="info"
ghost
@click="validateCredentials"
:loading="validatingCredentials"
:disabled="!credentialsFilePath"
>
<template #icon>
<i class="fas fa-check-circle"></i>
</template>
验证凭据
</n-button>
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">检查间隔(分钟)</label>
<n-input-number
v-model:value="googleIndexConfig.checkInterval"
:min="1"
:max="1440"
@update:value="updateGoogleIndexConfig"
:disabled="!credentialsFilePath"
style="width: 100%"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">批处理大小</label>
<n-input-number
v-model:value="googleIndexConfig.batchSize"
:min="1"
:max="1000"
@update:value="updateGoogleIndexConfig"
:disabled="!credentialsFilePath"
style="width: 100%"
/>
</div>
</div>
</div>
<!-- 凭据状态 -->
<div v-if="credentialsStatus" class="mt-4 p-3 rounded-lg border"
:class="{
'bg-green-50 border-green-200 text-green-700 dark:bg-green-900/20 dark:border-green-800 dark:text-green-300': credentialsStatus === 'valid',
'bg-yellow-50 border-yellow-200 text-yellow-700 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-300': credentialsStatus === 'invalid',
'bg-blue-50 border-blue-200 text-blue-700 dark:bg-blue-900/20 dark:border-blue-800 dark:text-blue-300': credentialsStatus === 'verifying'
}"
>
<div class="flex items-center">
<i
:class="{
'fas fa-check-circle text-green-500 dark:text-green-400': credentialsStatus === 'valid',
'fas fa-exclamation-circle text-yellow-500 dark:text-yellow-400': credentialsStatus === 'invalid',
'fas fa-spinner fa-spin text-blue-500 dark:text-blue-400': credentialsStatus === 'verifying'
}"
class="mr-2"
></i>
<span>{{ credentialsStatusMessage }}</span>
</div>
</div>
</div>
<!-- Google索引统计 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<div class="flex items-center">
<div class="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
<i class="fas fa-link text-blue-600 dark:text-blue-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-gray-600 dark:text-gray-400">总URL数</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ googleIndexStats.totalURLs || 0 }}</p>
</div>
</div>
</div>
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
<div class="flex items-center">
<div class="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
<i class="fas fa-check-circle text-green-600 dark:text-green-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-gray-600 dark:text-gray-400">已索引</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ googleIndexStats.indexedURLs || 0 }}</p>
</div>
</div>
</div>
<div class="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4">
<div class="flex items-center">
<div class="p-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
<i class="fas fa-exclamation-triangle text-yellow-600 dark:text-yellow-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-gray-600 dark:text-gray-400">错误数</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ googleIndexStats.errorURLs || 0 }}</p>
</div>
</div>
</div>
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
<div class="flex items-center">
<div class="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
<i class="fas fa-tasks text-purple-600 dark:text-purple-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-gray-600 dark:text-gray-400">总任务数</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ googleIndexStats.totalTasks || 0 }}</p>
</div>
</div>
</div>
</div>
<!-- 外部工具链接 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
<i class="fas fa-chart-line text-purple-600 dark:text-purple-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-gray-600 dark:text-gray-400">Google Search Console</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">查看详细分析数据</p>
</div>
</div>
<a
:href="getSearchConsoleUrl()"
target="_blank"
class="px-3 py-1 bg-purple-600 text-white rounded-md hover:bg-purple-700 transition-colors text-sm"
>
打开控制台
</a>
</div>
</div>
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="p-2 bg-orange-100 dark:bg-orange-900 rounded-lg">
<i class="fas fa-chart-line text-orange-600 dark:text-orange-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-gray-600 dark:text-gray-400">Google Analytics</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">网站流量分析仪表板</p>
</div>
</div>
<a
:href="getAnalyticsUrl()"
target="_blank"
class="px-3 py-1 bg-orange-600 text-white rounded-md hover:bg-orange-700 transition-colors text-sm"
>
查看分析
</a>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex flex-wrap gap-3 mb-6">
<n-button
type="primary"
@click="$emit('manual-check-urls')"
:loading="manualCheckLoading"
size="large"
>
<template #icon>
<i class="fas fa-search"></i>
</template>
手动检查URL
</n-button>
<n-button
type="success"
@click="submitSitemap"
:loading="submitSitemapLoading"
size="large"
>
<template #icon>
<i class="fas fa-upload"></i>
</template>
提交网站地图
</n-button>
<n-button
type="info"
@click="$emit('refresh-status')"
size="large"
>
<template #icon>
<i class="fas fa-sync-alt"></i>
</template>
刷新状态
</n-button>
</div>
<!-- 任务列表 -->
<div>
<h4 class="text-lg font-medium text-gray-900 dark:text-white mb-4">索引任务列表</h4>
<n-data-table
:columns="taskColumns"
:data="tasks"
:pagination="pagination"
:loading="tasksLoading"
:bordered="false"
striped
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useMessage } from 'naive-ui'
import { useApi } from '~/composables/useApi'
import { ref, computed, h, watch } from 'vue'
// Props
interface Props {
systemConfig?: any
googleIndexConfig: any
googleIndexStats: any
tasks: any[]
credentialsStatus: string | null
credentialsStatusMessage: string
configLoading: boolean
manualCheckLoading: boolean
submitSitemapLoading: boolean
tasksLoading: boolean
pagination: any
}
const props = withDefaults(defineProps<Props>(), {
systemConfig: null,
googleIndexConfig: () => ({}),
googleIndexStats: () => ({}),
tasks: () => [],
credentialsStatus: null,
credentialsStatusMessage: '',
configLoading: false,
manualCheckLoading: false,
submitSitemapLoading: false,
tasksLoading: false,
pagination: () => ({})
})
// Emits
const emit = defineEmits<{
'update:google-index-config': []
'show-verification': []
'show-credentials-guide': []
'select-credentials-file': []
'manual-check-urls': []
'refresh-status': []
}>()
// 获取消息组件
const message = useMessage()
// 本地状态
const validatingCredentials = ref(false)
// 计算属性,用于安全地访问凭据文件路径
const credentialsFilePath = computed(() => {
const path = props.googleIndexConfig?.credentialsFile || ''
console.log('Component computed credentialsFilePath:', path)
return path
})
// 任务表格列
const taskColumns = [
{
title: 'ID',
key: 'id',
width: 80
},
{
title: '标题',
key: 'name',
width: 200
},
{
title: '类型',
key: 'type',
width: 120,
render: (row: any) => {
const typeMap = {
status_check: { text: '状态检查', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' },
sitemap_submit: { text: '网站地图', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
url_indexing: { text: 'URL索引', class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' }
}
const type = typeMap[row.type as keyof typeof typeMap] || { text: row.type, class: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' }
return h('span', {
class: `px-2 py-1 text-xs font-medium rounded ${type.class}`
}, type.text)
}
},
{
title: '状态',
key: 'status',
width: 100,
render: (row: any) => {
const statusMap = {
pending: { text: '待处理', class: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' },
running: { text: '运行中', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' },
completed: { text: '完成', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
failed: { text: '失败', class: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' }
}
const status = statusMap[row.status as keyof typeof statusMap] || { text: row.status, class: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' }
return h('span', {
class: `px-2 py-1 text-xs font-medium rounded ${status.class}`
}, status.text)
}
},
{
title: '总项目',
key: 'totalItems',
width: 100
},
{
title: '成功/失败',
key: 'progress',
width: 120,
render: (row: any) => {
return h('span', `${row.successful_items} / ${row.failed_items}`)
}
},
{
title: '创建时间',
key: 'created_at',
width: 150,
render: (row: any) => {
return row.created_at ? new Date(row.created_at).toLocaleString('zh-CN') : 'N/A'
}
},
{
title: '操作',
key: 'actions',
width: 150,
render: (row: any) => {
return h('div', { class: 'space-x-2' }, [
h('button', {
class: 'text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 text-sm',
onClick: () => emit('view-task-items', row.id)
}, '详情'),
h('button', {
class: 'text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 text-sm',
disabled: row.status !== 'pending' && row.status !== 'running',
onClick: () => emit('start-task', row.id)
}, '启动')
].filter(btn => !btn.props?.disabled))
}
}
]
// 验证凭据
const validateCredentials = async () => {
if (!credentialsFilePath.value) {
message.warning('请先上传凭据文件')
return
}
validatingCredentials.value = true
try {
const api = useApi()
const response = await api.googleIndexApi.validateCredentials({})
if (response?.valid) {
message.success('凭据验证成功')
emit('update:google-index-config')
} else {
message.error('凭据验证失败:' + (response?.message || '凭据无效或权限不足'))
}
} catch (error: any) {
console.error('凭据验证失败:', error)
message.error('凭据验证失败: ' + (error?.message || '网络错误'))
} finally {
validatingCredentials.value = false
}
}
// 更新Google索引配置
const updateGoogleIndexConfig = async () => {
emit('update:google-index-config')
}
// 提交网站地图
const submitSitemap = async () => {
const siteUrl = props.systemConfig?.site_url || ''
if (!siteUrl) {
message.warning('请先在站点配置中设置站点URL')
return
}
const sitemapUrl = siteUrl + '/sitemap.xml'
try {
const api = useApi()
const response = await api.googleIndexApi.createGoogleIndexTask({
title: `网站地图提交任务 - ${new Date().toLocaleString('zh-CN')}`,
type: 'sitemap_submit',
description: `提交网站地图: ${sitemapUrl}`,
SitemapURL: sitemapUrl
})
if (response) {
message.success('网站地图提交任务已创建')
emit('refresh-status')
}
} catch (error) {
console.error('提交网站地图失败:', error)
message.error('提交网站地图失败')
}
}
// 获取Google Search Console URL
const getSearchConsoleUrl = () => {
const siteUrl = props.systemConfig?.site_url || ''
if (!siteUrl) {
return 'https://search.google.com/search-console'
}
// 格式化URL用于Google Search Console
const normalizedUrl = siteUrl.startsWith('http') ? siteUrl : `https://${siteUrl}`
return `https://search.google.com/search-console/performance/search-analytics?resource_id=${encodeURIComponent(normalizedUrl)}`
}
// 获取Google Analytics URL
const getAnalyticsUrl = () => {
const siteUrl = props.systemConfig?.site_url || ''
// 格式化URL用于Google Analytics
const normalizedUrl = siteUrl.startsWith('http') ? siteUrl : `https://${siteUrl}`
// 跳转到Google Analytics
return 'https://analytics.google.com/'
}
</script>
<style scoped>
.tab-content-container {
height: calc(100vh - 240px);
overflow-y: auto;
padding-bottom: 1rem;
}
</style>

View File

@@ -0,0 +1,199 @@
<template>
<div class="tab-content-container">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div class="mb-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">外链建设待开发</h3>
<p class="text-gray-600 dark:text-gray-400">管理和监控外部链接建设情况</p>
</div>
<!-- 外链统计 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<div class="flex items-center">
<div class="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
<i class="fas fa-link text-blue-600 dark:text-blue-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-gray-600 dark:text-gray-400">总外链数</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ linkStats.total }}</p>
</div>
</div>
</div>
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
<div class="flex items-center">
<div class="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
<i class="fas fa-check text-green-600 dark:text-green-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-gray-600 dark:text-gray-400">有效外链</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ linkStats.valid }}</p>
</div>
</div>
</div>
<div class="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4">
<div class="flex items-center">
<div class="p-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
<i class="fas fa-clock text-yellow-600 dark:text-yellow-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-gray-600 dark:text-gray-400">待审核</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ linkStats.pending }}</p>
</div>
</div>
</div>
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4">
<div class="flex items-center">
<div class="p-2 bg-red-100 dark:bg-red-900 rounded-lg">
<i class="fas fa-times text-red-600 dark:text-red-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-gray-600 dark:text-gray-400">失效外链</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ linkStats.invalid }}</p>
</div>
</div>
</div>
</div>
<!-- 外链列表 -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-lg font-medium text-gray-900 dark:text-white">外链列表</h4>
<n-button type="primary" @click="$emit('add-new-link')">
<template #icon>
<i class="fas fa-plus"></i>
</template>
添加外链
</n-button>
</div>
<n-data-table
:columns="linkColumns"
:data="linkList"
:pagination="pagination"
:loading="loading"
:bordered="false"
striped
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, h } from 'vue'
// Props
interface Props {
linkStats: {
total: number
valid: number
pending: number
invalid: number
}
linkList: Array<{
id: number
url: string
title: string
status: string
domain: string
created_at: string
}>
loading: boolean
pagination: any
}
const props = withDefaults(defineProps<Props>(), {
linkStats: () => ({
total: 0,
valid: 0,
pending: 0,
invalid: 0
}),
linkList: () => [],
loading: false,
pagination: () => ({})
})
// Emits
const emit = defineEmits<{
'add-new-link': []
'edit-link': [row: any]
'delete-link': [row: any]
'load-link-list': [page: number]
}>()
// 表格列配置
const linkColumns = [
{
title: 'URL',
key: 'url',
width: 300,
render: (row: any) => {
return h('a', {
href: row.url,
target: '_blank',
class: 'text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300'
}, row.url)
}
},
{
title: '标题',
key: 'title',
width: 200
},
{
title: '域名',
key: 'domain',
width: 150
},
{
title: '状态',
key: 'status',
width: 100,
render: (row: any) => {
const statusMap = {
valid: { text: '有效', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
pending: { text: '待审核', class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' },
invalid: { text: '失效', class: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' }
}
const status = statusMap[row.status as keyof typeof statusMap]
return h('span', {
class: `px-2 py-1 text-xs font-medium rounded ${status.class}`
}, status.text)
}
},
{
title: '创建时间',
key: 'created_at',
width: 120
},
{
title: '操作',
key: 'actions',
width: 120,
render: (row: any) => {
return h('div', { class: 'space-x-2' }, [
h('button', {
class: 'text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300',
onClick: () => emit('edit-link', row)
}, '编辑'),
h('button', {
class: 'text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300',
onClick: () => emit('delete-link', row)
}, '删除')
])
}
}
]
</script>
<style scoped>
.tab-content-container {
height: calc(100vh - 240px);
overflow-y: auto;
padding-bottom: 1rem;
}
</style>

View File

@@ -0,0 +1,275 @@
<template>
<div class="tab-content-container">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div class="mb-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">站点提交待开发</h3>
<p class="text-gray-600 dark:text-gray-400">向各大搜索引擎提交站点信息</p>
</div>
<!-- 搜索引擎列表 -->
<div class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- 百度 -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-blue-500 rounded flex items-center justify-center">
<i class="fas fa-search text-white text-sm"></i>
</div>
<div>
<h4 class="font-medium text-gray-900 dark:text-white">百度</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">baidu.com</p>
</div>
</div>
<n-button size="small" type="primary" @click="submitToBaidu">
<template #icon>
<i class="fas fa-paper-plane"></i>
</template>
提交
</n-button>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
最后提交时间{{ lastSubmitTime.baidu || '未提交' }}
</div>
</div>
<!-- 谷歌 -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-red-500 rounded flex items-center justify-center">
<i class="fas fa-globe text-white text-sm"></i>
</div>
<div>
<h4 class="font-medium text-gray-900 dark:text-white">谷歌</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">google.com</p>
</div>
</div>
<n-button size="small" type="primary" @click="submitToGoogle">
<template #icon>
<i class="fas fa-paper-plane"></i>
</template>
提交
</n-button>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
最后提交时间{{ lastSubmitTime.google || '未提交' }}
</div>
</div>
<!-- 必应 -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-green-500 rounded flex items-center justify-center">
<i class="fas fa-search text-white text-sm"></i>
</div>
<div>
<h4 class="font-medium text-gray-900 dark:text-white">必应</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">bing.com</p>
</div>
</div>
<n-button size="small" type="primary" @click="submitToBing">
<template #icon>
<i class="fas fa-paper-plane"></i>
</template>
提交
</n-button>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
最后提交时间{{ lastSubmitTime.bing || '未提交' }}
</div>
</div>
<!-- 搜狗 -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-orange-500 rounded flex items-center justify-center">
<i class="fas fa-search text-white text-sm"></i>
</div>
<div>
<h4 class="font-medium text-gray-900 dark:text-white">搜狗</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">sogou.com</p>
</div>
</div>
<n-button size="small" type="primary" @click="submitToSogou">
<template #icon>
<i class="fas fa-paper-plane"></i>
</template>
提交
</n-button>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
最后提交时间{{ lastSubmitTime.sogou || '未提交' }}
</div>
</div>
<!-- 神马搜索 -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-purple-500 rounded flex items-center justify-center">
<i class="fas fa-mobile-alt text-white text-sm"></i>
</div>
<div>
<h4 class="font-medium text-gray-900 dark:text-white">神马搜索</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">sm.cn</p>
</div>
</div>
<n-button size="small" type="primary" @click="submitToShenma">
<template #icon>
<i class="fas fa-paper-plane"></i>
</template>
提交
</n-button>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
最后提交时间{{ lastSubmitTime.shenma || '未提交' }}
</div>
</div>
<!-- 360搜索 -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 bg-green-600 rounded flex items-center justify-center">
<i class="fas fa-shield-alt text-white text-sm"></i>
</div>
<div>
<h4 class="font-medium text-gray-900 dark:text-white">360搜索</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">so.com</p>
</div>
</div>
<n-button size="small" type="primary" @click="submitTo360">
<template #icon>
<i class="fas fa-paper-plane"></i>
</template>
提交
</n-button>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
最后提交时间{{ lastSubmitTime.so360 || '未提交' }}
</div>
</div>
</div>
<!-- 批量提交 -->
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium text-blue-900 dark:text-blue-100">批量提交</h4>
<p class="text-sm text-blue-700 dark:text-blue-300 mt-1">
一键提交到所有支持的搜索引擎
</p>
</div>
<n-button type="primary" @click="submitToAll">
<template #icon>
<i class="fas fa-rocket"></i>
</template>
批量提交
</n-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useMessage } from 'naive-ui'
import { ref } from 'vue'
// Props
interface Props {
lastSubmitTime: {
baidu: string
google: string
bing: string
sogou: string
shenma: string
so360: string
}
}
const props = withDefaults(defineProps<Props>(), {
lastSubmitTime: () => ({
baidu: '',
google: '',
bing: '',
sogou: '',
shenma: '',
so360: ''
})
})
// Emits
const emit = defineEmits<{
'update:last-submit-time': [engine: string, time: string]
}>()
// 获取消息组件
const message = useMessage()
// 提交到百度
const submitToBaidu = () => {
const time = new Date().toLocaleString('zh-CN')
emit('update:last-submit-time', 'baidu', time)
message.success('已提交到百度')
}
// 提交到谷歌
const submitToGoogle = () => {
const time = new Date().toLocaleString('zh-CN')
emit('update:last-submit-time', 'google', time)
message.success('已提交到谷歌')
}
// 提交到必应
const submitToBing = () => {
const time = new Date().toLocaleString('zh-CN')
emit('update:last-submit-time', 'bing', time)
message.success('已提交到必应')
}
// 提交到搜狗
const submitToSogou = () => {
const time = new Date().toLocaleString('zh-CN')
emit('update:last-submit-time', 'sogou', time)
message.success('已提交到搜狗')
}
// 提交到神马搜索
const submitToShenma = () => {
const time = new Date().toLocaleString('zh-CN')
emit('update:last-submit-time', 'shenma', time)
message.success('已提交到神马搜索')
}
// 提交到360搜索
const submitTo360 = () => {
const time = new Date().toLocaleString('zh-CN')
emit('update:last-submit-time', 'so360', time)
message.success('已提交到360搜索')
}
// 批量提交
const submitToAll = () => {
const time = new Date().toLocaleString('zh-CN')
const engines = ['baidu', 'google', 'bing', 'sogou', 'shenma', 'so360']
engines.forEach(engine => {
emit('update:last-submit-time', engine, time)
})
message.success('已批量提交到所有搜索引擎')
}
</script>
<style scoped>
.tab-content-container {
height: calc(100vh - 240px);
overflow-y: auto;
padding-bottom: 1rem;
}
</style>

View File

@@ -0,0 +1,226 @@
<template>
<div class="tab-content-container">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<!-- Sitemap配置 -->
<div class="mb-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Sitemap配置</h3>
<p class="text-gray-600 dark:text-gray-400">管理网站的Sitemap生成和配置</p>
</div>
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="flex items-center justify-between mb-4">
<div>
<h4 class="font-medium text-gray-900 dark:text-white mb-2">自动生成Sitemap</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
开启后系统将定期自动生成Sitemap文件
</p>
</div>
<n-switch
v-model:value="sitemapConfig.autoGenerate"
@update:value="updateSitemapConfig"
:loading="configLoading"
size="large"
>
<template #checked>已开启</template>
<template #unchecked>已关闭</template>
</n-switch>
</div>
<!-- 配置详情 -->
<div class="border-t border-gray-200 dark:border-gray-600 pt-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">站点URL</label>
<n-input
:value="systemConfig?.site_url || '站点URL未配置'"
:disabled="true"
placeholder="请先在站点配置中设置站点URL"
>
<template #prefix>
<i class="fas fa-globe text-gray-400"></i>
</template>
</n-input>
</div>
<div class="flex flex-col justify-end">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">最后生成时间</label>
<div class="text-sm text-gray-600 dark:text-gray-400">
{{ sitemapConfig.lastGenerate || '尚未生成' }}
</div>
</div>
</div>
</div>
</div>
<!-- Sitemap统计 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<div class="flex items-center">
<div class="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
<i class="fas fa-link text-blue-600 dark:text-blue-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-gray-600 dark:text-gray-400">资源总数</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ sitemapStats.total_resources || 0 }}</p>
</div>
</div>
</div>
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
<div class="flex items-center">
<div class="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
<i class="fas fa-sitemap text-green-600 dark:text-green-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-gray-600 dark:text-gray-400">页面数量</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ sitemapStats.total_pages || 0 }}</p>
</div>
</div>
</div>
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
<div class="flex items-center">
<div class="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
<i class="fas fa-history text-purple-600 dark:text-purple-400"></i>
</div>
<div class="ml-3">
<p class="text-sm text-gray-600 dark:text-gray-400">最后更新</p>
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ sitemapStats.last_generate || 'N/A' }}</p>
</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex flex-wrap gap-3 mb-6">
<n-button
type="primary"
@click="generateSitemap"
:loading="isGenerating"
size="large"
>
<template #icon>
<i class="fas fa-cog"></i>
</template>
生成Sitemap
</n-button>
<n-button
type="success"
@click="viewSitemap"
size="large"
>
<template #icon>
<i class="fas fa-external-link-alt"></i>
</template>
查看Sitemap
</n-button>
<n-button
type="info"
@click="$emit('refresh-status')"
size="large"
>
<template #icon>
<i class="fas fa-sync-alt"></i>
</template>
刷新状态
</n-button>
</div>
<!-- 生成状态 -->
<div v-if="generateStatus" class="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex items-center">
<i class="fas fa-info-circle text-blue-500 dark:text-blue-400 mr-2"></i>
<span class="text-blue-700 dark:text-blue-300">{{ generateStatus }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useMessage } from 'naive-ui'
import { useApi } from '~/composables/useApi'
import { ref } from 'vue'
// Props
interface Props {
systemConfig?: any
sitemapConfig: any
sitemapStats: any
configLoading: boolean
isGenerating: boolean
generateStatus: string
}
const props = withDefaults(defineProps<Props>(), {
systemConfig: null,
sitemapConfig: () => ({}),
sitemapStats: () => ({}),
configLoading: false,
isGenerating: false,
generateStatus: ''
})
// Emits
const emit = defineEmits<{
'update:sitemap-config': [value: boolean]
'refresh-status': []
}>()
// 获取消息组件
const message = useMessage()
// 更新Sitemap配置
const updateSitemapConfig = async (value: boolean) => {
try {
const api = useApi()
await api.sitemapApi.updateSitemapConfig({
autoGenerate: value,
lastGenerate: props.sitemapConfig.lastGenerate,
lastUpdate: new Date().toISOString()
})
message.success(value ? '自动生成功能已开启' : '自动生成功能已关闭')
} catch (error) {
message.error('更新配置失败')
// 恢复之前的值
props.sitemapConfig.autoGenerate = !value
}
}
// 生成Sitemap
const generateSitemap = async () => {
// 使用已经加载的系统配置
const siteUrl = props.systemConfig?.site_url || ''
if (!siteUrl) {
message.warning('请先在站点配置中设置站点URL然后再生成Sitemap')
return
}
try {
const api = useApi()
const response = await api.sitemapApi.generateSitemap({ site_url: siteUrl })
if (response) {
message.success(`Sitemap生成任务已启动使用站点URL: ${siteUrl}`)
// 更新统计信息
emit('refresh-status')
}
} catch (error: any) {
message.error('Sitemap生成失败')
}
}
// 查看Sitemap
const viewSitemap = () => {
window.open('/sitemap.xml', '_blank')
}
</script>
<style scoped>
.tab-content-container {
height: calc(100vh - 240px);
overflow-y: auto;
padding-bottom: 1rem;
}
</style>

File diff suppressed because it is too large Load Diff