使用了AI重构项目,并完善了一部分后台问题

This commit is contained in:
2026-08-05 04:47:11 +08:00
parent 146b6b1e6c
commit ac740c24fd
35 changed files with 2876 additions and 980 deletions
+188
View File
@@ -0,0 +1,188 @@
package analytics
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
type Config struct {
Mode string
APIURL string
Credential string
WebsiteID string
}
type Stats struct {
Pageviews int
Visitors int
Visits int
Bounces int
TotalTime int64
}
type Point struct {
X string `json:"x"`
Y int `json:"y"`
}
type Client struct {
httpClient *http.Client
}
func NewClient() *Client {
return &Client{httpClient: &http.Client{Timeout: 5 * time.Second}}
}
func (c *Client) GetStats(ctx context.Context, cfg Config, startAt, endAt time.Time) (Stats, error) {
var response struct {
Pageviews json.RawMessage `json:"pageviews"`
Visitors json.RawMessage `json:"visitors"`
Visits json.RawMessage `json:"visits"`
Bounces json.RawMessage `json:"bounces"`
TotalTime json.RawMessage `json:"totaltime"`
}
if err := c.get(ctx, cfg, "/websites/"+url.PathEscape(cfg.WebsiteID)+"/stats", startAt, endAt, "", &response); err != nil {
return Stats{}, err
}
return Stats{
Pageviews: rawNumber(response.Pageviews),
Visitors: rawNumber(response.Visitors),
Visits: rawNumber(response.Visits),
Bounces: rawNumber(response.Bounces),
TotalTime: int64(rawNumber(response.TotalTime)),
}, nil
}
func (c *Client) GetActive(ctx context.Context, cfg Config) (int, error) {
var response struct {
Visitors int `json:"visitors"`
}
if err := c.get(ctx, cfg, "/websites/"+url.PathEscape(cfg.WebsiteID)+"/active", time.Time{}, time.Time{}, "", &response); err != nil {
return 0, err
}
return response.Visitors, nil
}
func (c *Client) GetPageviews(ctx context.Context, cfg Config, startAt, endAt time.Time) ([]Point, error) {
var response struct {
Pageviews []Point `json:"pageviews"`
}
if err := c.get(ctx, cfg, "/websites/"+url.PathEscape(cfg.WebsiteID)+"/pageviews", startAt, endAt, "day", &response); err != nil {
return nil, err
}
return response.Pageviews, nil
}
func (c *Client) GetMetrics(ctx context.Context, cfg Config, startAt, endAt time.Time, metricType string) ([]Point, error) {
var response []Point
if err := c.get(ctx, cfg, "/websites/"+url.PathEscape(cfg.WebsiteID)+"/metrics", startAt, endAt, metricType, &response); err != nil {
return nil, err
}
return response, nil
}
func (c *Client) Test(ctx context.Context, cfg Config) error {
if strings.TrimSpace(cfg.WebsiteID) == "" {
return errors.New("Umami Website ID 不能为空")
}
if strings.TrimSpace(cfg.Credential) == "" {
return errors.New("Umami API 凭据未配置")
}
_, err := c.GetStats(ctx, cfg, time.Now().Add(-24*time.Hour), time.Now())
return err
}
func (c *Client) get(ctx context.Context, cfg Config, path string, startAt, endAt time.Time, unitOrType string, target any) error {
baseURL, err := normalizeBaseURL(cfg.Mode, cfg.APIURL)
if err != nil {
return err
}
requestURL, err := url.Parse(baseURL + path)
if err != nil {
return errors.New("Umami API 地址无效")
}
query := requestURL.Query()
if !startAt.IsZero() {
query.Set("startAt", strconv.FormatInt(startAt.UnixMilli(), 10))
}
if !endAt.IsZero() {
query.Set("endAt", strconv.FormatInt(endAt.UnixMilli(), 10))
}
if strings.HasSuffix(path, "/pageviews") && unitOrType != "" {
query.Set("unit", unitOrType)
}
if strings.HasSuffix(path, "/metrics") && unitOrType != "" {
query.Set("type", unitOrType)
}
requestURL.RawQuery = query.Encode()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL.String(), nil)
if err != nil {
return errors.New("创建 Umami 请求失败")
}
request.Header.Set("Accept", "application/json")
if cfg.Mode == "cloud" {
request.Header.Set("x-umami-api-key", cfg.Credential)
} else {
request.Header.Set("Authorization", "Bearer "+cfg.Credential)
}
response, err := c.httpClient.Do(request)
if err != nil {
return fmt.Errorf("Umami 请求失败: %w", err)
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("Umami 返回 HTTP %d", response.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(response.Body, 2<<20))
if err != nil {
return errors.New("读取 Umami 响应失败")
}
if err := json.Unmarshal(body, target); err != nil {
return errors.New("解析 Umami 响应失败")
}
return nil
}
func normalizeBaseURL(mode, raw string) (string, error) {
base := strings.TrimRight(strings.TrimSpace(raw), "/")
if base == "" && mode == "cloud" {
base = "https://api.umami.is/v1"
}
if base == "" {
return "", errors.New("Umami API 地址不能为空")
}
parsed, err := url.Parse(base)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return "", errors.New("Umami API 地址必须是有效的 http/https 地址")
}
if mode == "cloud" && !strings.HasSuffix(parsed.Path, "/v1") {
base += "/v1"
}
return strings.TrimRight(base, "/"), nil
}
func rawNumber(raw json.RawMessage) int {
if len(raw) == 0 || string(raw) == "null" {
return 0
}
var number float64
if json.Unmarshal(raw, &number) == nil {
return int(number)
}
var wrapped struct {
Value float64 `json:"value"`
}
if json.Unmarshal(raw, &wrapped) == nil {
return int(wrapped.Value)
}
return 0
}
+56
View File
@@ -0,0 +1,56 @@
package analytics
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestSelfHostedStatsAndPageviews(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer self-secret" {
t.Errorf("missing self-hosted auth header: %q", r.Header.Get("Authorization"))
}
if strings.HasSuffix(r.URL.Path, "/pageviews") && r.URL.Query().Get("unit") != "day" {
t.Errorf("missing pageviews unit: %q", r.URL.RawQuery)
}
w.Header().Set("Content-Type", "application/json")
if strings.HasSuffix(r.URL.Path, "/pageviews") {
_, _ = w.Write([]byte(`{"pageviews":[{"x":"8/5","y":4}]}`))
return
}
_, _ = w.Write([]byte(`{"pageviews":{"value":12},"visitors":3,"visits":5,"bounces":1,"totaltime":90}`))
}))
defer server.Close()
client := NewClient()
cfg := Config{Mode: "selfhost", APIURL: server.URL, Credential: "self-secret", WebsiteID: "website"}
stats, err := client.GetStats(context.Background(), cfg, time.Unix(0, 0), time.Now())
if err != nil || stats.Pageviews != 12 || stats.Visitors != 3 {
t.Fatalf("unexpected stats: %+v, %v", stats, err)
}
points, err := client.GetPageviews(context.Background(), cfg, time.Now().Add(-24*time.Hour), time.Now())
if err != nil || len(points) != 1 || points[0].Y != 4 {
t.Fatalf("unexpected pageviews: %+v, %v", points, err)
}
}
func TestCloudUsesAPIKey(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("x-umami-api-key") != "cloud-secret" || r.Header.Get("Authorization") != "" {
t.Errorf("unexpected cloud auth headers: api-key=%q authorization=%q", r.Header.Get("x-umami-api-key"), r.Header.Get("Authorization"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"visitors":1}`))
}))
defer server.Close()
client := NewClient()
_, err := client.GetStats(context.Background(), Config{Mode: "cloud", APIURL: server.URL + "/v1", Credential: "cloud-secret", WebsiteID: "website"}, time.Time{}, time.Time{})
if err != nil {
t.Fatal(err)
}
}
+62
View File
@@ -0,0 +1,62 @@
package api
import (
"errors"
"io"
"net/http"
"strings"
"home-vue-go/internal/analytics"
"home-vue-go/internal/config"
"home-vue-go/internal/database"
"github.com/gin-gonic/gin"
)
func TestUmamiConnection(db *database.Database, appConfig *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
Mode *string `json:"mode"`
APIURL *string `json:"apiUrl"`
WebsiteID *string `json:"websiteId"`
Credential *string `json:"credential"`
}
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
c.JSON(http.StatusBadRequest, gin.H{"error": "测试参数格式错误"})
return
}
settings, err := db.LoadSiteSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载统计配置失败"})
return
}
credential := ""
if req.Credential != nil {
credential = strings.TrimSpace(*req.Credential)
} else if settings.UmamiCredential != "" && appConfig != nil {
credential, err = appConfig.DecryptSecret(settings.UmamiCredential)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "已保存的统计凭据无法解密"})
return
}
}
mode := settings.UmamiAPIMode
apiURL := settings.UmamiAPIURL
websiteID := settings.UmamiWebsiteID
if req.Mode != nil {
mode = strings.TrimSpace(*req.Mode)
}
if req.APIURL != nil {
apiURL = strings.TrimSpace(*req.APIURL)
}
if req.WebsiteID != nil {
websiteID = strings.TrimSpace(*req.WebsiteID)
}
clientConfig := analytics.Config{Mode: mode, APIURL: apiURL, Credential: credential, WebsiteID: websiteID}
if err := umamiClient.Test(c.Request.Context(), clientConfig); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "Umami连接失败,请检查地址、Website ID 和凭据"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "message": "Umami连接成功"})
}
}
+256 -230
View File
@@ -1,7 +1,10 @@
package api
import (
"fmt"
"net/http"
"net/url"
"strings"
"home-vue-go/internal/config"
"home-vue-go/internal/database"
@@ -9,271 +12,294 @@ import (
"github.com/gin-gonic/gin"
)
// GetSiteConfig 获取站点配置(含底部年份配置)
func GetSiteConfig(db *database.Database, cfg *config.Config) gin.HandlerFunc {
type updateSiteConfigRequest struct {
SiteName *string `json:"siteName"`
SiteURL *string `json:"siteURL"`
SiteIcon *string `json:"siteIcon"`
SiteDescription *string `json:"siteDescription"`
SiteKeywords *string `json:"siteKeywords"`
UserName *string `json:"userName"`
ProfileImageURL *string `json:"profileImageURL"`
ICPNumber *string `json:"icpNumber"`
PoliceNumber *string `json:"policeNumber"`
PageTitle *string `json:"pageTitle"`
Favicon *string `json:"favicon"`
IconLibrary *string `json:"iconLibrary"`
FontLibrary *string `json:"fontLibrary"`
FooterYearStart *string `json:"footerYearStart"`
FooterYearEnd *string `json:"footerYearEnd"`
ShowVisitTimer *bool `json:"showVisitTimer"`
RotatingTexts *[]string `json:"rotatingTexts"`
GreetingText *string `json:"greetingText"`
OnlineStatusText *string `json:"onlineStatusText"`
FooterLabel *string `json:"footerLabel"`
ShowAbout *bool `json:"showAbout"`
ShowSites *bool `json:"showSites"`
ShowContacts *bool `json:"showContacts"`
ShowThemeToggle *bool `json:"showThemeToggle"`
ShowFooter *bool `json:"showFooter"`
AboutTitle *string `json:"aboutTitle"`
AboutDescription *string `json:"aboutDescription"`
AboutLinks *[]config.AboutLink `json:"aboutLinks"`
SitePageSize *int `json:"sitePageSize"`
OpenLinksNewTab *bool `json:"openLinksInNewTab"`
AnalyticsProvider *string `json:"analyticsProvider"`
UmamiScript *string `json:"umamiScript"`
UmamiScriptURL *string `json:"umamiScriptUrl"`
UmamiWebsiteID *string `json:"umamiWebsiteId"`
UmamiAPIMode *string `json:"umamiApiMode"`
UmamiAPIURL *string `json:"umamiApiUrl"`
UmamiCredential *string `json:"umamiCredential"`
ClearUmamiCredential *bool `json:"clearUmamiCredential"`
UmamiDomains *string `json:"umamiDomains"`
UmamiDoNotTrack *bool `json:"umamiDoNotTrack"`
UmamiExcludeSearch *bool `json:"umamiExcludeSearch"`
UmamiExcludeHash *bool `json:"umamiExcludeHash"`
UmamiPerformance *bool `json:"umamiPerformance"`
UmamiTag *string `json:"umamiTag"`
UmamiTrackerOptions *struct {
Domains *string `json:"domains"`
DoNotTrack *bool `json:"doNotTrack"`
ExcludeSearch *bool `json:"excludeSearch"`
ExcludeHash *bool `json:"excludeHash"`
Performance *bool `json:"performance"`
Tag *string `json:"tag"`
} `json:"umamiTrackerOptions"`
}
func GetSiteConfig(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
footerCfg, _ := cfg.LoadFooterYear()
visitTimerCfg, _ := cfg.LoadVisitTimer()
siteCfg, err := db.Client.SiteConfig.Get(ctx, 1)
settings, err := db.LoadSiteSettings(c.Request.Context())
if err != nil {
// 如果不存在,返回默认值 + 年份配置
c.JSON(http.StatusOK, gin.H{
"siteName": "个人主页",
"siteURL": "https://example.com",
"siteIcon": "/favicon.ico",
"siteDescription": "一个基于Vue3的个人主页",
"siteKeywords": "个人主页,Vue3",
"userName": "用户",
"profileImageURL": "",
"icpNumber": "暂未填写",
"policeNumber": "暂未填写",
"pageTitle": "个人主页",
"favicon": "/favicon.ico",
"umamiScript": "",
"umamiWebsiteId": "",
"iconLibrary": "//lib.baomitu.com/font-awesome/6.5.0/css/all.min.css",
"fontLibrary": "",
"showVisitTimer": visitTimerCfg.ShowVisitTimer,
"footerYearStart": footerCfg.Start,
"footerYearEnd": footerCfg.End,
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载站点配置失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"siteName": siteCfg.SiteName,
"siteURL": siteCfg.SiteURL,
"siteIcon": siteCfg.SiteIcon,
"siteDescription": siteCfg.SiteDescription,
"siteKeywords": siteCfg.SiteKeywords,
"userName": siteCfg.UserName,
"profileImageURL": siteCfg.ProfileImageURL,
"icpNumber": siteCfg.IcpNumber,
"policeNumber": siteCfg.PoliceNumber,
"pageTitle": siteCfg.PageTitle,
"favicon": siteCfg.Favicon,
"umamiScript": siteCfg.UmamiScript,
"umamiWebsiteId": siteCfg.UmamiWebsiteID,
"iconLibrary": siteCfg.IconLibrary,
"fontLibrary": siteCfg.FontLibrary,
"showVisitTimer": visitTimerCfg.ShowVisitTimer,
"footerYearStart": footerCfg.Start,
"footerYearEnd": footerCfg.End,
})
c.JSON(http.StatusOK, siteConfigResponse(settings, false))
}
}
func GetAdminSiteConfig(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
settings, err := db.LoadSiteSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载站点配置失败"})
return
}
c.JSON(http.StatusOK, siteConfigResponse(settings, true))
}
}
// UpdateSiteConfig 更新站点配置(含底部年份配置)
func UpdateSiteConfig(db *database.Database, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
SiteName string `json:"siteName"`
SiteURL string `json:"siteURL"`
SiteIcon string `json:"siteIcon"`
SiteDescription string `json:"siteDescription"`
SiteKeywords string `json:"siteKeywords"`
UserName string `json:"userName"`
ProfileImageURL string `json:"profileImageURL"`
ICPNumber string `json:"icpNumber"`
PoliceNumber string `json:"policeNumber"`
PageTitle string `json:"pageTitle"`
Favicon string `json:"favicon"`
UmamiScript string `json:"umamiScript"`
UmamiWebsiteId string `json:"umamiWebsiteId"`
IconLibrary string `json:"iconLibrary"`
FontLibrary string `json:"fontLibrary"`
ShowVisitTimer *bool `json:"showVisitTimer"`
FooterYearStart string `json:"footerYearStart"`
FooterYearEnd string `json:"footerYearEnd"`
var req updateSiteConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "配置参数格式错误"})
return
}
if err := c.ShouldBindJSON(&req); err != nil {
ctx := c.Request.Context()
settings, err := db.LoadSiteSettings(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载现有站点配置失败"})
return
}
next := settings.Clone()
applyString(&next.SiteName, req.SiteName)
applyString(&next.SiteURL, req.SiteURL)
applyString(&next.SiteIcon, req.SiteIcon)
applyString(&next.SiteDescription, req.SiteDescription)
applyString(&next.SiteKeywords, req.SiteKeywords)
applyString(&next.UserName, req.UserName)
applyString(&next.ProfileImageURL, req.ProfileImageURL)
applyString(&next.ICPNumber, req.ICPNumber)
applyString(&next.PoliceNumber, req.PoliceNumber)
applyString(&next.PageTitle, req.PageTitle)
applyString(&next.Favicon, req.Favicon)
applyString(&next.IconLibrary, req.IconLibrary)
applyString(&next.FontLibrary, req.FontLibrary)
applyString(&next.FooterYearStart, req.FooterYearStart)
applyString(&next.FooterYearEnd, req.FooterYearEnd)
applyBool(&next.ShowVisitTimer, req.ShowVisitTimer)
applySlice(&next.RotatingTexts, req.RotatingTexts)
applyString(&next.GreetingText, req.GreetingText)
applyString(&next.OnlineStatusText, req.OnlineStatusText)
applyString(&next.FooterLabel, req.FooterLabel)
applyBool(&next.ShowAbout, req.ShowAbout)
applyBool(&next.ShowSites, req.ShowSites)
applyBool(&next.ShowContacts, req.ShowContacts)
applyBool(&next.ShowThemeToggle, req.ShowThemeToggle)
applyBool(&next.ShowFooter, req.ShowFooter)
applyString(&next.AboutTitle, req.AboutTitle)
applyString(&next.AboutDescription, req.AboutDescription)
applySlice(&next.AboutLinks, req.AboutLinks)
applyInt(&next.SitePageSize, req.SitePageSize)
applyBool(&next.OpenLinksNewTab, req.OpenLinksNewTab)
applyString(&next.AnalyticsProvider, req.AnalyticsProvider)
applyString(&next.UmamiScript, req.UmamiScript)
applyString(&next.UmamiScript, req.UmamiScriptURL)
applyString(&next.UmamiWebsiteID, req.UmamiWebsiteID)
applyString(&next.UmamiAPIMode, req.UmamiAPIMode)
applyString(&next.UmamiAPIURL, req.UmamiAPIURL)
applyString(&next.UmamiDomains, req.UmamiDomains)
applyBool(&next.UmamiDoNotTrack, req.UmamiDoNotTrack)
applyBool(&next.UmamiExcludeSearch, req.UmamiExcludeSearch)
applyBool(&next.UmamiExcludeHash, req.UmamiExcludeHash)
applyBool(&next.UmamiPerformance, req.UmamiPerformance)
applyString(&next.UmamiTag, req.UmamiTag)
if options := req.UmamiTrackerOptions; options != nil {
applyString(&next.UmamiDomains, options.Domains)
applyBool(&next.UmamiDoNotTrack, options.DoNotTrack)
applyBool(&next.UmamiExcludeSearch, options.ExcludeSearch)
applyBool(&next.UmamiExcludeHash, options.ExcludeHash)
applyBool(&next.UmamiPerformance, options.Performance)
applyString(&next.UmamiTag, options.Tag)
}
if req.ClearUmamiCredential != nil && *req.ClearUmamiCredential {
next.UmamiCredential = ""
} else if req.UmamiCredential != nil {
if strings.TrimSpace(*req.UmamiCredential) == "" {
next.UmamiCredential = ""
} else {
if cfg == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加密配置不可用"})
return
}
encrypted, err := cfg.EncryptSecret(strings.TrimSpace(*req.UmamiCredential))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存统计凭据失败"})
return
}
next.UmamiCredential = encrypted
}
}
if err := validateSiteSettings(next); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
update := db.Client.SiteConfig.UpdateOneID(1)
if req.SiteName != "" {
update.SetSiteName(req.SiteName)
}
if req.SiteURL != "" {
update.SetSiteURL(req.SiteURL)
}
if req.SiteIcon != "" {
update.SetSiteIcon(req.SiteIcon)
}
if req.SiteDescription != "" {
update.SetSiteDescription(req.SiteDescription)
}
if req.SiteKeywords != "" {
update.SetSiteKeywords(req.SiteKeywords)
}
if req.UserName != "" {
update.SetUserName(req.UserName)
}
if req.ProfileImageURL != "" {
update.SetProfileImageURL(req.ProfileImageURL)
}
if req.ICPNumber != "" {
update.SetIcpNumber(req.ICPNumber)
}
if req.PoliceNumber != "" {
update.SetPoliceNumber(req.PoliceNumber)
}
if req.PageTitle != "" {
update.SetPageTitle(req.PageTitle)
}
if req.Favicon != "" {
update.SetFavicon(req.Favicon)
}
if req.UmamiScript != "" {
update.SetUmamiScript(req.UmamiScript)
}
if req.UmamiWebsiteId != "" {
update.SetUmamiWebsiteID(req.UmamiWebsiteId)
}
if req.IconLibrary != "" {
update.SetIconLibrary(req.IconLibrary)
}
if req.FontLibrary != "" {
update.SetFontLibrary(req.FontLibrary)
}
siteCfg, err := update.Save(ctx)
if err != nil {
// 如果不存在,创建新的
create := db.Client.SiteConfig.Create().
SetSiteName(req.SiteName).
SetSiteURL(req.SiteURL).
SetSiteIcon(req.SiteIcon).
SetSiteDescription(req.SiteDescription).
SetSiteKeywords(req.SiteKeywords).
SetUserName(req.UserName)
if req.ProfileImageURL != "" {
create.SetProfileImageURL(req.ProfileImageURL)
}
if req.ICPNumber != "" {
create.SetIcpNumber(req.ICPNumber)
}
if req.PoliceNumber != "" {
create.SetPoliceNumber(req.PoliceNumber)
}
if req.PageTitle != "" {
create.SetPageTitle(req.PageTitle)
}
if req.Favicon != "" {
create.SetFavicon(req.Favicon)
}
if req.UmamiScript != "" {
create.SetUmamiScript(req.UmamiScript)
}
if req.UmamiWebsiteId != "" {
create.SetUmamiWebsiteID(req.UmamiWebsiteId)
}
if req.IconLibrary != "" {
create.SetIconLibrary(req.IconLibrary)
}
if req.FontLibrary != "" {
create.SetFontLibrary(req.FontLibrary)
}
siteCfg, err = create.Save(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
if err := db.SaveSiteSettings(ctx, next); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点配置失败"})
return
}
}
// 保存底部年份配置到独立文件
_ = cfg.SaveFooterYear(&config.FooterYearConfig{
Start: req.FooterYearStart,
End: req.FooterYearEnd,
})
// 保存访问时间显示配置
if req.ShowVisitTimer != nil {
_ = cfg.SaveVisitTimer(&config.VisitTimerConfig{
ShowVisitTimer: *req.ShowVisitTimer,
})
}
// 读取最新的配置用于返回
footerCfg, _ := cfg.LoadFooterYear()
visitTimerCfg, _ := cfg.LoadVisitTimer()
c.JSON(http.StatusOK, gin.H{
"siteName": siteCfg.SiteName,
"siteURL": siteCfg.SiteURL,
"siteIcon": siteCfg.SiteIcon,
"siteDescription": siteCfg.SiteDescription,
"siteKeywords": siteCfg.SiteKeywords,
"userName": siteCfg.UserName,
"profileImageURL": siteCfg.ProfileImageURL,
"icpNumber": siteCfg.IcpNumber,
"policeNumber": siteCfg.PoliceNumber,
"pageTitle": siteCfg.PageTitle,
"favicon": siteCfg.Favicon,
"umamiScript": siteCfg.UmamiScript,
"umamiWebsiteId": siteCfg.UmamiWebsiteID,
"iconLibrary": siteCfg.IconLibrary,
"fontLibrary": siteCfg.FontLibrary,
"showVisitTimer": visitTimerCfg.ShowVisitTimer,
"footerYearStart": footerCfg.Start,
"footerYearEnd": footerCfg.End,
})
c.JSON(http.StatusOK, siteConfigResponse(next, true))
}
}
// GetRotatingTexts 获取轮换文本配置
func GetRotatingTexts(cfg *config.Config) gin.HandlerFunc {
func GetRotatingTexts(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
textsCfg, err := cfg.LoadRotatingTexts()
settings, err := db.LoadSiteSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载轮换文本配置失败: " + err.Error()})
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载轮换文本配置失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"texts": textsCfg.Texts,
})
c.JSON(http.StatusOK, gin.H{"texts": settings.RotatingTexts})
}
}
// UpdateRotatingTexts 更新轮换文本配置
func UpdateRotatingTexts(cfg *config.Config) gin.HandlerFunc {
func UpdateRotatingTexts(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
Texts []string `json:"texts"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
c.JSON(http.StatusBadRequest, gin.H{"error": "轮换文本参数格式错误"})
return
}
// 限制最多8条文本
if len(req.Texts) > 8 {
req.Texts = req.Texts[:8]
}
textsCfg := &config.RotatingTextsConfig{
Texts: req.Texts,
}
if err := cfg.SaveRotatingTexts(textsCfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存轮换文本配置失败: " + err.Error()})
settings, err := db.LoadSiteSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载站点配置失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "轮换文本配置已保存",
"texts": textsCfg.Texts,
})
settings.RotatingTexts = req.Texts
settings.Normalize()
if err := db.SaveSiteSettings(c.Request.Context(), settings); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存轮换文本配置失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "轮换文本配置已保存", "texts": settings.RotatingTexts})
}
}
func siteConfigResponse(settings *config.SiteSettings, admin bool) gin.H {
result := gin.H{
"siteName": settings.SiteName, "siteURL": settings.SiteURL, "siteIcon": settings.SiteIcon,
"siteDescription": settings.SiteDescription, "siteKeywords": settings.SiteKeywords, "userName": settings.UserName,
"profileImageURL": settings.ProfileImageURL, "icpNumber": settings.ICPNumber, "policeNumber": settings.PoliceNumber,
"pageTitle": settings.PageTitle, "favicon": settings.Favicon, "iconLibrary": settings.IconLibrary, "fontLibrary": settings.FontLibrary,
"footerYearStart": settings.FooterYearStart, "footerYearEnd": settings.FooterYearEnd, "showVisitTimer": settings.ShowVisitTimer,
"rotatingTexts": settings.RotatingTexts, "greetingText": settings.GreetingText, "onlineStatusText": settings.OnlineStatusText,
"footerLabel": settings.FooterLabel, "showAbout": settings.ShowAbout, "showSites": settings.ShowSites, "showContacts": settings.ShowContacts,
"showThemeToggle": settings.ShowThemeToggle, "showFooter": settings.ShowFooter, "aboutTitle": settings.AboutTitle,
"aboutDescription": settings.AboutDescription, "aboutLinks": settings.AboutLinks, "sitePageSize": settings.SitePageSize,
"openLinksInNewTab": settings.OpenLinksNewTab, "analyticsProvider": settings.AnalyticsProvider, "umamiScript": settings.UmamiScript, "umamiScriptUrl": settings.UmamiScript,
"umamiWebsiteId": settings.UmamiWebsiteID, "umamiDomains": settings.UmamiDomains, "umamiDoNotTrack": settings.UmamiDoNotTrack,
"umamiExcludeSearch": settings.UmamiExcludeSearch, "umamiExcludeHash": settings.UmamiExcludeHash,
"umamiPerformance": settings.UmamiPerformance, "umamiTag": settings.UmamiTag,
"umamiTrackerOptions": gin.H{"domains": settings.UmamiDomains, "doNotTrack": settings.UmamiDoNotTrack, "excludeSearch": settings.UmamiExcludeSearch, "excludeHash": settings.UmamiExcludeHash, "performance": settings.UmamiPerformance, "tag": settings.UmamiTag},
}
if admin {
result["umamiApiMode"] = settings.UmamiAPIMode
result["umamiApiUrl"] = settings.UmamiAPIURL
result["umamiCredentialConfigured"] = settings.UmamiCredential != ""
}
return result
}
func validateSiteSettings(settings *config.SiteSettings) error {
for name, value := range map[string]string{"站点URL": settings.SiteURL, "Umami脚本地址": settings.UmamiScript, "Umami API地址": settings.UmamiAPIURL} {
if value == "" {
continue
}
parsed, err := url.ParseRequestURI(value)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return fmt.Errorf("%s必须是有效的 http/https 地址", name)
}
}
if settings.SitePageSize != 6 && settings.SitePageSize != 9 && settings.SitePageSize != 12 {
return fmt.Errorf("站点每页数量只能是 6、9 或 12")
}
if settings.AnalyticsProvider != "local" && settings.AnalyticsProvider != "umami" {
return fmt.Errorf("统计来源只能是 local 或 umami")
}
if settings.UmamiAPIMode != "selfhost" && settings.UmamiAPIMode != "cloud" {
return fmt.Errorf("Umami API 模式不正确")
}
if len(settings.AboutLinks) > 8 {
return fmt.Errorf("关于链接最多支持 8 条")
}
for _, link := range settings.AboutLinks {
if strings.TrimSpace(link.URL) == "" {
return fmt.Errorf("关于链接地址不能为空")
}
parsed, err := url.ParseRequestURI(link.URL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return fmt.Errorf("关于链接必须是有效的 http/https 地址")
}
}
return nil
}
func applyString(target *string, value *string) {
if value != nil {
*target = strings.TrimSpace(*value)
}
}
func applyBool(target *bool, value *bool) {
if value != nil {
*target = *value
}
}
func applyInt(target *int, value *int) {
if value != nil {
*target = *value
}
}
func applySlice[T any](target *[]T, value *[]T) {
if value != nil {
*target = *value
}
}
+155 -16
View File
@@ -3,8 +3,10 @@ package api
import (
"net/http"
"strconv"
"strings"
"home-vue-go/internal/database"
"home-vue-go/internal/ent"
"home-vue-go/internal/ent/contact"
"github.com/gin-gonic/gin"
@@ -36,6 +38,97 @@ func GetContacts(db *database.Database) gin.HandlerFunc {
}
}
func ReorderContacts(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
IDs []int `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表不能为空"})
return
}
seen := make(map[int]bool, len(req.IDs))
for _, id := range req.IDs {
if id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含无效ID"})
return
}
if seen[id] {
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含重复ID"})
return
}
seen[id] = true
}
ctx := c.Request.Context()
existingIDs, err := db.Client.Contact.Query().IDs(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if len(existingIDs) != len(req.IDs) {
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表与当前联系方式数量不一致"})
return
}
existing := make(map[int]bool, len(existingIDs))
for _, id := range existingIDs {
existing[id] = true
}
for _, id := range req.IDs {
if !existing[id] {
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含不存在的联系方式"})
return
}
}
tx, err := db.Client.Tx(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for index, id := range req.IDs {
if _, err := tx.Contact.UpdateOneID(id).SetSortOrder((index + 1) * 10).Save(ctx); err != nil {
_ = tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
if err := tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
contacts, err := db.Client.Contact.Query().Order(contact.BySortOrder(), contact.ByID()).All(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
result := make([]gin.H, len(contacts))
for i, contact := range contacts {
result[i] = gin.H{
"id": contact.ID,
"type": contact.Type,
"icon": contact.Icon,
"url": contact.URL,
"qrCode": contact.QrCode,
"hoverColor": contact.HoverColor,
"sortOrder": contact.SortOrder,
}
}
c.JSON(http.StatusOK, result)
}
}
func CreateContact(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
@@ -51,13 +144,31 @@ func CreateContact(db *database.Database) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 验证Email类型必须是mailto格式
if req.Type == "Email" && req.URL != "" {
if len(req.URL) < 7 || req.URL[:7] != "mailto:" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Email URL必须是mailto:格式"})
req.Type = strings.TrimSpace(req.Type)
req.Icon = strings.TrimSpace(req.Icon)
req.URL = strings.TrimSpace(req.URL)
req.QrCode = strings.TrimSpace(req.QrCode)
req.HoverColor = strings.TrimSpace(req.HoverColor)
if req.Type == "" || req.Icon == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "类型和图标不能为空"})
return
}
if req.URL != "" && !validContactURL(req.URL) {
c.JSON(http.StatusBadRequest, gin.H{"error": "联系方式URL必须是有效的http/https/mailto/tel地址"})
return
}
if req.QrCode != "" && !validQRCode(req.QrCode) {
c.JSON(http.StatusBadRequest, gin.H{"error": "二维码必须使用本地上传路径或安全的http/https图片地址"})
return
}
if req.URL != "" && req.QrCode != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "联系方式不能同时设置链接和二维码"})
return
}
if req.URL == "" && req.QrCode == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "链接或二维码不能为空"})
return
}
ctx := c.Request.Context()
@@ -103,8 +214,8 @@ func UpdateContact(db *database.Database) gin.HandlerFunc {
}
var req struct {
Type string `json:"type"`
Icon string `json:"icon"`
Type string `json:"type" binding:"required"`
Icon string `json:"icon" binding:"required"`
URL string `json:"url"`
QrCode string `json:"qrCode"`
HoverColor string `json:"hoverColor"`
@@ -115,36 +226,60 @@ func UpdateContact(db *database.Database) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 验证Email类型必须是mailto格式
if req.Type == "Email" && req.URL != "" {
if len(req.URL) < 7 || req.URL[:7] != "mailto:" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Email URL必须是mailto:格式"})
req.Type = strings.TrimSpace(req.Type)
req.Icon = strings.TrimSpace(req.Icon)
req.URL = strings.TrimSpace(req.URL)
req.QrCode = strings.TrimSpace(req.QrCode)
req.HoverColor = strings.TrimSpace(req.HoverColor)
if req.Type == "" || req.Icon == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "类型和图标不能为空"})
return
}
if req.URL != "" && !validContactURL(req.URL) {
c.JSON(http.StatusBadRequest, gin.H{"error": "联系方式URL必须是有效的http/https/mailto/tel地址"})
return
}
if req.QrCode != "" && !validQRCode(req.QrCode) {
c.JSON(http.StatusBadRequest, gin.H{"error": "二维码必须使用本地上传路径或安全的http/https图片地址"})
return
}
if req.URL != "" && req.QrCode != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "联系方式不能同时设置链接和二维码"})
return
}
if req.URL == "" && req.QrCode == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "链接或二维码不能为空"})
return
}
ctx := c.Request.Context()
update := db.Client.Contact.UpdateOneID(id)
if req.Type != "" {
update.SetType(req.Type)
}
if req.Icon != "" {
update.SetIcon(req.Icon)
}
if req.URL != "" {
update.SetURL(req.URL)
} else {
update.ClearURL()
}
if req.QrCode != "" {
update.SetQrCode(req.QrCode)
} else {
update.ClearQrCode()
}
if req.HoverColor != "" {
update.SetHoverColor(req.HoverColor)
} else {
update.ClearHoverColor()
}
update.SetSortOrder(req.SortOrder)
contact, err := update.Save(ctx)
if err != nil {
if ent.IsNotFound(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "联系方式不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -171,6 +306,10 @@ func DeleteContact(db *database.Database) gin.HandlerFunc {
ctx := c.Request.Context()
if err := db.Client.Contact.DeleteOneID(id).Exec(ctx); err != nil {
if ent.IsNotFound(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "联系方式不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+22 -39
View File
@@ -8,51 +8,34 @@ import (
"github.com/gin-gonic/gin"
)
// GetFrontendConfig 获取前端配置(用于index.html等)
// GetFrontendConfig returns only public head, runtime and tracker settings.
func GetFrontendConfig(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
config, err := db.Client.SiteConfig.Get(ctx, 1)
settings, err := db.LoadSiteSettings(c.Request.Context())
if err != nil {
// 如果不存在,返回默认值
c.JSON(http.StatusOK, gin.H{
"title": "个人主页",
"keywords": "个人主页,Vue3",
"description": "一个基于Vue3的个人主页",
"favicon": "/favicon.ico",
"umamiScript": "",
"umamiWebsiteId": "",
"iconLibrary": "//lib.baomitu.com/font-awesome/6.5.0/css/all.min.css",
"fontLibrary": "",
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载前端配置失败"})
return
}
pageTitle := config.PageTitle
if pageTitle == "" {
pageTitle = "个人主页"
}
favicon := config.Favicon
if favicon == "" {
favicon = "/favicon.ico"
}
iconLibrary := config.IconLibrary
if iconLibrary == "" {
iconLibrary = "//lib.baomitu.com/font-awesome/6.5.0/css/all.min.css"
}
// 确保返回完整的配置信息,包括站点名称和URL
c.JSON(http.StatusOK, gin.H{
"title": pageTitle,
"siteName": config.SiteName,
"siteURL": config.SiteURL,
"keywords": config.SiteKeywords,
"description": config.SiteDescription,
"favicon": favicon,
"umamiScript": config.UmamiScript,
"umamiWebsiteId": config.UmamiWebsiteID,
"iconLibrary": iconLibrary,
"fontLibrary": config.FontLibrary,
"title": settings.PageTitle,
"siteName": settings.SiteName,
"siteURL": settings.SiteURL,
"keywords": settings.SiteKeywords,
"description": settings.SiteDescription,
"favicon": settings.Favicon,
"iconLibrary": settings.IconLibrary,
"fontLibrary": settings.FontLibrary,
"analyticsProvider": settings.AnalyticsProvider,
"umamiScript": settings.UmamiScript,
"umamiScriptUrl": settings.UmamiScript,
"umamiWebsiteId": settings.UmamiWebsiteID,
"umamiDomains": settings.UmamiDomains,
"umamiDoNotTrack": settings.UmamiDoNotTrack,
"umamiExcludeSearch": settings.UmamiExcludeSearch,
"umamiExcludeHash": settings.UmamiExcludeHash,
"umamiPerformance": settings.UmamiPerformance,
"umamiTag": settings.UmamiTag,
"umamiTrackerOptions": gin.H{"domains": settings.UmamiDomains, "doNotTrack": settings.UmamiDoNotTrack, "excludeSearch": settings.UmamiExcludeSearch, "excludeHash": settings.UmamiExcludeHash, "performance": settings.UmamiPerformance, "tag": settings.UmamiTag},
})
}
}
+11 -5
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"os"
"path/filepath"
"sync"
"time"
"home-vue-go/internal/config"
@@ -16,14 +17,19 @@ import (
var logBuffer []string
var maxLogLines = 1000
var logBufferMutex sync.RWMutex
// 初始化日志缓冲区
func InitLogBuffer() {
logBufferMutex.Lock()
defer logBufferMutex.Unlock()
logBuffer = make([]string, 0, maxLogLines)
}
// 添加日志到缓冲区
func AddLogToBuffer(logLine string) {
logBufferMutex.Lock()
defer logBufferMutex.Unlock()
logBuffer = append(logBuffer, logLine)
if len(logBuffer) > maxLogLines {
logBuffer = logBuffer[len(logBuffer)-maxLogLines:]
@@ -48,11 +54,11 @@ func GetBackendLogs(cfg *config.Config) gin.HandlerFunc {
}
// 从缓冲区获取日志
logs := make([]string, 0)
if len(logBuffer) > linesInt {
logs = logBuffer[len(logBuffer)-linesInt:]
} else {
logs = logBuffer
logBufferMutex.RLock()
logs := append([]string(nil), logBuffer...)
logBufferMutex.RUnlock()
if len(logs) > linesInt {
logs = logs[len(logs)-linesInt:]
}
// 尝试从日志文件读取(如果存在)
+10 -7
View File
@@ -19,9 +19,9 @@ func SetupRoutes(r *gin.Engine, db *database.Database, cfg *config.Config) {
// 公开API - 获取数据
api.GET("/sites", GetSites(db))
api.GET("/contacts", GetContacts(db))
api.GET("/config", GetSiteConfig(db, cfg))
api.GET("/config", GetSiteConfig(db))
api.GET("/frontend-config", GetFrontendConfig(db))
api.GET("/rotating-texts", GetRotatingTexts(cfg))
api.GET("/rotating-texts", GetRotatingTexts(db))
// 访问统计API(公开,用于记录访问)
api.POST("/track-visit", TrackVisit(db))
@@ -39,30 +39,33 @@ func SetupRoutes(r *gin.Engine, db *database.Database, cfg *config.Config) {
// 站点管理
admin.GET("/sites", GetSites(db))
admin.POST("/sites", CreateSite(db))
admin.PUT("/sites/reorder", ReorderSites(db))
admin.PUT("/sites/:id", UpdateSite(db))
admin.DELETE("/sites/:id", DeleteSite(db))
// 联系方式管理
admin.GET("/contacts", GetContacts(db))
admin.POST("/contacts", CreateContact(db))
admin.PUT("/contacts/reorder", ReorderContacts(db))
admin.PUT("/contacts/:id", UpdateContact(db))
admin.DELETE("/contacts/:id", DeleteContact(db))
// 站点配置管理
admin.GET("/config", GetSiteConfig(db, cfg))
admin.GET("/config", GetAdminSiteConfig(db))
admin.PUT("/config", UpdateSiteConfig(db, cfg))
// 轮换文本配置
admin.GET("/rotating-texts", GetRotatingTexts(cfg))
admin.PUT("/rotating-texts", UpdateRotatingTexts(cfg))
admin.GET("/rotating-texts", GetRotatingTexts(db))
admin.PUT("/rotating-texts", UpdateRotatingTexts(db))
// 文件上传
admin.POST("/upload", UploadFile(cfg))
// 统计API
admin.GET("/stats", GetStats(db))
admin.GET("/charts", GetChartData(db))
admin.GET("/stats", GetStats(db, cfg))
admin.GET("/charts", GetChartData(db, cfg))
admin.GET("/recent-visits", GetRecentVisits(db))
admin.POST("/analytics/test", TestUmamiConnection(db, cfg))
admin.POST("/notify-update", NotifyConfigUpdate())
// 用户管理
+109 -6
View File
@@ -3,8 +3,10 @@ package api
import (
"net/http"
"strconv"
"strings"
"home-vue-go/internal/database"
"home-vue-go/internal/ent"
"home-vue-go/internal/ent/site"
"github.com/gin-gonic/gin"
@@ -34,6 +36,95 @@ func GetSites(db *database.Database) gin.HandlerFunc {
}
}
func ReorderSites(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
IDs []int `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表不能为空"})
return
}
seen := make(map[int]bool, len(req.IDs))
for _, id := range req.IDs {
if id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含无效ID"})
return
}
if seen[id] {
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含重复ID"})
return
}
seen[id] = true
}
ctx := c.Request.Context()
existingIDs, err := db.Client.Site.Query().IDs(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if len(existingIDs) != len(req.IDs) {
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表与当前站点数量不一致"})
return
}
existing := make(map[int]bool, len(existingIDs))
for _, id := range existingIDs {
existing[id] = true
}
for _, id := range req.IDs {
if !existing[id] {
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含不存在的站点"})
return
}
}
tx, err := db.Client.Tx(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for index, id := range req.IDs {
if _, err := tx.Site.UpdateOneID(id).SetSortOrder((index + 1) * 10).Save(ctx); err != nil {
_ = tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
if err := tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
sites, err := db.Client.Site.Query().Order(site.BySortOrder(), site.ByID()).All(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
result := make([]gin.H, len(sites))
for i, site := range sites {
result[i] = gin.H{
"id": site.ID,
"name": site.Name,
"url": site.URL,
"icon": site.Icon,
"sortOrder": site.SortOrder,
}
}
c.JSON(http.StatusOK, result)
}
}
func CreateSite(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
@@ -47,6 +138,11 @@ func CreateSite(db *database.Database) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Name, req.URL, req.Icon = strings.TrimSpace(req.Name), strings.TrimSpace(req.URL), strings.TrimSpace(req.Icon)
if req.Name == "" || req.URL == "" || req.Icon == "" || !validHTTPURL(req.URL) {
c.JSON(http.StatusBadRequest, gin.H{"error": "站点名称、图标不能为空,URL必须是有效的http/https地址"})
return
}
ctx := c.Request.Context()
site, err := db.Client.Site.Create().
@@ -90,22 +186,25 @@ func UpdateSite(db *database.Database) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Name, req.URL, req.Icon = strings.TrimSpace(req.Name), strings.TrimSpace(req.URL), strings.TrimSpace(req.Icon)
if req.Name == "" || req.URL == "" || req.Icon == "" || !validHTTPURL(req.URL) {
c.JSON(http.StatusBadRequest, gin.H{"error": "站点名称、图标不能为空,URL必须是有效的http/https地址"})
return
}
ctx := c.Request.Context()
update := db.Client.Site.UpdateOneID(id)
if req.Name != "" {
update.SetName(req.Name)
}
if req.URL != "" {
update.SetURL(req.URL)
}
if req.Icon != "" {
update.SetIcon(req.Icon)
}
update.SetSortOrder(req.SortOrder)
site, err := update.Save(ctx)
if err != nil {
if ent.IsNotFound(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "站点不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -130,6 +229,10 @@ func DeleteSite(db *database.Database) gin.HandlerFunc {
ctx := c.Request.Context()
if err := db.Client.Site.DeleteOneID(id).Exec(ctx); err != nil {
if ent.IsNotFound(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "站点不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+338 -148
View File
@@ -3,191 +3,381 @@ package api
import (
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"home-vue-go/internal/analytics"
"home-vue-go/internal/config"
"home-vue-go/internal/database"
"home-vue-go/internal/ent/visit"
"github.com/gin-gonic/gin"
)
// GetStats 获取统计数据
func GetStats(db *database.Database) gin.HandlerFunc {
type analyticsCache struct {
mu sync.Mutex
statsExpires time.Time
chartsExpires time.Time
statsKey string
chartsKey string
stats gin.H
trend []gin.H
sources []gin.H
}
var dashboardAnalyticsCache analyticsCache
var umamiClient = analytics.NewClient()
func GetStats(db *database.Database, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
// 获取站点数量
sites, _ := db.Client.Site.Query().All(ctx)
totalSites := len(sites)
// 获取总访问量(从内存记录)
records := getVisitRecords()
totalViews := len(records)
// 获取独立访客数(去重IP
uniqueIPs := make(map[string]bool)
for _, r := range records {
uniqueIPs[r.IP] = true
settings, err := db.LoadSiteSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载统计配置失败"})
return
}
uniqueVisitors := len(uniqueIPs)
// 获取今日访问量
today := time.Now()
todayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location())
todayViews := 0
for _, r := range records {
if r.VisitTime.After(todayStart) {
todayViews++
siteCount, _ := db.Client.Site.Query().Count(c.Request.Context())
if settings.AnalyticsProvider == "umami" {
stats, status, err := loadUmamiStats(c, settings, siteCount, cfg)
if err != nil {
c.JSON(status, gin.H{"error": "Umami统计暂时不可用", "analyticsSource": "umami", "analyticsConfigured": false, "analyticsError": "unavailable"})
return
}
}
stats := gin.H{
"totalViews": totalViews,
"uniqueVisitors": uniqueVisitors,
"todayViews": todayViews,
"totalSites": totalSites,
}
c.JSON(http.StatusOK, stats)
return
}
c.JSON(http.StatusOK, localStats(c, db, siteCount))
}
}
// GetChartData 获取图表数据
func GetChartData(db *database.Database) gin.HandlerFunc {
func GetChartData(db *database.Database, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
period := c.DefaultQuery("period", "7")
// 根据period确定天数
periodDays := 7
if period == "30" {
periodDays = 30
} else if period == "90" {
periodDays = 90
settings, err := db.LoadSiteSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载统计配置失败"})
return
}
// 获取真实趋势数据(从内存记录)
records := getVisitRecords()
trend := []gin.H{}
now := time.Now()
for i := 0; i < periodDays; i++ {
date := now.AddDate(0, 0, -(periodDays-1-i))
dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
dayEnd := dayStart.Add(24 * time.Hour)
// 统计当天的访问量
count := 0
for _, r := range records {
if r.VisitTime.After(dayStart) && r.VisitTime.Before(dayEnd) {
count++
periodDays := chartPeriodDays(c.DefaultQuery("period", "7"))
if settings.AnalyticsProvider == "umami" {
trend, sources, status, err := loadUmamiCharts(c, settings, periodDays, cfg)
if err != nil {
c.JSON(status, gin.H{"error": "Umami统计暂时不可用", "analyticsSource": "umami", "analyticsConfigured": false, "analyticsError": "unavailable"})
return
}
c.JSON(http.StatusOK, gin.H{"trend": trend, "sources": sources, "analyticsSource": "umami", "analyticsConfigured": true, "analyticsError": ""})
return
}
trend = append(trend, gin.H{
"label": date.Format("1/2"),
"value": count,
})
}
// 获取访问来源数据(基于referer)
sourceMap := make(map[string]int)
total := len(records)
for _, r := range records {
ref := strings.ToLower(r.Referer)
if ref == "" {
sourceMap["直接访问"]++
} else if strings.Contains(ref, "google") || strings.Contains(ref, "baidu") || strings.Contains(ref, "bing") || strings.Contains(ref, "yahoo") || strings.Contains(ref, "sogou") {
sourceMap["搜索引擎"]++
} else if strings.Contains(ref, "twitter") || strings.Contains(ref, "facebook") || strings.Contains(ref, "weibo") || strings.Contains(ref, "wechat") || strings.Contains(ref, "qq") {
sourceMap["社交媒体"]++
} else {
sourceMap["其他"]++
}
}
sources := []gin.H{}
if total > 0 {
for label, count := range sourceMap {
sources = append(sources, gin.H{
"label": label,
"value": (count * 100) / total, // 转换为百分比
})
}
} else {
// 如果没有数据,返回默认值
sources = []gin.H{
{"label": "直接访问", "value": 100},
}
}
c.JSON(http.StatusOK, gin.H{
"trend": trend,
"sources": sources,
})
trend, sources := localCharts(c, db, periodDays)
c.JSON(http.StatusOK, gin.H{"trend": trend, "sources": sources, "analyticsSource": "local", "analyticsConfigured": true, "analyticsError": ""})
}
}
// GetRecentVisits 获取最近访问记录
func GetRecentVisits(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
// 支持通过 query 参数自定义条数,默认 5,最大 50
settings, err := db.LoadSiteSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载统计配置失败"})
return
}
if settings.AnalyticsProvider == "umami" {
c.JSON(http.StatusOK, []gin.H{})
return
}
limit := 5
if q := c.DefaultQuery("limit", "5"); q != "" {
if n, err := parseInt(q); err == nil && n > 0 {
limit = n
if value, err := strconv.Atoi(c.DefaultQuery("limit", "5")); err == nil && value > 0 {
limit = value
if limit > 50 {
limit = 50
}
}
records, err := queryVisits(c, db, 0)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载访问记录失败"})
return
}
// 从内存记录获取最近访问
records := getVisitRecords()
result := make([]gin.H, 0, limit)
result := make([]gin.H, 0, min(limit, len(records)))
now := time.Now()
// 取最近 limit 条
start := len(records) - limit
if start < 0 {
start = 0
for index := len(records) - 1; index >= 0 && len(result) < limit; index-- {
record := records[index]
result = append(result, gin.H{"path": record.Path, "ip": record.IP, "time": relativeVisitTime(now, record.VisitTime)})
}
for i := len(records) - 1; i >= start && i >= 0; i-- {
r := records[i]
// 计算相对时间
diff := now.Sub(r.VisitTime)
var timeStr string
if diff < time.Minute {
timeStr = "刚刚"
} else if diff < time.Hour {
timeStr = fmt.Sprintf("%.0f分钟前", diff.Minutes())
} else if diff < 24*time.Hour {
timeStr = fmt.Sprintf("%.0f小时前", diff.Hours())
} else {
timeStr = fmt.Sprintf("%.0f天前", diff.Hours()/24)
}
result = append(result, gin.H{
"path": r.Path,
"ip": r.IP,
"time": timeStr,
})
}
c.JSON(http.StatusOK, result)
}
}
// NotifyConfigUpdate 通知配置更新(用于热重载)
func NotifyConfigUpdate() gin.HandlerFunc {
return func(c *gin.Context) {
// 这里可以添加通知逻辑,比如通过WebSocket或SSE通知前端
// 目前简单返回成功
c.JSON(http.StatusOK, gin.H{
"message": "配置更新通知已发送",
})
resetAnalyticsCache()
c.JSON(http.StatusOK, gin.H{"message": "配置更新通知已发送"})
}
}
func loadUmamiStats(c *gin.Context, settings *config.SiteSettings, siteCount int, appConfig *config.Config) (gin.H, int, error) {
credential, err := decryptUmamiCredential(settings, appConfig)
if err != nil {
return nil, http.StatusBadGateway, err
}
cacheKey := "stats:" + settings.UmamiWebsiteID + ":" + settings.UmamiAPIURL
dashboardAnalyticsCache.mu.Lock()
if dashboardAnalyticsCache.statsKey == cacheKey && time.Now().Before(dashboardAnalyticsCache.statsExpires) && dashboardAnalyticsCache.stats != nil {
value := dashboardAnalyticsCache.stats
dashboardAnalyticsCache.mu.Unlock()
return value, http.StatusOK, nil
}
dashboardAnalyticsCache.mu.Unlock()
clientConfig := analytics.Config{Mode: settings.UmamiAPIMode, APIURL: settings.UmamiAPIURL, Credential: credential, WebsiteID: settings.UmamiWebsiteID}
now := time.Now()
allTime, err := umamiClient.GetStats(c.Request.Context(), clientConfig, time.Unix(0, 0), now)
if err != nil {
return nil, http.StatusBadGateway, err
}
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
todayStats, err := umamiClient.GetStats(c.Request.Context(), clientConfig, today, now)
if err != nil {
return nil, http.StatusBadGateway, err
}
active, err := umamiClient.GetActive(c.Request.Context(), clientConfig)
if err != nil {
return nil, http.StatusBadGateway, err
}
result := gin.H{"totalViews": allTime.Pageviews, "uniqueVisitors": allTime.Visitors, "todayViews": todayStats.Pageviews, "activeVisitors": active, "totalSites": siteCount, "analyticsSource": "umami", "analyticsConfigured": true}
dashboardAnalyticsCache.mu.Lock()
dashboardAnalyticsCache.statsKey, dashboardAnalyticsCache.statsExpires, dashboardAnalyticsCache.stats = cacheKey, time.Now().Add(30*time.Second), result
dashboardAnalyticsCache.mu.Unlock()
return result, http.StatusOK, nil
}
func loadUmamiCharts(c *gin.Context, settings *config.SiteSettings, periodDays int, appConfig *config.Config) ([]gin.H, []gin.H, int, error) {
credential, err := decryptUmamiCredential(settings, appConfig)
if err != nil {
return nil, nil, http.StatusBadGateway, err
}
cacheKey := fmt.Sprintf("charts:%s:%d:%s", settings.UmamiWebsiteID, periodDays, settings.UmamiAPIURL)
dashboardAnalyticsCache.mu.Lock()
if dashboardAnalyticsCache.chartsKey == cacheKey && time.Now().Before(dashboardAnalyticsCache.chartsExpires) && dashboardAnalyticsCache.trend != nil {
trend, sources := dashboardAnalyticsCache.trend, dashboardAnalyticsCache.sources
dashboardAnalyticsCache.mu.Unlock()
return trend, sources, http.StatusOK, nil
}
dashboardAnalyticsCache.mu.Unlock()
clientConfig := analytics.Config{Mode: settings.UmamiAPIMode, APIURL: settings.UmamiAPIURL, Credential: credential, WebsiteID: settings.UmamiWebsiteID}
now := time.Now()
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -periodDays+1)
pageviews, err := umamiClient.GetPageviews(c.Request.Context(), clientConfig, start, now)
if err != nil {
return nil, nil, http.StatusBadGateway, err
}
referrers, err := umamiClient.GetMetrics(c.Request.Context(), clientConfig, start, now, "referrer")
if err != nil {
return nil, nil, http.StatusBadGateway, err
}
trend := make([]gin.H, 0, len(pageviews))
for _, point := range pageviews {
trend = append(trend, gin.H{"label": point.X, "value": point.Y})
}
sources := percentageSources(referrers)
dashboardAnalyticsCache.mu.Lock()
dashboardAnalyticsCache.chartsKey, dashboardAnalyticsCache.chartsExpires, dashboardAnalyticsCache.trend, dashboardAnalyticsCache.sources = cacheKey, time.Now().Add(30*time.Second), trend, sources
dashboardAnalyticsCache.mu.Unlock()
return trend, sources, http.StatusOK, nil
}
func localStats(c *gin.Context, db *database.Database, siteCount int) gin.H {
records, err := queryVisits(c, db, 0)
if err != nil {
return gin.H{"totalViews": 0, "uniqueVisitors": 0, "todayViews": 0, "activeVisitors": 0, "totalSites": siteCount, "analyticsSource": "local", "analyticsConfigured": true, "analyticsError": "query_failed"}
}
now := time.Now()
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
unique := map[string]struct{}{}
active := map[string]struct{}{}
todayViews := 0
for _, record := range records {
key := record.IP + "|" + record.UserAgent
unique[key] = struct{}{}
if !record.VisitTime.Before(start) {
todayViews++
}
if !record.VisitTime.Before(now.Add(-5 * time.Minute)) {
active[key] = struct{}{}
}
}
return gin.H{"totalViews": len(records), "uniqueVisitors": len(unique), "todayViews": todayViews, "activeVisitors": len(active), "totalSites": siteCount, "analyticsSource": "local", "analyticsConfigured": true, "analyticsError": ""}
}
func localCharts(c *gin.Context, db *database.Database, periodDays int) ([]gin.H, []gin.H) {
records, err := queryVisits(c, db, 0)
if err != nil {
return emptyTrend(periodDays), []gin.H{{"label": "直接访问", "value": 100}}
}
now := time.Now()
trend := make([]gin.H, 0, periodDays)
for index := 0; index < periodDays; index++ {
date := now.AddDate(0, 0, -(periodDays - 1 - index))
dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
dayEnd := dayStart.AddDate(0, 0, 1)
count := 0
for _, record := range records {
if !record.VisitTime.Before(dayStart) && record.VisitTime.Before(dayEnd) {
count++
}
}
trend = append(trend, gin.H{"label": date.Format("1/2"), "value": count})
}
counts := make(map[string]int)
for _, record := range records {
counts[classifyReferer(record.Referer)]++
}
sources := percentageCounts(counts, len(records))
return trend, sources
}
func queryVisits(c *gin.Context, db *database.Database, days int) ([]*structVisit, error) {
query := db.Client.Visit.Query().Order(visit.ByVisitTime())
if days > 0 {
query = query.Where(visit.VisitTimeGTE(time.Now().AddDate(0, 0, -days)))
}
rows, err := query.All(c.Request.Context())
if err != nil {
return nil, err
}
result := make([]*structVisit, len(rows))
for index, row := range rows {
result[index] = &structVisit{Path: row.Path, IP: row.IP, UserAgent: row.UserAgent, Referer: row.Referer, VisitTime: row.VisitTime}
}
return result, nil
}
type structVisit struct {
Path, IP, UserAgent, Referer string
VisitTime time.Time
}
func percentageSources(points []analytics.Point) []gin.H {
counts := make(map[string]int)
for _, point := range points {
counts[classifyReferer(point.X)] += point.Y
}
total := 0
for _, count := range counts {
total += count
}
return percentageCounts(counts, total)
}
func percentageCounts(counts map[string]int, total int) []gin.H {
if total <= 0 {
return []gin.H{{"label": "直接访问", "value": 100}}
}
type item struct {
label string
count, value, remainder int
}
items := make([]item, 0, len(counts))
used := 0
for label, count := range counts {
value := count * 100 / total
items = append(items, item{label: label, count: count, value: value, remainder: count * 100 % total})
used += value
}
sort.Slice(items, func(i, j int) bool {
if items[i].remainder != items[j].remainder {
return items[i].remainder > items[j].remainder
}
return items[i].label < items[j].label
})
for index := 0; index < 100-used && len(items) > 0; index++ {
items[index%len(items)].value++
}
sort.Slice(items, func(i, j int) bool {
if items[i].count != items[j].count {
return items[i].count > items[j].count
}
return items[i].label < items[j].label
})
result := make([]gin.H, 0, len(items))
for _, item := range items {
result = append(result, gin.H{"label": item.label, "value": item.value})
}
return result
}
func classifyReferer(referrer string) string {
referrer = strings.ToLower(strings.TrimSpace(referrer))
if referrer == "" {
return "直接访问"
}
for _, source := range []string{"google", "baidu", "bing", "yahoo", "sogou"} {
if strings.Contains(referrer, source) {
return "搜索引擎"
}
}
for _, source := range []string{"twitter", "facebook", "weibo", "wechat", "qq"} {
if strings.Contains(referrer, source) {
return "社交媒体"
}
}
return "其他"
}
func decryptUmamiCredential(settings *config.SiteSettings, appConfig *config.Config) (string, error) {
if settings.UmamiWebsiteID == "" || settings.UmamiCredential == "" {
return "", fmt.Errorf("Umami配置不完整")
}
if appConfig == nil {
return "", fmt.Errorf("统计加密配置不可用")
}
return appConfig.DecryptSecret(settings.UmamiCredential)
}
func resetAnalyticsCache() {
dashboardAnalyticsCache.mu.Lock()
dashboardAnalyticsCache.statsKey = ""
dashboardAnalyticsCache.chartsKey = ""
dashboardAnalyticsCache.statsExpires = time.Time{}
dashboardAnalyticsCache.chartsExpires = time.Time{}
dashboardAnalyticsCache.stats = nil
dashboardAnalyticsCache.trend = nil
dashboardAnalyticsCache.sources = nil
dashboardAnalyticsCache.mu.Unlock()
}
func chartPeriodDays(value string) int {
switch value {
case "30":
return 30
case "90":
return 90
default:
return 7
}
}
func emptyTrend(days int) []gin.H {
result := make([]gin.H, days)
for index := range result {
result[index] = gin.H{"label": "", "value": 0}
}
return result
}
func relativeVisitTime(now, visited time.Time) string {
diff := now.Sub(visited)
switch {
case diff < time.Minute:
return "刚刚"
case diff < time.Hour:
return fmt.Sprintf("%.0f分钟前", diff.Minutes())
case diff < 24*time.Hour:
return fmt.Sprintf("%.0f小时前", diff.Hours())
default:
return fmt.Sprintf("%.0f天前", diff.Hours()/24)
}
}
func min(left, right int) int {
if left < right {
return left
}
return right
}
+27 -53
View File
@@ -2,75 +2,49 @@ package api
import (
"net/http"
"sync"
"time"
"strings"
"home-vue-go/internal/database"
"github.com/gin-gonic/gin"
)
// VisitRecord 表示一次访问记录(暂存在内存中)
type VisitRecord struct {
Path string
IP string
UserAgent string
Referer string
VisitTime time.Time
}
// 简单的内存存储与读写锁
var (
visitMutex sync.RWMutex
visitRecords []VisitRecord
maxVisits = 1000 // 最多保留的访问记录条数
)
// TrackVisit 记录访问
// TrackVisit stores a local visit only when local analytics is selected.
// Umami mode is tracked by the browser and must not be duplicated here.
func TrackVisit(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
settings, err := db.LoadSiteSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载统计配置失败"})
return
}
if settings.AnalyticsProvider == "umami" {
c.JSON(http.StatusNoContent, nil)
return
}
var req struct {
Path string `json:"path"`
Referer string `json:"referer"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
ip := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
// 记录访问到内存
visitMutex.Lock()
visitRecords = append(visitRecords, VisitRecord{
Path: req.Path,
IP: ip,
UserAgent: userAgent,
Referer: req.Referer,
VisitTime: time.Now(),
})
// 限制记录数量
if len(visitRecords) > maxVisits {
visitRecords = visitRecords[len(visitRecords)-maxVisits:]
path := strings.TrimSpace(req.Path)
if path == "" || len(path) > 2048 {
c.JSON(http.StatusBadRequest, gin.H{"error": "访问路径无效"})
return
}
visitMutex.Unlock()
// TODO: 等Ent代码生成后,改用数据库存储
// ctx := c.Request.Context()
// if db.Client.Visit != nil {
// db.Client.Visit.Create()...
// }
c.JSON(http.StatusOK, gin.H{"message": "访问已记录"})
_, err = db.Client.Visit.Create().
SetPath(path).
SetIP(c.ClientIP()).
SetUserAgent(c.GetHeader("User-Agent")).
SetReferer(strings.TrimSpace(req.Referer)).
Save(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "访问记录保存失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "访问已记录", "analyticsSource": "local"})
}
}
// 获取访问记录(从内存)
func getVisitRecords() []VisitRecord {
visitMutex.RLock()
defer visitMutex.RUnlock()
return visitRecords
}
+32
View File
@@ -0,0 +1,32 @@
package api
import (
"net/url"
"path"
"strings"
)
func validHTTPURL(value string) bool {
parsed, err := url.ParseRequestURI(strings.TrimSpace(value))
return err == nil && parsed.Host != "" && (parsed.Scheme == "http" || parsed.Scheme == "https")
}
func validContactURL(value string) bool {
value = strings.TrimSpace(value)
parsed, err := url.ParseRequestURI(value)
if err != nil {
return false
}
if parsed.Scheme == "http" || parsed.Scheme == "https" {
return parsed.Host != ""
}
return parsed.Scheme == "mailto" || parsed.Scheme == "tel"
}
func validQRCode(value string) bool {
value = strings.TrimSpace(value)
if strings.HasPrefix(value, "/uploads/") {
return !strings.Contains(value, "\\") && path.Clean(value) == value
}
return validHTTPURL(value)
}
+42
View File
@@ -0,0 +1,42 @@
package api
import "testing"
func TestURLValidation(t *testing.T) {
for _, value := range []string{"https://example.com", "http://localhost:8080"} {
if !validHTTPURL(value) {
t.Errorf("expected valid site URL: %s", value)
}
}
for _, value := range []string{"javascript:alert(1)", "//example.com", "example.com"} {
if validHTTPURL(value) {
t.Errorf("expected invalid site URL: %s", value)
}
}
for _, value := range []string{"mailto:a@example.com", "tel:+8613800000000", "https://example.com"} {
if !validContactURL(value) {
t.Errorf("expected valid contact URL: %s", value)
}
}
for _, value := range []string{"/uploads/qr.png", "https://example.com/qr.png"} {
if !validQRCode(value) {
t.Errorf("expected valid QR path: %s", value)
}
}
for _, value := range []string{"/uploads/../secret.png", "data:image/png;base64,abc"} {
if validQRCode(value) {
t.Errorf("expected invalid QR path: %s", value)
}
}
}
func TestPercentageCountsSumsToOneHundred(t *testing.T) {
values := percentageCounts(map[string]int{"direct": 1, "search": 1, "other": 1}, 3)
total := 0
for _, value := range values {
total += value["value"].(int)
}
if total != 100 {
t.Fatalf("percentage total = %d, want 100", total)
}
}
+71
View File
@@ -1,8 +1,17 @@
package config
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
type Config struct {
@@ -10,6 +19,7 @@ type Config struct {
DatabasePath string
UploadDir string
JWTSecret string
EncryptionKey []byte
}
func New(dataDir string) *Config {
@@ -26,10 +36,71 @@ func New(dataDir string) *Config {
jwtSecret = "your-secret-key-change-in-production"
}
encryptionSource := os.Getenv("CONFIG_ENCRYPTION_KEY")
if encryptionSource == "" {
// Keep existing installations decryptable while allowing production deployments
// to use a dedicated key that is independent from JWT signing.
encryptionSource = jwtSecret
}
encryptionKey := sha256.Sum256([]byte(encryptionSource))
return &Config{
DataDir: dataDir,
DatabasePath: filepath.Join(dataDir, "home.db"),
UploadDir: uploadDir,
JWTSecret: jwtSecret,
EncryptionKey: encryptionKey[:],
}
}
// EncryptSecret encrypts a value for storage in the application database.
func (c *Config) EncryptSecret(value string) (string, error) {
if strings.TrimSpace(value) == "" {
return "", nil
}
block, err := aes.NewCipher(c.EncryptionKey)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nil, nonce, []byte(value), nil)
encoded := base64.RawStdEncoding.EncodeToString(append(nonce, ciphertext...))
return "v1:" + encoded, nil
}
// DecryptSecret decrypts a value previously returned by EncryptSecret.
func (c *Config) DecryptSecret(value string) (string, error) {
if strings.TrimSpace(value) == "" {
return "", nil
}
if !strings.HasPrefix(value, "v1:") {
return "", errors.New("unsupported encrypted secret format")
}
raw, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(value, "v1:"))
if err != nil {
return "", fmt.Errorf("decode encrypted secret: %w", err)
}
block, err := aes.NewCipher(c.EncryptionKey)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(raw) < gcm.NonceSize() {
return "", errors.New("encrypted secret is too short")
}
plaintext, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
if err != nil {
return "", errors.New("encrypted secret authentication failed")
}
return string(plaintext), nil
}
+26
View File
@@ -0,0 +1,26 @@
package config
import "testing"
func TestSecretRoundTripAndIsolation(t *testing.T) {
first := &Config{EncryptionKey: []byte("01234567890123456789012345678901")}
second := &Config{EncryptionKey: []byte("abcdefghijklmnopqrstuvwxyz123456")}
ciphertext, err := first.EncryptSecret("umami-secret")
if err != nil {
t.Fatal(err)
}
if ciphertext == "umami-secret" || ciphertext == "" {
t.Fatalf("secret was not encrypted: %q", ciphertext)
}
plaintext, err := first.DecryptSecret(ciphertext)
if err != nil || plaintext != "umami-secret" {
t.Fatalf("round trip failed: %q, %v", plaintext, err)
}
if _, err := second.DecryptSecret(ciphertext); err == nil {
t.Fatal("ciphertext decrypted with the wrong key")
}
cleared, err := first.EncryptSecret("")
if err != nil || cleared != "" {
t.Fatalf("empty secret should clear storage: %q, %v", cleared, err)
}
}
+180
View File
@@ -0,0 +1,180 @@
package config
import "strings"
// AboutLink is a configurable link displayed in the about dialog.
type AboutLink struct {
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
Icon string `json:"icon"`
}
// SiteSettings is the single JSON payload stored alongside the SiteConfig row.
// The encrypted Umami credential is intentionally kept in this internal type.
type SiteSettings struct {
SiteName string `json:"siteName"`
SiteURL string `json:"siteURL"`
SiteIcon string `json:"siteIcon"`
SiteDescription string `json:"siteDescription"`
SiteKeywords string `json:"siteKeywords"`
UserName string `json:"userName"`
ProfileImageURL string `json:"profileImageURL"`
ICPNumber string `json:"icpNumber"`
PoliceNumber string `json:"policeNumber"`
PageTitle string `json:"pageTitle"`
Favicon string `json:"favicon"`
IconLibrary string `json:"iconLibrary"`
FontLibrary string `json:"fontLibrary"`
FooterYearStart string `json:"footerYearStart"`
FooterYearEnd string `json:"footerYearEnd"`
ShowVisitTimer bool `json:"showVisitTimer"`
RotatingTexts []string `json:"rotatingTexts"`
GreetingText string `json:"greetingText"`
OnlineStatusText string `json:"onlineStatusText"`
FooterLabel string `json:"footerLabel"`
ShowAbout bool `json:"showAbout"`
ShowSites bool `json:"showSites"`
ShowContacts bool `json:"showContacts"`
ShowThemeToggle bool `json:"showThemeToggle"`
ShowFooter bool `json:"showFooter"`
AboutTitle string `json:"aboutTitle"`
AboutDescription string `json:"aboutDescription"`
AboutLinks []AboutLink `json:"aboutLinks"`
SitePageSize int `json:"sitePageSize"`
OpenLinksNewTab bool `json:"openLinksInNewTab"`
AnalyticsProvider string `json:"analyticsProvider"`
UmamiScript string `json:"umamiScript"`
UmamiWebsiteID string `json:"umamiWebsiteId"`
UmamiAPIMode string `json:"umamiApiMode"`
UmamiAPIURL string `json:"umamiApiUrl"`
UmamiCredential string `json:"umamiCredential"`
UmamiDomains string `json:"umamiDomains"`
UmamiDoNotTrack bool `json:"umamiDoNotTrack"`
UmamiExcludeSearch bool `json:"umamiExcludeSearch"`
UmamiExcludeHash bool `json:"umamiExcludeHash"`
UmamiPerformance bool `json:"umamiPerformance"`
UmamiTag string `json:"umamiTag"`
}
var defaultRotatingTexts = []string{
"你好鸭,欢迎来到我的主页!!",
"随时可以联系我,期待与你交流。",
"愿你历尽千帆,归来仍是少年。",
"梦想还是要有的,万一实现了呢?",
"I hope you have a happy day every day.",
}
func DefaultSiteSettings() *SiteSettings {
return &SiteSettings{
SiteName: "个人主页",
SiteURL: "https://example.com",
SiteIcon: "/favicon.ico",
SiteDescription: "一个基于Vue3的个人主页",
SiteKeywords: "个人主页,Vue3",
UserName: "用户",
PageTitle: "个人主页",
Favicon: "/favicon.ico",
FooterYearStart: "",
FooterYearEnd: "",
ShowVisitTimer: true,
RotatingTexts: append([]string(nil), defaultRotatingTexts...),
GreetingText: "Hi,",
OnlineStatusText: "在线中",
FooterLabel: "Made by",
ShowAbout: true,
ShowSites: true,
ShowContacts: true,
ShowThemeToggle: true,
ShowFooter: true,
AboutTitle: "关于本站",
AboutLinks: []AboutLink{
{Title: "静态原项目", Description: "Home-Vue", URL: "https://github.com/JLinMr/Home-Vue", Icon: "fab fa-github"},
{Title: "动态现项目", Description: "Home-Vue-go", URL: "https://github.com/QWQLwToo/Home-Vue-go", Icon: "fab fa-github"},
},
SitePageSize: 6,
OpenLinksNewTab: true,
AnalyticsProvider: "local",
UmamiAPIMode: "selfhost",
UmamiDoNotTrack: true,
UmamiExcludeSearch: false,
UmamiExcludeHash: false,
UmamiPerformance: false,
}
}
func (s *SiteSettings) Normalize() {
defaults := DefaultSiteSettings()
if strings.TrimSpace(s.SiteName) == "" {
s.SiteName = defaults.SiteName
}
if strings.TrimSpace(s.SiteURL) == "" {
s.SiteURL = defaults.SiteURL
}
if strings.TrimSpace(s.SiteIcon) == "" {
s.SiteIcon = defaults.SiteIcon
}
if strings.TrimSpace(s.SiteDescription) == "" {
s.SiteDescription = defaults.SiteDescription
}
if strings.TrimSpace(s.SiteKeywords) == "" {
s.SiteKeywords = defaults.SiteKeywords
}
if strings.TrimSpace(s.UserName) == "" {
s.UserName = defaults.UserName
}
if strings.TrimSpace(s.PageTitle) == "" {
s.PageTitle = defaults.PageTitle
}
if strings.TrimSpace(s.Favicon) == "" {
s.Favicon = defaults.Favicon
}
if strings.TrimSpace(s.GreetingText) == "" {
s.GreetingText = defaults.GreetingText
}
if strings.TrimSpace(s.OnlineStatusText) == "" {
s.OnlineStatusText = defaults.OnlineStatusText
}
if strings.TrimSpace(s.FooterLabel) == "" {
s.FooterLabel = defaults.FooterLabel
}
if strings.TrimSpace(s.AboutTitle) == "" {
s.AboutTitle = defaults.AboutTitle
}
if s.SitePageSize != 6 && s.SitePageSize != 9 && s.SitePageSize != 12 {
s.SitePageSize = defaults.SitePageSize
}
if s.AnalyticsProvider != "umami" {
s.AnalyticsProvider = "local"
}
if s.UmamiAPIMode != "cloud" {
s.UmamiAPIMode = "selfhost"
}
filteredTexts := make([]string, 0, 8)
for _, text := range s.RotatingTexts {
if value := strings.TrimSpace(text); value != "" {
filteredTexts = append(filteredTexts, value)
}
if len(filteredTexts) == 8 {
break
}
}
if len(filteredTexts) == 0 {
filteredTexts = append([]string(nil), defaults.RotatingTexts...)
}
s.RotatingTexts = filteredTexts
if len(s.AboutLinks) > 8 {
s.AboutLinks = s.AboutLinks[:8]
}
}
func (s *SiteSettings) Clone() *SiteSettings {
copyValue := *s
copyValue.RotatingTexts = append([]string(nil), s.RotatingTexts...)
copyValue.AboutLinks = append([]AboutLink(nil), s.AboutLinks...)
return &copyValue
}
+199 -25
View File
@@ -3,10 +3,16 @@ package database
import (
"context"
"database/sql"
"log"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
"home-vue-go/internal/config"
"home-vue-go/internal/ent"
"home-vue-go/internal/ent/migrate"
@@ -14,11 +20,14 @@ import (
"golang.org/x/crypto/bcrypt"
)
const siteConfigPayloadColumn = "config_json"
type Database struct {
Client *ent.Client
SQL *sql.DB
}
func Init(dbPath string) (*Database, error) {
func Init(dbPath string, cfg *config.Config) (*Database, error) {
db, err := sql.Open("sqlite3", dbPath+"?_fk=1")
if err != nil {
return nil, err
@@ -26,35 +35,70 @@ func Init(dbPath string) (*Database, error) {
drv := entsql.OpenDB(dialect.SQLite, db)
client := ent.NewClient(ent.Driver(drv))
// 运行数据库迁移
ctx := context.Background()
if err := client.Schema.Create(ctx, migrate.WithForeignKeys(false)); err != nil {
log.Fatalf("数据库迁移失败: %v", err)
_ = db.Close()
return nil, fmt.Errorf("数据库迁移失败: %w", err)
}
if err := ensureSiteConfigPayload(db); err != nil {
_ = client.Close()
_ = db.Close()
return nil, err
}
// 初始化默认数据
database := &Database{Client: client, SQL: db}
if err := initDefaultData(ctx, client); err != nil {
log.Printf("初始化默认数据失败: %v", err)
_ = database.Close()
return nil, fmt.Errorf("初始化默认数据失败: %w", err)
}
return &Database{Client: client}, nil
if err := initializeSiteSettings(ctx, database, cfg); err != nil {
_ = database.Close()
return nil, fmt.Errorf("初始化站点配置载荷失败: %w", err)
}
return database, nil
}
func (d *Database) Close() error {
if d.Client != nil {
return d.Client.Close()
}
if d.SQL != nil {
return d.SQL.Close()
}
return nil
}
func ensureSiteConfigPayload(db *sql.DB) error {
rows, err := db.Query("PRAGMA table_info(site_configs)")
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var cid int
var name, columnType string
var notNull, primaryKey int
var defaultValue any
if err := rows.Scan(&cid, &name, &columnType, &notNull, &defaultValue, &primaryKey); err != nil {
return err
}
if name == siteConfigPayloadColumn {
return rows.Err()
}
}
if err := rows.Err(); err != nil {
return err
}
_, err = db.Exec(`ALTER TABLE site_configs ADD COLUMN config_json TEXT NOT NULL DEFAULT '{}'`)
return err
}
func initDefaultData(ctx context.Context, client *ent.Client) error {
// 检查是否已有站点配置
_, err := client.SiteConfig.Get(ctx, 1)
if err == nil {
// 已存在配置,不初始化
return nil
if _, err := client.SiteConfig.Get(ctx, 1); err != nil {
if !ent.IsNotFound(err) {
return err
}
// 创建默认站点配置
_, err = client.SiteConfig.Create().
if _, err := client.SiteConfig.Create().
SetSiteName("个人主页").
SetSiteURL("https://example.com").
SetSiteIcon("/favicon.ico").
@@ -68,32 +112,162 @@ func initDefaultData(ctx context.Context, client *ent.Client) error {
SetFavicon("/favicon.ico").
SetUmamiScript("").
SetUmamiWebsiteID("").
SetIconLibrary("//lib.baomitu.com/font-awesome/6.5.0/css/all.min.css").
SetIconLibrary("").
SetFontLibrary("").
Save(ctx)
if err != nil {
Save(ctx); err != nil {
return err
}
}
// 检查是否已有用户,如果没有才创建默认管理员用户
userCount, err := client.User.Query().Count(ctx)
if err != nil {
return err
}
// 只有在没有任何用户时才创建默认管理员
if userCount == 0 {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = client.User.Create().
SetUsername("admin").
SetPassword(string(hashedPassword)).
Save(ctx)
return err
}
return nil
}
func initializeSiteSettings(ctx context.Context, db *Database, cfg *config.Config) error {
payload, err := db.loadSiteConfigPayload(ctx)
if err != nil {
return err
}
if strings.TrimSpace(payload) != "" && strings.TrimSpace(payload) != "{}" {
var settings config.SiteSettings
if err := json.Unmarshal([]byte(payload), &settings); err != nil {
return fmt.Errorf("解析站点配置载荷失败: %w", err)
}
return nil
}
siteCfg, err := db.Client.SiteConfig.Get(ctx, 1)
if err != nil {
return err
}
settings := config.DefaultSiteSettings()
settings.SiteName = siteCfg.SiteName
settings.SiteURL = siteCfg.SiteURL
settings.SiteIcon = siteCfg.SiteIcon
settings.SiteDescription = siteCfg.SiteDescription
settings.SiteKeywords = siteCfg.SiteKeywords
settings.UserName = siteCfg.UserName
settings.ProfileImageURL = siteCfg.ProfileImageURL
settings.ICPNumber = siteCfg.IcpNumber
settings.PoliceNumber = siteCfg.PoliceNumber
settings.PageTitle = siteCfg.PageTitle
settings.Favicon = siteCfg.Favicon
settings.IconLibrary = siteCfg.IconLibrary
settings.FontLibrary = siteCfg.FontLibrary
settings.UmamiScript = siteCfg.UmamiScript
settings.UmamiWebsiteID = siteCfg.UmamiWebsiteID
if cfg != nil {
if err := importLegacyJSON(cfg.DataDir, settings); err != nil {
return err
}
}
settings.Normalize()
return db.SaveSiteSettings(ctx, settings)
}
func importLegacyJSON(dataDir string, settings *config.SiteSettings) error {
if dataDir == "" {
return nil
}
var footer struct {
Start string `json:"start"`
End string `json:"end"`
}
if err := readOptionalJSON(filepath.Join(dataDir, "footer_year.json"), &footer); err != nil {
return err
}
if footer.Start != "" || footer.End != "" {
settings.FooterYearStart, settings.FooterYearEnd = footer.Start, footer.End
}
var timer config.VisitTimerConfig
if err := readOptionalJSON(filepath.Join(dataDir, "visit_timer.json"), &timer); err != nil {
return err
}
if _, err := os.Stat(filepath.Join(dataDir, "visit_timer.json")); err == nil {
settings.ShowVisitTimer = timer.ShowVisitTimer
}
var texts config.RotatingTextsConfig
if err := readOptionalJSON(filepath.Join(dataDir, "rotating_texts.json"), &texts); err != nil {
return err
}
if len(texts.Texts) > 0 {
settings.RotatingTexts = texts.Texts
}
return nil
}
func readOptionalJSON(path string, target any) error {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if err := json.Unmarshal(data, target); err != nil {
return fmt.Errorf("解析 %s 失败: %w", filepath.Base(path), err)
}
return nil
}
func (d *Database) loadSiteConfigPayload(ctx context.Context) (string, error) {
var payload string
err := d.SQL.QueryRowContext(ctx, "SELECT config_json FROM site_configs WHERE id = 1").Scan(&payload)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return payload, err
}
func (d *Database) LoadSiteSettings(ctx context.Context) (*config.SiteSettings, error) {
payload, err := d.loadSiteConfigPayload(ctx)
if err != nil {
return nil, err
}
settings := config.DefaultSiteSettings()
if strings.TrimSpace(payload) != "" && strings.TrimSpace(payload) != "{}" {
if err := json.Unmarshal([]byte(payload), settings); err != nil {
return nil, err
}
}
settings.Normalize()
return settings, nil
}
func (d *Database) SaveSiteSettings(ctx context.Context, settings *config.SiteSettings) error {
if settings == nil {
return errors.New("site settings cannot be nil")
}
settings.Normalize()
payload, err := json.Marshal(settings)
if err != nil {
return err
}
result, err := d.SQL.ExecContext(ctx, "UPDATE site_configs SET config_json = ? WHERE id = 1", string(payload))
if err != nil {
return err
}
if affected, err := result.RowsAffected(); err != nil {
return err
} else if affected != 1 {
return errors.New("site config row does not exist")
}
return nil
}
+60
View File
@@ -0,0 +1,60 @@
package database
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"home-vue-go/internal/config"
)
func TestSiteSettingsMigrationAndBooleanPersistence(t *testing.T) {
dataDir := t.TempDir()
writeJSON := func(name, value string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dataDir, name), []byte(value), 0600); err != nil {
t.Fatal(err)
}
}
writeJSON("footer_year.json", `{"start":"2020","end":"2024"}`)
writeJSON("visit_timer.json", `{"showVisitTimer":false}`)
writeJSON("rotating_texts.json", `{"texts":["legacy text"]}`)
cfg := config.New(dataDir)
db, err := Init(cfg.DatabasePath, cfg)
if err != nil {
if strings.Contains(err.Error(), "CGO_ENABLED=0") {
t.Skip("go-sqlite3 requires cgo for the database integration test")
}
t.Fatal(err)
}
defer db.Close()
settings, err := db.LoadSiteSettings(context.Background())
if err != nil {
t.Fatal(err)
}
if settings.FooterYearStart != "2020" || settings.FooterYearEnd != "2024" || settings.ShowVisitTimer || len(settings.RotatingTexts) != 1 {
t.Fatalf("legacy settings were not migrated: %+v", settings)
}
settings.ShowAbout = false
settings.ShowSites = false
settings.ShowContacts = false
settings.ShowThemeToggle = false
settings.ShowFooter = false
settings.AnalyticsProvider = "umami"
settings.UmamiCredential = "v1:stored-secret"
if err := db.SaveSiteSettings(context.Background(), settings); err != nil {
t.Fatal(err)
}
loaded, err := db.LoadSiteSettings(context.Background())
if err != nil {
t.Fatal(err)
}
if loaded.ShowAbout || loaded.ShowSites || loaded.ShowContacts || loaded.ShowThemeToggle || loaded.ShowFooter || loaded.AnalyticsProvider != "umami" || loaded.UmamiCredential != "v1:stored-secret" {
t.Fatalf("false values or private payload were not persisted: %+v", loaded)
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
package ent
//go:generate go run -mod=mod entgo.io/ent/cmd/ent@latest generate ./schema
//go:generate go run -mod=mod entgo.io/ent/cmd/ent@v0.14.5 generate ./schema
+56 -104
View File
@@ -6,6 +6,7 @@ import (
"io/fs"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -46,7 +47,7 @@ func main() {
cfg := config.New(dataDir)
// 初始化数据库
db, err := database.Init(cfg.DatabasePath)
db, err := database.Init(cfg.DatabasePath, cfg)
if err != nil {
log.Fatal("数据库初始化失败:", err)
}
@@ -104,6 +105,11 @@ func main() {
// 初始化API路由
api.SetupRoutes(r, db, cfg)
apiPort := os.Getenv("API_PORT")
if apiPort == "" {
apiPort = "1551"
}
// 创建前端服务器(1552端口)- 从嵌入的文件系统提供前端文件
frontendRouter := gin.New()
frontendRouter.Use(gin.Recovery())
@@ -131,47 +137,8 @@ func main() {
c.Data(http.StatusOK, "image/x-icon", content)
})
// API代理:将/api请求代理到1551端口
frontendRouter.Any("/api/*path", func(c *gin.Context) {
client := &http.Client{
Timeout: 30 * time.Second,
}
targetURL := "http://localhost:1551" + c.Request.URL.Path
if c.Request.URL.RawQuery != "" {
targetURL += "?" + c.Request.URL.RawQuery
}
req, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, targetURL, c.Request.Body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建代理请求失败"})
return
}
for key, values := range c.Request.Header {
for _, value := range values {
req.Header.Add(key, value)
}
}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "代理请求失败: " + err.Error()})
return
}
defer resp.Body.Close()
for key, values := range resp.Header {
for _, value := range values {
c.Writer.Header().Add(key, value)
}
}
// 复制响应状态码和内容
c.Status(resp.StatusCode)
c.Header("Content-Type", resp.Header.Get("Content-Type"))
io.Copy(c.Writer, resp.Body)
})
// API代理:将/api请求转发到配置的API端口
frontendRouter.Any("/api/*path", proxyAPIRequest(apiPort))
// /uploads代理
frontendRouter.Static("/uploads", cfg.UploadDir)
@@ -242,34 +209,7 @@ func main() {
frontendRouter.Static("/static", filepath.Join(distPath, "static"))
frontendRouter.StaticFile("/favicon.ico", filepath.Join(distPath, "favicon.ico"))
frontendRouter.Any("/api/*path", func(c *gin.Context) {
client := &http.Client{Timeout: 30 * time.Second}
targetURL := "http://localhost:1551" + c.Request.URL.Path
if c.Request.URL.RawQuery != "" {
targetURL += "?" + c.Request.URL.RawQuery
}
req, _ := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, targetURL, c.Request.Body)
for key, values := range c.Request.Header {
for _, value := range values {
req.Header.Add(key, value)
}
}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "代理请求失败"})
return
}
defer resp.Body.Close()
for key, values := range resp.Header {
for _, value := range values {
c.Writer.Header().Add(key, value)
}
}
// 复制响应状态码和内容
c.Status(resp.StatusCode)
c.Header("Content-Type", resp.Header.Get("Content-Type"))
io.Copy(c.Writer, resp.Body)
})
frontendRouter.Any("/api/*path", proxyAPIRequest(apiPort))
frontendRouter.Static("/uploads", cfg.UploadDir)
frontendRouter.NoRoute(func(c *gin.Context) {
@@ -281,34 +221,7 @@ func main() {
} else if _, err := os.Stat("./dist"); err == nil {
frontendRouter.Static("/static", "./dist/static")
frontendRouter.StaticFile("/favicon.ico", "./dist/favicon.ico")
frontendRouter.Any("/api/*path", func(c *gin.Context) {
client := &http.Client{Timeout: 30 * time.Second}
targetURL := "http://localhost:1551" + c.Request.URL.Path
if c.Request.URL.RawQuery != "" {
targetURL += "?" + c.Request.URL.RawQuery
}
req, _ := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, targetURL, c.Request.Body)
for key, values := range c.Request.Header {
for _, value := range values {
req.Header.Add(key, value)
}
}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "代理请求失败"})
return
}
defer resp.Body.Close()
for key, values := range resp.Header {
for _, value := range values {
c.Writer.Header().Add(key, value)
}
}
// 复制响应状态码和内容
c.Status(resp.StatusCode)
c.Header("Content-Type", resp.Header.Get("Content-Type"))
io.Copy(c.Writer, resp.Body)
})
frontendRouter.Any("/api/*path", proxyAPIRequest(apiPort))
frontendRouter.Static("/uploads", cfg.UploadDir)
frontendRouter.NoRoute(func(c *gin.Context) {
if !strings.HasPrefix(c.Request.URL.Path, "/api/") && !strings.HasPrefix(c.Request.URL.Path, "/uploads/") {
@@ -322,11 +235,6 @@ func main() {
}
// 启动两个服务器
apiPort := os.Getenv("API_PORT")
if apiPort == "" {
apiPort = "1551"
}
frontendPort := os.Getenv("FRONTEND_PORT")
if frontendPort == "" {
frontendPort = "1552"
@@ -372,9 +280,23 @@ func main() {
}
func corsMiddleware() gin.HandlerFunc {
allowedOrigins := make(map[string]struct{})
for _, origin := range strings.Split(os.Getenv("CORS_ALLOWED_ORIGINS"), ",") {
if value := strings.TrimSpace(origin); value != "" {
allowedOrigins[value] = struct{}{}
}
}
if len(allowedOrigins) == 0 {
allowedOrigins["http://localhost:1552"] = struct{}{}
allowedOrigins["http://127.0.0.1:1552"] = struct{}{}
}
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
origin := c.GetHeader("Origin")
if _, ok := allowedOrigins[origin]; ok {
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Add("Vary", "Origin")
}
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
@@ -386,3 +308,33 @@ func corsMiddleware() gin.HandlerFunc {
c.Next()
}
}
func proxyAPIRequest(apiPort string) gin.HandlerFunc {
client := &http.Client{Timeout: 30 * time.Second}
return func(c *gin.Context) {
target := &url.URL{Scheme: "http", Host: "localhost:" + apiPort, Path: c.Request.URL.Path, RawQuery: c.Request.URL.RawQuery}
req, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, target.String(), c.Request.Body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建代理请求失败"})
return
}
for key, values := range c.Request.Header {
for _, value := range values {
req.Header.Add(key, value)
}
}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "代理请求失败"})
return
}
defer resp.Body.Close()
for key, values := range resp.Header {
for _, value := range values {
c.Writer.Header().Add(key, value)
}
}
c.Status(resp.StatusCode)
_, _ = io.Copy(c.Writer, resp.Body)
}
}
+38
View File
@@ -0,0 +1,38 @@
package main
import (
"net"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/gin-gonic/gin"
)
func TestProxyAPIRequestUsesConfiguredPort(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"path":"` + r.URL.RequestURI() + `"}`))
}))
defer backend.Close()
backendURL, err := url.Parse(backend.URL)
if err != nil {
t.Fatal(err)
}
_, port, err := net.SplitHostPort(backendURL.Host)
if err != nil {
t.Fatal(err)
}
gin.SetMode(gin.TestMode)
router := gin.New()
router.Any("/api/*path", proxyAPIRequest(port))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/config?custom=1", nil))
if recorder.Code != http.StatusOK || recorder.Body.String() != `{"path":"/api/config?custom=1"}` {
t.Fatalf("unexpected proxy response: status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
+14 -24
View File
@@ -4,20 +4,20 @@
<div v-if="viewRoute.name === 'home'" :key="viewRoute.name" class="public-shell">
<div class="background" aria-hidden="true"></div>
<component :is="Component" />
<footer class="site-footer">
<span>© {{ footerYearText }} Made by <a href="/">{{ userName }}</a></span>
<a v-if="icpNumber" href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">
{{ icpNumber }}
<footer v-if="config.showFooter" class="site-footer">
<span>© {{ footerYearText }} {{ config.footerLabel }} <a href="/">{{ config.userName }}</a></span>
<a v-if="visibleFiling(config.icpNumber)" href="https://beian.miit.gov.cn/" :target="config.openLinksInNewTab ? '_blank' : undefined" :rel="config.openLinksInNewTab ? 'noopener noreferrer' : undefined">
{{ visibleFiling(config.icpNumber) }}
</a>
<a
v-if="policeNumber"
:href="`https://beian.mps.gov.cn/#/query/webSearch?police=${policeNumber}`"
target="_blank"
rel="noopener noreferrer"
v-if="visibleFiling(config.policeNumber)"
:href="`https://beian.mps.gov.cn/#/query/webSearch?police=${encodeURIComponent(config.policeNumber)}`"
:target="config.openLinksInNewTab ? '_blank' : undefined"
:rel="config.openLinksInNewTab ? 'noopener noreferrer' : undefined"
class="police-link"
>
<i class="fas fa-shield-alt" aria-hidden="true"></i>
{{ policeNumber }}
{{ config.policeNumber }}
</a>
</footer>
</div>
@@ -29,23 +29,18 @@
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { getSiteConfig } from './api'
import { loadPublicConfig, publicConfig as config } from './composables/usePublicConfig'
const route = useRoute()
const userName = ref(import.meta.env.VITE_APP_USER_NAME || '用户')
const icpNumber = ref('')
const policeNumber = ref('')
const footerYearStart = ref('')
const footerYearEnd = ref('')
let configChannel = null
const isPublicPage = computed(() => route.name === 'home')
const footerYearText = computed(() => {
const currentYear = String(new Date().getFullYear())
const start = footerYearStart.value.trim()
const end = footerYearEnd.value.trim()
const start = String(config.footerYearStart || '').trim()
const end = String(config.footerYearEnd || '').trim()
if (!start && !end) return currentYear
if (start && end && start !== end) return `${start}~${end}`
return start || end || currentYear
@@ -59,12 +54,7 @@ const visibleFiling = (value) => {
const loadConfig = async () => {
if (!isPublicPage.value) return
try {
const { data } = await getSiteConfig()
userName.value = data.userName || userName.value
icpNumber.value = visibleFiling(data.icpNumber)
policeNumber.value = visibleFiling(data.policeNumber)
footerYearStart.value = String(data.footerYearStart || '')
footerYearEnd.value = String(data.footerYearEnd || '')
await loadPublicConfig(true)
} catch (error) {
console.error('加载页脚配置失败:', error)
}
+3
View File
@@ -55,12 +55,14 @@ export const adminAPI = {
createSite: (data) => api.post('/admin/sites', data),
updateSite: (id, data) => api.put(`/admin/sites/${id}`, data),
deleteSite: (id) => api.delete(`/admin/sites/${id}`),
reorderSites: (ids) => api.put('/admin/sites/reorder', { ids }),
// 联系方式管理
getContacts: () => api.get('/admin/contacts'),
createContact: (data) => api.post('/admin/contacts', data),
updateContact: (id, data) => api.put(`/admin/contacts/${id}`, data),
deleteContact: (id) => api.delete(`/admin/contacts/${id}`),
reorderContacts: (ids) => api.put('/admin/contacts/reorder', { ids }),
// 站点配置管理
getSiteConfig: () => api.get('/admin/config'),
@@ -81,6 +83,7 @@ export const adminAPI = {
getStats: () => api.get('/admin/stats'),
getChartData: (period) => api.get(`/admin/charts?period=${period}`),
getRecentVisits: (limit = 5) => api.get(`/admin/recent-visits?limit=${limit}`),
testUmamiConnection: (data) => api.post('/admin/analytics/test', data),
// 热重载通知
notifyConfigUpdate: () => api.post('/admin/notify-update'),
+24 -13
View File
@@ -2,6 +2,10 @@
<div class="about-page" @click.stop>
<div class="about-modal">
<div class="about-modal-content">
<header class="about-heading">
<h2>{{ config.aboutTitle }}</h2>
<p v-if="config.aboutDescription">{{ config.aboutDescription }}</p>
</header>
<div class="tech-stack">
<h3>使用的技术栈</h3>
<ul class="tech-list">
@@ -11,21 +15,21 @@
</li>
</ul>
</div>
<div class="github-info">
<h3>开源地址</h3>
<div v-if="config.aboutLinks.length" class="github-info">
<h3>相关链接</h3>
<div class="github-links">
<a href="https://github.com/JLinMr/Home-Vue" target="_blank" rel="noopener noreferrer" class="github-link">
<i class="fab fa-github" aria-hidden="true"></i>
<a
v-for="link in config.aboutLinks"
:key="link.url"
:href="link.url"
:target="config.openLinksInNewTab ? '_blank' : undefined"
:rel="config.openLinksInNewTab ? 'noopener noreferrer' : undefined"
class="github-link"
>
<i :class="link.icon || 'fas fa-link'" aria-hidden="true"></i>
<div class="link-content">
<span class="link-title">静态原项目</span>
<span class="link-desc">Home-Vue</span>
</div>
</a>
<a href="https://github.com/QWQLwToo/Home-Vue-go" target="_blank" rel="noopener noreferrer" class="github-link">
<i class="fab fa-github" aria-hidden="true"></i>
<div class="link-content">
<span class="link-title">动态现项目</span>
<span class="link-desc">Home-Vue-go</span>
<span class="link-title">{{ link.title }}</span>
<span class="link-desc">{{ link.description }}</span>
</div>
</a>
</div>
@@ -39,6 +43,7 @@
</template>
<script setup>
import { onMounted } from 'vue';
import vueLogo from '@fortawesome/fontawesome-free/svgs/brands/vuejs.svg?raw';
import cssLogo from '@fortawesome/fontawesome-free/svgs/brands/css3-alt.svg?raw';
import htmlLogo from '@fortawesome/fontawesome-free/svgs/brands/html5.svg?raw';
@@ -49,6 +54,7 @@ import ginLogo from '@fortawesome/fontawesome-free/svgs/solid/server.svg?raw';
import sqliteLogo from '@fortawesome/fontawesome-free/svgs/solid/database.svg?raw';
import entLogo from '@fortawesome/fontawesome-free/svgs/solid/code-branch.svg?raw';
import jwtLogo from '@fortawesome/fontawesome-free/svgs/solid/key.svg?raw';
import { loadPublicConfig, publicConfig as config } from '../composables/usePublicConfig';
const emit = defineEmits(['close']);
@@ -65,6 +71,7 @@ const techStack = [
{ name: 'JWT', logo: jwtLogo, color: '#b33ac2' }
];
const closeModal = () => emit('close');
onMounted(() => loadPublicConfig());
</script>
<style scoped>
@@ -98,6 +105,10 @@ h3 {
text-align: left;
}
.about-heading { margin-bottom: 22px; text-align: left; }
.about-heading h2 { margin: 0 0 7px; font-size: 1.25rem; }
.about-heading p { margin: 0; color: var(--text-muted); font-size: 13px; line-height: 1.6; }
.tech-list {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
+129 -10
View File
@@ -66,13 +66,39 @@
:config="siteConfig"
:rotating-texts="rotatingTexts"
:active-section="activeConfigSection"
:action-loading="actionLoading"
@update:active-section="activeConfigSection = $event"
@add-text="addRotatingText"
@remove-text="removeRotatingText"
@save-texts="saveRotatingTexts"
@test-analytics="testAnalytics"
/>
<AdminCollection
v-else-if="activeTab === '站点管理'"
key="sites"
kind="sites"
:items="sites"
:busy="actionLoading"
:reset-version="collectionResetVersion"
@edit="openSiteForm"
@delete="requestDelete('site', $event)"
@reorder-save="saveSiteOrder"
@reorder-reset="notify('站点排序已重置')"
@notify="notify($event.message, $event.type)"
/>
<AdminCollection
v-else
key="contacts"
kind="contacts"
:items="contacts"
:busy="actionLoading"
:reset-version="collectionResetVersion"
@edit="openContactForm"
@delete="requestDelete('contact', $event)"
@reorder-save="saveContactOrder"
@reorder-reset="notify('联系方式排序已重置')"
@notify="notify($event.message, $event.type)"
/>
<AdminCollection v-else-if="activeTab === '站点管理'" key="sites" kind="sites" :items="sites" @edit="openSiteForm" @delete="requestDelete('site', $event)" />
<AdminCollection v-else key="contacts" kind="contacts" :items="contacts" @edit="openContactForm" @delete="requestDelete('contact', $event)" />
</Transition>
</main>
</div>
@@ -102,7 +128,7 @@
<label class="field-full"><span>图标类名 <b>*</b></span><div class="icon-input"><i :class="contactForm.icon || 'fas fa-icons'" :style="{ color: contactForm.hoverColor }"></i><input v-model.trim="contactForm.icon" type="text" required placeholder="fas fa-envelope" /><button type="button" @click="openIconPicker('contact')"><i class="fas fa-icons"></i>选择</button></div></label>
<label v-if="!contactUsesQr" class="field-full"><span>链接 <b>*</b></span><input v-model.trim="contactForm.url" type="text" required :placeholder="contactForm.type === 'Email' ? 'mailto:name@example.com' : 'https://example.com'" /><small v-if="contactForm.type === 'Email'">Email 必须使用 mailto: 格式</small></label>
<div v-else class="field-full selector-field"><span>二维码图片 <b>*</b></span><IconSelector v-model="contactForm.qrCode" :default-icon-path="''" /></div>
<label><span>悬停颜色</span><div class="color-input"><input v-model="contactForm.hoverColor" type="color" /><input v-model.trim="contactForm.hoverColor" type="text" pattern="#[0-9a-fA-F]{6}" /></div></label>
<label><span>悬停颜色</span><div class="color-input"><input v-model="colorPickerValue" type="color" /><input v-model.trim="contactForm.hoverColor" type="text" pattern="#[0-9a-fA-F]{6}" placeholder="#555555" /><button type="button" title="清空颜色" aria-label="清空颜色" @click="contactForm.hoverColor = ''"><i class="fas fa-eraser" aria-hidden="true"></i></button></div></label>
</form>
<template #footer><button type="button" class="secondary-button" @click="closeContactForm">取消</button><button form="contact-form" type="submit" class="primary-button" :disabled="actionLoading">{{ actionLoading ? '正在保存' : '保存联系方式' }}</button></template>
</AdminModal>
@@ -149,7 +175,7 @@ const tabs = [
{ name: '站点管理', icon: 'fas fa-link', description: '维护主页展示的快捷站点' },
{ name: '联系方式管理', icon: 'fas fa-address-book', description: '维护社交链接和二维码联系方式' },
]
const configSections = ['基础信息', '用户信息', '图标配置', '备案信息', '前端配置', '轮换文本', '统计配置']
const configSections = ['基础信息', '用户信息', '主页显示', '图标配置', '备案信息', '关于信息', '前端配置', '轮换文本', '统计配置']
const contactTypes = ['Email', 'Github', '支付宝', '微信', 'QQ', '微博', '其他']
const defaultTexts = ['你好鸭,欢迎来到我的主页!!', '随时可以联系我,期待与你交流。', '愿你历尽千帆,归来仍是少年。', '梦想还是要有的,万一实现了呢?', 'I hope you have a happy day every day.']
const route = useRoute()
@@ -164,6 +190,7 @@ const sites = ref([])
const contacts = ref([])
const rotatingTexts = ref([...defaultTexts])
const brandIconFailed = ref(false)
const collectionResetVersion = ref(0)
const siteModalOpen = ref(false)
const contactModalOpen = ref(false)
const iconPickerOpen = ref(false)
@@ -179,7 +206,13 @@ let configChannel = null
const siteConfig = reactive({
siteName: '', siteURL: '', siteIcon: '', siteDescription: '', siteKeywords: '', userName: '', profileImageURL: '',
icpNumber: '', policeNumber: '', pageTitle: '', favicon: '', umamiScript: '', umamiWebsiteId: '', iconLibrary: '', fontLibrary: '',
footerYearStart: '', footerYearEnd: '', showVisitTimer: true,
footerYearStart: '', footerYearEnd: '', showVisitTimer: true, rotatingTexts: [...defaultTexts],
greetingText: 'Hi,', onlineStatusText: '在线中', footerLabel: 'Made by',
showAbout: true, showSites: true, showContacts: true, showThemeToggle: true, showFooter: true,
aboutTitle: '关于本站', aboutDescription: '', aboutLinks: [], sitePageSize: 6, openLinksInNewTab: true,
analyticsProvider: 'local', umamiApiMode: 'selfhost', umamiApiUrl: '', umamiCredentialConfigured: false,
umamiCredentialDraft: '', clearUmamiCredential: false, umamiDomains: '', umamiDoNotTrack: true,
umamiExcludeSearch: false, umamiExcludeHash: false, umamiPerformance: false, umamiTag: '',
})
const siteForm = reactive({ name: '', url: '', icon: '', sortOrder: 0 })
const contactForm = reactive({ type: 'Email', icon: '', url: '', qrCode: '', hoverColor: '#555555', sortOrder: 0 })
@@ -193,6 +226,10 @@ const iconPickerValue = computed({
get: () => iconPickerTarget.value === 'site' ? siteForm.icon : contactForm.icon,
set: (value) => { if (iconPickerTarget.value === 'site') siteForm.icon = value; else contactForm.icon = value },
})
const colorPickerValue = computed({
get: () => /^#[0-9a-fA-F]{6}$/.test(contactForm.hoverColor) ? contactForm.hoverColor : '#555555',
set: (value) => { contactForm.hoverColor = value },
})
const passwordMismatch = computed(() => Boolean(passwordForm.newPassword && passwordForm.confirmPassword && passwordForm.newPassword !== passwordForm.confirmPassword))
const canChangePassword = computed(() => passwordForm.oldPassword && passwordForm.newPassword.length >= 8 && passwordForm.confirmPassword && !passwordMismatch.value)
const passwordStrength = computed(() => {
@@ -227,6 +264,10 @@ const syncRoute = () => {
}
watch([activeTab, activeConfigSection], syncRoute)
watch(() => siteConfig.profileImageURL, () => { brandIconFailed.value = false })
watch(() => contactForm.type, () => {
if (contactUsesQr.value) contactForm.url = ''
else contactForm.qrCode = ''
})
const loadData = async () => {
loading.value = true
@@ -238,7 +279,10 @@ const loadData = async () => {
contacts.value = contactsResult.data || []
Object.assign(siteConfig, configResult.data || {})
siteConfig.showVisitTimer = configResult.data?.showVisitTimer === undefined ? true : Boolean(configResult.data.showVisitTimer)
rotatingTexts.value = textsResult.data?.texts?.length ? textsResult.data.texts : [...defaultTexts]
siteConfig.umamiCredentialDraft = ''
siteConfig.clearUmamiCredential = false
const configuredTexts = configResult.data?.rotatingTexts || textsResult.data?.texts
rotatingTexts.value = configuredTexts?.length ? [...configuredTexts] : [...defaultTexts]
} catch (error) {
notify(`加载管理数据失败:${error.response?.data?.error || error.message}`, 'error')
} finally { loading.value = false }
@@ -257,13 +301,45 @@ const broadcastConfigUpdate = async () => {
const saveSiteConfig = async () => {
actionLoading.value = true
try {
await adminAPI.updateSiteConfig({ ...siteConfig, footerYearStart: String(siteConfig.footerYearStart || '').trim(), footerYearEnd: String(siteConfig.footerYearEnd || '').trim(), showVisitTimer: Boolean(siteConfig.showVisitTimer) })
const payload = {
...siteConfig,
footerYearStart: String(siteConfig.footerYearStart || '').trim(),
footerYearEnd: String(siteConfig.footerYearEnd || '').trim(),
showVisitTimer: Boolean(siteConfig.showVisitTimer),
rotatingTexts: [...rotatingTexts.value],
aboutLinks: siteConfig.aboutLinks.map((link) => ({ ...link })),
clearUmamiCredential: Boolean(siteConfig.clearUmamiCredential),
}
const credential = String(siteConfig.umamiCredentialDraft || '').trim()
if (credential) payload.umamiCredential = credential
delete payload.umamiCredentialConfigured
delete payload.umamiCredentialDraft
await adminAPI.updateSiteConfig(payload)
siteConfig.umamiCredentialDraft = ''
siteConfig.clearUmamiCredential = false
await broadcastConfigUpdate()
notify('站点配置已保存')
} catch (error) { notify(`保存失败:${error.response?.data?.error || error.message}`, 'error') }
finally { actionLoading.value = false }
}
const testAnalytics = async () => {
actionLoading.value = true
try {
const payload = {
mode: siteConfig.umamiApiMode,
apiUrl: siteConfig.umamiApiUrl,
websiteId: siteConfig.umamiWebsiteId,
}
const credential = String(siteConfig.umamiCredentialDraft || '').trim()
if (credential) payload.credential = credential
await adminAPI.testUmamiConnection(payload)
notify('Umami 连接成功')
} catch (error) {
notify(`Umami 连接失败:${error.response?.data?.error || error.message}`, 'error')
} finally { actionLoading.value = false }
}
const addRotatingText = () => { if (rotatingTexts.value.length < 8) rotatingTexts.value.push('') }
const removeRotatingText = (index) => { if (rotatingTexts.value.length > 1) rotatingTexts.value.splice(index, 1) }
const saveRotatingTexts = async () => {
@@ -275,10 +351,40 @@ const saveRotatingTexts = async () => {
finally { actionLoading.value = false }
}
const nextSortOrder = (items) => Math.max(0, ...items.map((item) => Number(item.sortOrder) || 0)) + 10
const resetCollectionDraft = async (reload) => {
try { await reload() } catch {}
collectionResetVersion.value += 1
}
const saveSiteOrder = async (ids) => {
if (!ids.length) return
actionLoading.value = true
try {
sites.value = (await adminAPI.reorderSites(ids)).data || []
await broadcastConfigUpdate()
notify('站点排序已保存')
} catch (error) {
await resetCollectionDraft(async () => { sites.value = (await adminAPI.getSites()).data || sites.value })
notify(`保存排序失败:${error.response?.data?.error || error.message}`, 'error')
} finally { actionLoading.value = false }
}
const saveContactOrder = async (ids) => {
if (!ids.length) return
actionLoading.value = true
try {
contacts.value = (await adminAPI.reorderContacts(ids)).data || []
await broadcastConfigUpdate()
notify('联系方式排序已保存')
} catch (error) {
await resetCollectionDraft(async () => { contacts.value = (await adminAPI.getContacts()).data || contacts.value })
notify(`保存排序失败:${error.response?.data?.error || error.message}`, 'error')
} finally { actionLoading.value = false }
}
const openIconPicker = (target) => { iconPickerTarget.value = target; iconPickerOpen.value = true }
const openSiteForm = (site = null) => {
editingSite.value = site
Object.assign(siteForm, site ? { name: site.name, url: site.url, icon: site.icon, sortOrder: site.sortOrder } : { name: '', url: '', icon: '', sortOrder: 0 })
Object.assign(siteForm, site ? { name: site.name, url: site.url, icon: site.icon, sortOrder: site.sortOrder } : { name: '', url: '', icon: '', sortOrder: nextSortOrder(sites.value) })
siteModalOpen.value = true
}
const closeSiteForm = () => { siteModalOpen.value = false; editingSite.value = null }
@@ -288,6 +394,7 @@ const saveSite = async () => {
if (editingSite.value) await adminAPI.updateSite(editingSite.value.id, { ...siteForm })
else await adminAPI.createSite({ ...siteForm })
sites.value = (await adminAPI.getSites()).data || []
await broadcastConfigUpdate()
notify(editingSite.value ? '站点已更新' : '站点已创建')
closeSiteForm()
} catch (error) { notify(`保存失败:${error.response?.data?.error || error.message}`, 'error') }
@@ -296,7 +403,7 @@ const saveSite = async () => {
const openContactForm = (contact = null) => {
editingContact.value = contact
Object.assign(contactForm, contact ? { type: contact.type, icon: contact.icon, url: contact.url || '', qrCode: contact.qrCode || '', hoverColor: contact.hoverColor || '#555555', sortOrder: contact.sortOrder } : { type: 'Email', icon: '', url: '', qrCode: '', hoverColor: '#555555', sortOrder: 0 })
Object.assign(contactForm, contact ? { type: contact.type, icon: contact.icon, url: contact.url || '', qrCode: contact.qrCode || '', hoverColor: contact.hoverColor || '#555555', sortOrder: contact.sortOrder } : { type: 'Email', icon: '', url: '', qrCode: '', hoverColor: '#555555', sortOrder: nextSortOrder(contacts.value) })
contactModalOpen.value = true
}
const closeContactForm = () => { contactModalOpen.value = false; editingContact.value = null }
@@ -310,6 +417,7 @@ const saveContact = async () => {
if (editingContact.value) await adminAPI.updateContact(editingContact.value.id, payload)
else await adminAPI.createContact(payload)
contacts.value = (await adminAPI.getContacts()).data || []
await broadcastConfigUpdate()
notify(editingContact.value ? '联系方式已更新' : '联系方式已创建')
closeContactForm()
} catch (error) { notify(`保存失败:${error.response?.data?.error || error.message}`, 'error') }
@@ -327,6 +435,7 @@ const runConfirmedAction = async () => {
try {
if (confirmAction.value.kind === 'site') { await adminAPI.deleteSite(confirmAction.value.item.id); sites.value = (await adminAPI.getSites()).data || [] }
else { await adminAPI.deleteContact(confirmAction.value.item.id); contacts.value = (await adminAPI.getContacts()).data || [] }
await broadcastConfigUpdate()
notify('记录已删除')
confirmAction.value = null
} catch (error) { notify(`删除失败:${error.response?.data?.error || error.message}`, 'error') }
@@ -433,6 +542,14 @@ onUnmounted(() => { window.clearTimeout(toastTimer); configChannel?.close() })
.modal-form input,
.modal-form select,
.password-form input { width: 100%; height: 40px; padding: 0 10px; border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-color); background: var(--surface-muted); }
.modal-form input,
.modal-form select,
.password-form input,
.icon-input button,
.color-input button { transition: border-color var(--motion-fast) var(--motion-ease-standard), background-color var(--motion-fast) var(--motion-ease-standard), box-shadow var(--motion-fast) var(--motion-ease-standard), color var(--motion-fast) var(--motion-ease-standard); }
.modal-form input:hover,
.modal-form select:hover,
.password-form input:hover { border-color: rgba(var(--hover-link-color-rgb), 0.52); }
.modal-form input:focus,
.modal-form select:focus,
.password-form input:focus { border-color: var(--hover-link-color); outline: 3px solid var(--focus-ring); }
@@ -442,8 +559,10 @@ onUnmounted(() => { window.clearTimeout(toastTimer); configChannel?.close() })
.icon-input > i { height: 40px; display: grid; place-items: center; border: 1px solid var(--border-color); border-right: 0; border-radius: 6px 0 0 6px; background: var(--surface-muted); }
.icon-input input { border-radius: 0; }
.icon-input button { height: 40px; display: inline-flex; align-items: center; gap: 6px; padding: 0 11px; border: 1px solid var(--border-color); border-left: 0; border-radius: 0 6px 6px 0; color: var(--text-color); background: var(--surface-solid); cursor: pointer; }
.color-input { display: grid; grid-template-columns: 48px minmax(0, 1fr); gap: 7px; }
.color-input { display: grid; grid-template-columns: 48px minmax(0, 1fr) 40px; gap: 7px; }
.color-input input[type='color'] { padding: 4px; }
.color-input button { width: 40px; height: 40px; display: grid; place-items: center; border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-muted); background: var(--surface-solid); cursor: pointer; }
.color-input button:hover { border-color: var(--danger-color); color: var(--danger-color); background: rgba(201, 54, 43, 0.07); }
.password-form { display: flex; flex-direction: column; gap: 15px; }
.password-input { position: relative; }
.password-input input { padding-right: 42px; }
+11 -3
View File
@@ -1,5 +1,6 @@
<template>
<div ref="dashboardElement" class="dashboard" :aria-busy="loading">
<p class="dashboard-source"><i class="fas fa-database" aria-hidden="true"></i>数据源{{ analyticsSource === 'umami' ? 'Umami' : 'SQLite 本地统计' }}<span v-if="analyticsConfigured === false">未配置或不可用</span></p>
<Transition name="dashboard-alert">
<p v-if="errorMessage" class="dashboard-alert" role="status">
<i class="fas fa-circle-exclamation" aria-hidden="true"></i>{{ errorMessage }}
@@ -91,11 +92,12 @@ const statCards = [
{ key: 'totalViews', label: '总访问量', icon: 'fas fa-eye' },
{ key: 'uniqueVisitors', label: '独立访客', icon: 'fas fa-users' },
{ key: 'todayViews', label: '今日访问', icon: 'fas fa-calendar-day' },
{ key: 'activeVisitors', label: '活跃访客', icon: 'fas fa-user-clock' },
{ key: 'totalSites', label: '站点数量', icon: 'fas fa-link' },
]
const chartColors = ['#ffcc00', '#4f8bc9', '#4c9a61', '#d07842', '#8b6cb1']
const stats = ref({ totalViews: 0, uniqueVisitors: 0, todayViews: 0, totalSites: 0 })
const displayedStats = ref({ totalViews: 0, uniqueVisitors: 0, todayViews: 0, totalSites: 0 })
const stats = ref({ totalViews: 0, uniqueVisitors: 0, todayViews: 0, activeVisitors: 0, totalSites: 0 })
const displayedStats = ref({ totalViews: 0, uniqueVisitors: 0, todayViews: 0, activeVisitors: 0, totalSites: 0 })
const chartPeriod = ref('7')
const trendData = ref([])
const sourceData = ref([])
@@ -107,6 +109,8 @@ const logsLoading = ref(false)
const loginLoading = ref(false)
const autoScroll = ref(true)
const errorMessage = ref('')
const analyticsSource = ref('local')
const analyticsConfigured = ref(true)
const dashboardElement = ref(null)
const trendCanvas = ref(null)
const sourceCanvas = ref(null)
@@ -266,6 +270,8 @@ const animateCharts = () => {
const loadStats = async () => {
const { data } = await adminAPI.getStats()
stats.value = { ...stats.value, ...data }
analyticsSource.value = data.analyticsSource || 'local'
analyticsConfigured.value = data.analyticsConfigured !== false
animateStats(stats.value)
}
@@ -338,8 +344,10 @@ onUnmounted(() => {
<style scoped>
.dashboard { display: flex; flex-direction: column; gap: 20px; }
.dashboard-source { margin: -8px 0 0; color: var(--text-muted); font-size: 12px; }
.dashboard-source i { margin-right: 6px; color: var(--hover-link-color); }
.dashboard-alert { display: flex; align-items: center; gap: 8px; margin: 0; padding: 10px 12px; border-left: 3px solid var(--warning-color); color: var(--warning-color); background: var(--surface-muted); font-size: 13px; }
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
.stats-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 14px; }
.stat-card { min-width: 0; display: flex; align-items: center; gap: 13px; padding: 17px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--surface-color); transition: transform var(--motion-base) var(--motion-ease), border-color var(--motion-fast) var(--motion-ease-standard), box-shadow var(--motion-base) var(--motion-ease); }
.stat-card:hover { transform: translateY(-3px); border-color: rgba(var(--hover-link-color-rgb), 0.7); box-shadow: 0 7px 18px var(--shadow-color); }
.stat-icon { width: 42px; height: 42px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 7px; color: #302800; background: var(--hover-link-color); }
+31 -40
View File
@@ -1,20 +1,22 @@
<template>
<main class="home-view" :aria-busy="loading">
<section class="identity" aria-labelledby="home-title">
<button
<component
:is="config.showAbout ? 'button' : 'div'"
class="profile-button"
type="button"
aria-label="查看关于信息"
@click="showAbout = true"
:class="{ 'profile-button--static': !config.showAbout }"
:type="config.showAbout ? 'button' : undefined"
:aria-label="config.showAbout ? '查看关于信息' : undefined"
@click="config.showAbout && (showAbout = true)"
v-motion-pop
>
<img v-if="profileImage && !profileImageFailed" :src="profileImage" :alt="`${userName} 的头像`" @error="profileImageFailed = true" />
<span v-else class="profile-fallback"><i class="fas fa-user" aria-hidden="true"></i></span>
<span class="status-ball"><span>在线中</span></span>
</button>
<span class="status-ball"><span>{{ config.onlineStatusText }}</span></span>
</component>
<div class="user-name" v-motion-slide-left>
<h1 id="home-title">Hi,</h1>
<h1 id="home-title">{{ config.greetingText }}</h1>
<h1>I'm <span class="name-style">{{ userName }}</span></h1>
</div>
</section>
@@ -34,12 +36,12 @@
aria-label="联系方式"
v-motion-pop
>
<template v-for="contact in contacts" :key="contact.id || contact.type">
<template v-if="config.showContacts" v-for="contact in contacts" :key="contact.id || contact.type">
<a
v-if="contact.url"
:href="contact.url"
target="_blank"
rel="noopener noreferrer"
:target="config.openLinksInNewTab ? '_blank' : undefined"
:rel="config.openLinksInNewTab ? 'noopener noreferrer' : undefined"
class="contact-item"
:style="{ '--hover-color': contact.hoverColor || 'var(--hover-link-color)' }"
:aria-label="contact.type"
@@ -59,14 +61,14 @@
<span class="tooltip">{{ contact.type }}</span>
</button>
</template>
<button type="button" class="contact-item" :aria-label="themeLabel" @click="toggleTheme">
<button v-if="config.showThemeToggle" type="button" class="contact-item" :aria-label="themeLabel" @click="toggleTheme">
<i :class="themeIcon" aria-hidden="true"></i>
<span class="tooltip">{{ isDarkMode ? '浅色' : '深色' }}</span>
</button>
</nav>
<Website />
<VisitTimer v-if="showVisitTimer" />
<Website v-if="config.showSites" />
<VisitTimer v-if="config.showVisitTimer" />
<Transition name="fade">
<div v-if="showAbout" class="overlay" role="dialog" aria-modal="true" aria-label="关于本站" @click="showAbout = false">
@@ -87,27 +89,20 @@
<script setup>
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import Typed from 'typed.js'
import api, { getContacts, getSiteConfig } from '../api'
import { getContacts } from '../api'
import fallbackContacts from '../config/links.json'
import { useTheme } from '../composables/useTheme'
import { applyAnalytics, recordHomePageView } from '../composables/useAnalytics'
import { loadPublicConfig, publicConfig as config } from '../composables/usePublicConfig'
import AboutPage from './AboutPage.vue'
import VisitTimer from './VisitTimer.vue'
import Website from './Website.vue'
const defaultDescriptions = [
'你好鸭,欢迎来到我的主页!!',
'随时可以联系我,期待与你交流。',
'愿你历尽千帆,归来仍是少年。',
'梦想还是要有的,万一实现了呢?',
'I hope you have a happy day every day.',
]
const contacts = ref(fallbackContacts)
const userName = ref(import.meta.env.VITE_APP_USER_NAME || '用户')
const profileImage = ref(import.meta.env.VITE_APP_PROFILE_IMAGE_URL || '')
const userName = ref(config.userName)
const profileImage = ref(config.profileImageURL)
const profileImageFailed = ref(false)
const showVisitTimer = ref(true)
const descriptions = ref(defaultDescriptions)
const descriptions = ref(config.rotatingTexts)
const descriptionElement = ref(null)
const loading = ref(true)
const loadError = ref(false)
@@ -137,10 +132,9 @@ const loadData = async () => {
loadError.value = false
profileImageFailed.value = false
const [contactsResult, configResult, textsResult] = await Promise.allSettled([
const [contactsResult, configResult] = await Promise.allSettled([
getContacts(),
getSiteConfig(),
api.get('/rotating-texts'),
loadPublicConfig(true),
])
if (contactsResult.status === 'fulfilled' && Array.isArray(contactsResult.value.data)) {
@@ -151,22 +145,17 @@ const loadData = async () => {
}
if (configResult.status === 'fulfilled') {
const config = configResult.value.data || {}
userName.value = config.userName || import.meta.env.VITE_APP_USER_NAME || '用户'
profileImage.value = config.profileImageURL || import.meta.env.VITE_APP_PROFILE_IMAGE_URL || ''
showVisitTimer.value = config.showVisitTimer === undefined ? true : Boolean(config.showVisitTimer)
userName.value = config.userName
profileImage.value = config.profileImageURL
descriptions.value = config.rotatingTexts
await applyAnalytics(config)
} else {
loadError.value = true
}
if (textsResult.status === 'fulfilled' && textsResult.value.data?.texts?.length) {
descriptions.value = textsResult.value.data.texts
} else {
descriptions.value = defaultDescriptions
}
loading.value = false
await initializeTyped()
if (configResult.status === 'fulfilled') await recordHomePageView(config)
}
const showQRCode = (src) => {
@@ -183,7 +172,6 @@ const closeDialogsOnEscape = (event) => {
onMounted(async () => {
await loadData()
api.post('/track-visit', { path: window.location.pathname, referer: document.referrer || '' }).catch(() => {})
document.addEventListener('keydown', closeDialogsOnEscape)
if (window.BroadcastChannel) {
@@ -234,6 +222,7 @@ onUnmounted(() => {
box-shadow: 0 2px 8px var(--shadow-color);
cursor: pointer;
}
.profile-button--static { cursor: default; }
.profile-button img,
.profile-fallback {
@@ -264,6 +253,8 @@ onUnmounted(() => {
.status-ball span { color: #00c800; opacity: 0; font-size: 12px; white-space: nowrap; transition: opacity 0.3s ease, color 0.1s ease; }
.profile-button:hover .status-ball { width: 4.5em; }
.profile-button:hover .status-ball span { color: #eee; opacity: 1; }
.profile-button--static:hover .status-ball { width: 2em; }
.profile-button--static:hover .status-ball span { color: #00c800; opacity: 0; }
.user-name { display: flex; flex-direction: column; align-items: flex-start; font-size: 1.3em; }
.user-name h1 { margin: 0; letter-spacing: 0; }
+35 -8
View File
@@ -96,6 +96,9 @@
<p class="current-label">当前图标</p>
<img :src="currentIcon" alt="当前图标" class="icon-preview" @error="handleImageError" />
<p class="icon-hint">{{ currentIcon }}</p>
<button type="button" class="clear-btn" @click="clearIcon">
<i class="fas fa-eraser" aria-hidden="true"></i>清空图标
</button>
</div>
</div>
</template>
@@ -189,6 +192,16 @@ const selectDefault = () => {
emit('update:modelValue', props.defaultIconPath)
}
const clearIcon = () => {
currentIcon.value = ''
iconUrl.value = ''
uploadedIconUrl.value = ''
validatedUrl.value = ''
urlError.value = ''
urlValidated.value = false
emit('update:modelValue', '')
}
// URL
const validateImageUrl = async (url) => {
if (!url) {
@@ -374,10 +387,10 @@ onMounted(() => {
}
.tab-btn.active {
border-bottom-color: var(--accent-color);
border-bottom-color: var(--hover-link-color);
color: var(--text-color);
font-weight: 700;
background: var(--accent-soft);
background: rgba(var(--hover-link-color-rgb), 0.14);
}
.tab-btn:hover:not(.active) { background: var(--surface-color); }
@@ -419,7 +432,7 @@ onMounted(() => {
padding: 40px;
background: var(--surface-color);
border-radius: 7px;
border: 1px dashed var(--border-strong);
border: 1px dashed rgba(var(--hover-link-color-rgb), 0.45);
margin-bottom: 15px;
}
@@ -434,23 +447,37 @@ onMounted(() => {
}
.upload-btn,
.select-btn {
.select-btn,
.clear-btn {
padding: 10px 20px;
background: var(--accent-color);
background: var(--hover-link-color);
color: #2f280d;
border: 1px solid var(--accent-strong);
border: 1px solid #d1a700;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
margin-top: 10px;
transition: transform var(--motion-fast) var(--motion-ease), box-shadow var(--motion-base) var(--motion-ease), filter var(--motion-fast) var(--motion-ease-standard);
}
.upload-btn:hover,
.select-btn:hover {
background: var(--accent-strong);
.select-btn:hover,
.clear-btn:hover {
filter: brightness(0.97);
box-shadow: 0 5px 14px var(--shadow-color);
transform: translateY(-1px);
}
.clear-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
background: var(--surface-muted);
color: var(--text-color);
border-color: var(--border-color);
}
.select-btn:disabled {
color: var(--text-muted);
background: var(--surface-muted);
+27 -8
View File
@@ -3,7 +3,7 @@
<div v-if="loading" class="site-grid site-grid--loading" aria-label="正在加载站点">
<span v-for="index in 6" :key="index" class="site-skeleton"></span>
</div>
<div v-else ref="swiperElement" class="swiper sites-swiper">
<div v-else-if="sites.length" ref="swiperElement" class="swiper sites-swiper">
<div class="swiper-wrapper">
<div v-for="(siteChunk, index) in chunkedSites" :key="index" class="swiper-slide">
<div class="site-grid">
@@ -11,8 +11,8 @@
v-for="site in siteChunk"
:key="site.id || site.url"
:href="site.url"
target="_blank"
rel="noopener noreferrer"
:target="config.openLinksInNewTab ? '_blank' : undefined"
:rel="config.openLinksInNewTab ? 'noopener noreferrer' : undefined"
class="site-box"
>
<i :class="site.icon" aria-hidden="true"></i>
@@ -23,6 +23,7 @@
</div>
<div ref="paginationElement" class="swiper-pagination"></div>
</div>
<div v-else class="site-empty"><i class="fas fa-link" aria-hidden="true"></i><span>暂无站点</span></div>
</section>
</template>
@@ -32,18 +33,20 @@ import Swiper from 'swiper/bundle'
import 'swiper/swiper-bundle.css'
import { getSites } from '../api'
import fallbackSites from '../config/site.json'
import { loadPublicConfig, publicConfig as config } from '../composables/usePublicConfig'
const sites = ref([])
const loading = ref(true)
const swiperElement = ref(null)
const paginationElement = ref(null)
const chunkedSites = computed(() => sites.value.reduce((chunks, site, index) => {
const chunkIndex = Math.floor(index / 6)
const chunkIndex = Math.floor(index / (Number(config.sitePageSize) || 6))
if (!chunks[chunkIndex]) chunks[chunkIndex] = []
chunks[chunkIndex].push(site)
return chunks
}, []))
let swiperInstance = null
let configChannel = null
const initializeSwiper = async () => {
await nextTick()
@@ -58,19 +61,33 @@ const initializeSwiper = async () => {
})
}
onMounted(async () => {
const loadSites = async () => {
loading.value = true
try {
const { data } = await getSites()
sites.value = Array.isArray(data) && data.length ? data : fallbackSites
const [{ data }] = await Promise.all([getSites(), loadPublicConfig()])
sites.value = Array.isArray(data) ? data : []
} catch {
sites.value = fallbackSites
} finally {
loading.value = false
initializeSwiper()
}
}
onMounted(async () => {
await loadSites()
if (window.BroadcastChannel) {
configChannel = new BroadcastChannel('config-update')
configChannel.onmessage = ({ data }) => {
if (data?.type === 'config-updated') loadSites()
}
}
})
onUnmounted(() => swiperInstance?.destroy(true, true))
onUnmounted(() => {
swiperInstance?.destroy(true, true)
configChannel?.close()
})
</script>
<style scoped lang="less">
@@ -90,6 +107,8 @@ onUnmounted(() => swiperInstance?.destroy(true, true))
.site-box i { flex: 0 0 auto; font-size: var(--icon-size); }
.site-box span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.site-grid--loading { padding: 10px; }
.site-empty { min-height: 150px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: var(--text-muted); font-size: 13px; }
.site-empty i { color: var(--hover-link-color); font-size: 22px; }
.site-skeleton { animation: pulse 1.2s ease-in-out infinite alternate; }
@keyframes pulse { to { opacity: 0.45; } }
:deep(.swiper-pagination-bullet) { background: var(--text-muted); transition: width 0.3s ease; }
+279 -59
View File
@@ -1,108 +1,306 @@
<template>
<section class="collection" :aria-label="kind === 'sites' ? '站点列表' : '联系方式列表'">
<section class="collection" :aria-label="isSites ? '站点列表' : '联系方式列表'">
<header class="collection-summary">
<span>{{ kind === 'sites' ? '站点' : '联系方式' }}</span>
<strong>{{ items.length }}</strong>
<div class="summary-title">
<span>{{ isSites ? '站点' : '联系方式' }}</span>
<strong>{{ draftItems.length }}</strong>
<Transition name="dirty-pill">
<em v-if="hasOrderChanges">排序已调整</em>
</Transition>
</div>
<div class="summary-actions">
<button type="button" class="secondary-button" :disabled="!hasOrderChanges || busy" @click="resetDraft">
<i class="fas fa-rotate-left" aria-hidden="true"></i><span>重置排序</span>
</button>
<button type="button" class="primary-button" :disabled="!hasOrderChanges || busy" @click="saveOrder">
<i :class="busy ? 'fas fa-spinner fa-spin' : 'fas fa-floppy-disk'" aria-hidden="true"></i><span>保存排序</span>
</button>
</div>
</header>
<div v-if="items.length" class="desktop-table">
<table v-if="kind === 'sites'">
<thead><tr><th>名称</th><th>地址</th><th>图标</th><th>排序</th><th><span class="sr-only">操作</span></th></tr></thead>
<div v-if="draftItems.length" class="desktop-table">
<table v-if="isSites">
<thead>
<tr>
<th>排序</th>
<th>名称</th>
<th>地址</th>
<th>图标</th>
<th>顺序</th>
<th><span class="sr-only">操作</span></th>
</tr>
</thead>
<TransitionGroup tag="tbody" name="table-row">
<tr v-for="item in items" :key="item.id">
<tr v-for="(item, index) in draftItems" :key="item.id">
<td><ReorderButtons :index="index" :count="draftItems.length" :busy="busy" @move="moveItem" /></td>
<td><span class="name-cell"><i :class="item.icon" aria-hidden="true"></i>{{ item.name }}</span></td>
<td><a :href="item.url" target="_blank" rel="noopener noreferrer" class="url-cell">{{ item.url }}<i class="fas fa-arrow-up-right-from-square"></i></a></td>
<td><a :href="item.url" target="_blank" rel="noopener noreferrer" class="url-cell">{{ item.url }}<i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i></a></td>
<td><code>{{ item.icon }}</code></td>
<td>{{ item.sortOrder }}</td>
<td class="row-actions"><ActionButtons :item="item" @edit="emit('edit', item)" @delete="emit('delete', item)" /></td>
<td>{{ displayOrder(item, index) }}</td>
<td class="row-actions"><ActionButtons :item="item" :is-sites="isSites" @copy="copyItem" @preview-qr="previewQR" @edit="emit('edit', item)" @delete="emit('delete', item)" /></td>
</tr>
</TransitionGroup>
</table>
<table v-else>
<thead><tr><th>类型</th><th>图标</th><th>链接或二维码</th><th>悬停颜色</th><th>排序</th><th><span class="sr-only">操作</span></th></tr></thead>
<thead>
<tr>
<th>排序</th>
<th>类型</th>
<th>图标</th>
<th>链接或二维码</th>
<th>悬停颜色</th>
<th>顺序</th>
<th><span class="sr-only">操作</span></th>
</tr>
</thead>
<TransitionGroup tag="tbody" name="table-row">
<tr v-for="item in items" :key="item.id">
<tr v-for="(item, index) in draftItems" :key="item.id">
<td><ReorderButtons :index="index" :count="draftItems.length" :busy="busy" @move="moveItem" /></td>
<td><strong>{{ item.type }}</strong></td>
<td><i :class="item.icon" :style="{ color: item.hoverColor }" aria-hidden="true"></i></td>
<td><a v-if="item.url" :href="item.url" target="_blank" rel="noopener noreferrer" class="url-cell">{{ item.url }}<i class="fas fa-arrow-up-right-from-square"></i></a><span v-else class="muted"><i class="fas fa-qrcode"></i> 二维码</span></td>
<td><span class="color-cell"><i :style="{ background: item.hoverColor }"></i><code>{{ item.hoverColor }}</code></span></td>
<td>{{ item.sortOrder }}</td>
<td class="row-actions"><ActionButtons :item="item" @edit="emit('edit', item)" @delete="emit('delete', item)" /></td>
<td>
<a v-if="item.url" :href="item.url" target="_blank" rel="noopener noreferrer" class="url-cell">{{ item.url }}<i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i></a>
<button v-else type="button" class="inline-chip" @click="previewQR(item)"><i class="fas fa-qrcode" aria-hidden="true"></i>二维码</button>
</td>
<td><span class="color-cell"><i :style="{ background: item.hoverColor || 'var(--text-muted)' }"></i><code>{{ item.hoverColor || '默认' }}</code></span></td>
<td>{{ displayOrder(item, index) }}</td>
<td class="row-actions"><ActionButtons :item="item" :is-sites="isSites" @copy="copyItem" @preview-qr="previewQR" @edit="emit('edit', item)" @delete="emit('delete', item)" /></td>
</tr>
</TransitionGroup>
</table>
</div>
<TransitionGroup v-if="items.length" tag="div" class="mobile-list" name="mobile-row">
<article v-for="item in items" :key="item.id" class="mobile-item">
<div class="mobile-icon"><i :class="item.icon" :style="kind === 'contacts' ? { color: item.hoverColor } : {}"></i></div>
<TransitionGroup v-if="draftItems.length" tag="div" class="mobile-list" name="mobile-row">
<article v-for="(item, index) in draftItems" :key="item.id" class="mobile-item">
<ReorderButtons :index="index" :count="draftItems.length" :busy="busy" @move="moveItem" />
<div class="mobile-icon"><i :class="item.icon" :style="isSites ? {} : { color: item.hoverColor }" aria-hidden="true"></i></div>
<div class="mobile-content">
<strong>{{ kind === 'sites' ? item.name : item.type }}</strong>
<strong>{{ isSites ? item.name : item.type }}</strong>
<span>{{ item.url || '二维码联系方式' }}</span>
<small> {{ item.sortOrder }}</small>
<small> {{ displayOrder(item, index) }}</small>
</div>
<ActionButtons :item="item" @edit="emit('edit', item)" @delete="emit('delete', item)" />
<ActionButtons :item="item" :is-sites="isSites" @copy="copyItem" @preview-qr="previewQR" @edit="emit('edit', item)" @delete="emit('delete', item)" />
</article>
</TransitionGroup>
<div v-if="!items.length" class="empty-state">
<i :class="kind === 'sites' ? 'fas fa-link' : 'fas fa-address-book'" aria-hidden="true"></i>
<strong>暂无{{ kind === 'sites' ? '站点' : '联系方式' }}</strong>
<p>使用页面右上角的添加按钮创建第一条记录</p>
<div v-if="!draftItems.length" class="empty-state">
<i :class="isSites ? 'fas fa-link' : 'fas fa-address-book'" aria-hidden="true"></i>
<strong>暂无{{ isSites ? '站点' : '联系方式' }}</strong>
</div>
<Teleport to="body">
<Transition name="qr-preview">
<button v-if="qrPreview" type="button" class="qr-preview-backdrop" aria-label="关闭二维码预览" @click="qrPreview = null">
<span class="qr-preview-dialog" @click.stop>
<img :src="qrPreview.qrCode" :alt="`${qrPreview.type} 二维码`" />
<strong>{{ qrPreview.type }}</strong>
<span>{{ qrPreview.qrCode }}</span>
</span>
</button>
</Transition>
</Teleport>
</section>
</template>
<script setup>
import { defineComponent, h } from 'vue'
import { computed, defineComponent, h, ref, watch } from 'vue'
defineProps({
const props = defineProps({
kind: { type: String, required: true },
items: { type: Array, default: () => [] },
busy: { type: Boolean, default: false },
resetVersion: { type: Number, default: 0 },
})
const emit = defineEmits(['edit', 'delete', 'reorderSave', 'reorderReset', 'notify'])
const draftItems = ref([])
const qrPreview = ref(null)
const isSites = computed(() => props.kind === 'sites')
const sourceOrder = computed(() => props.items.map((item) => item.id).join(','))
const draftOrder = computed(() => draftItems.value.map((item) => item.id).join(','))
const hasOrderChanges = computed(() => draftOrder.value !== sourceOrder.value)
const applyDraft = () => {
draftItems.value = props.items.map((item) => ({ ...item }))
}
const resetDraft = () => {
applyDraft()
emit('reorderReset')
}
watch([() => props.items, () => props.resetVersion], applyDraft, { immediate: true, deep: true })
const moveItem = (index, direction) => {
const target = index + direction
if (target < 0 || target >= draftItems.value.length || props.busy) return
const nextItems = [...draftItems.value]
;[nextItems[index], nextItems[target]] = [nextItems[target], nextItems[index]]
draftItems.value = nextItems
}
const saveOrder = () => {
if (!hasOrderChanges.value || props.busy) return
emit('reorderSave', draftItems.value.map((item) => item.id))
}
const displayOrder = (item, index) => hasOrderChanges.value ? (index + 1) * 10 : item.sortOrder
const itemText = (item) => item.url || item.qrCode || ''
const copyItem = async (item) => {
const text = itemText(item)
if (!text) {
emit('notify', { message: '没有可复制的内容', type: 'warning' })
return
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
} else {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
textarea.remove()
}
emit('notify', { message: '内容已复制', type: 'success' })
} catch {
emit('notify', { message: '复制失败,请手动复制', type: 'error' })
}
}
const previewQR = (item) => {
if (!item.qrCode) {
emit('notify', { message: '当前记录没有二维码', type: 'warning' })
return
}
qrPreview.value = item
}
const ReorderButtons = defineComponent({
props: {
index: { type: Number, required: true },
count: { type: Number, required: true },
busy: { type: Boolean, default: false },
},
emits: ['move'],
setup(childProps, { emit: childEmit }) {
return () => h('div', { class: 'reorder-buttons' }, [
h('button', {
type: 'button',
title: '上移',
'aria-label': '上移',
disabled: childProps.busy || childProps.index === 0,
onClick: () => childEmit('move', childProps.index, -1),
}, [h('i', { class: 'fas fa-arrow-up', 'aria-hidden': 'true' })]),
h('button', {
type: 'button',
title: '下移',
'aria-label': '下移',
disabled: childProps.busy || childProps.index === childProps.count - 1,
onClick: () => childEmit('move', childProps.index, 1),
}, [h('i', { class: 'fas fa-arrow-down', 'aria-hidden': 'true' })]),
])
},
})
const emit = defineEmits(['edit', 'delete'])
const ActionButtons = defineComponent({
emits: ['edit', 'delete'],
setup(_, { emit: childEmit }) {
return () => h('div', { class: 'action-buttons' }, [
h('button', { type: 'button', title: '编辑', 'aria-label': '编辑', onClick: () => childEmit('edit') }, [h('i', { class: 'fas fa-pen' })]),
h('button', { type: 'button', title: '删除', 'aria-label': '删除', class: 'delete', onClick: () => childEmit('delete') }, [h('i', { class: 'fas fa-trash' })]),
])
props: {
item: { type: Object, required: true },
isSites: { type: Boolean, default: false },
},
emits: ['copy', 'previewQr', 'edit', 'delete'],
setup(childProps, { emit: childEmit }) {
return () => {
const controls = [
h('button', { type: 'button', title: '复制', 'aria-label': '复制', onClick: () => childEmit('copy', childProps.item) }, [h('i', { class: 'fas fa-copy', 'aria-hidden': 'true' })]),
]
if (childProps.item.url) {
controls.push(h('a', { href: childProps.item.url, target: '_blank', rel: 'noopener noreferrer', title: '打开', 'aria-label': '打开' }, [h('i', { class: 'fas fa-arrow-up-right-from-square', 'aria-hidden': 'true' })]))
}
if (!childProps.isSites && childProps.item.qrCode) {
controls.push(h('button', { type: 'button', title: '预览二维码', 'aria-label': '预览二维码', onClick: () => childEmit('previewQr', childProps.item) }, [h('i', { class: 'fas fa-qrcode', 'aria-hidden': 'true' })]))
}
controls.push(
h('button', { type: 'button', title: '编辑', 'aria-label': '编辑', onClick: () => childEmit('edit') }, [h('i', { class: 'fas fa-pen', 'aria-hidden': 'true' })]),
h('button', { type: 'button', title: '删除', 'aria-label': '删除', class: 'delete', onClick: () => childEmit('delete') }, [h('i', { class: 'fas fa-trash', 'aria-hidden': 'true' })]),
)
return h('div', { class: 'action-buttons' }, controls)
}
},
})
</script>
<style scoped>
.collection { overflow: hidden; border: 1px solid var(--border-color); border-radius: 8px; background: var(--surface-color); }
.collection-summary { min-height: 52px; display: flex; align-items: center; gap: 8px; padding: 0 16px; border-bottom: 1px solid var(--border-color); color: var(--text-muted); font-size: 13px; }
.collection-summary strong { min-width: 26px; padding: 2px 7px; border-radius: 10px; color: var(--text-color); background: var(--surface-muted); text-align: center; }
.collection { overflow: hidden; border: 1px solid var(--border-color); border-radius: 8px; background: var(--surface-color); animation: collection-in var(--motion-emphasis) var(--motion-ease) both; }
.collection-summary { min-height: 58px; display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 10px 14px; border-bottom: 1px solid var(--border-color); }
.summary-title { min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--text-muted); font-size: 13px; }
.summary-title strong { min-width: 26px; padding: 2px 7px; border-radius: 10px; color: var(--text-color); background: var(--surface-muted); text-align: center; }
.summary-title em { padding: 3px 8px; border: 1px solid rgba(var(--hover-link-color-rgb), 0.5); border-radius: 999px; color: #312900; background: var(--hover-link-color); font-size: 11px; font-style: normal; font-weight: 700; }
.summary-actions { display: flex; justify-content: flex-end; gap: 8px; }
.primary-button,
.secondary-button { min-height: 34px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; padding: 0 11px; border-radius: 6px; font-weight: 700; cursor: pointer; transition: transform var(--motion-fast) var(--motion-ease), box-shadow var(--motion-base) var(--motion-ease), border-color var(--motion-fast) var(--motion-ease-standard), filter var(--motion-fast) var(--motion-ease-standard); }
.primary-button { border: 1px solid #d1a700; color: #292100; background: var(--hover-link-color); }
.secondary-button { border: 1px solid var(--border-color); color: var(--text-color); background: var(--surface-muted); }
.primary-button:hover:not(:disabled),
.secondary-button:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 5px 14px var(--shadow-color); }
.primary-button:disabled,
.secondary-button:disabled { opacity: 0.46; cursor: not-allowed; }
.desktop-table { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th,
td { padding: 13px 15px; border-bottom: 1px solid var(--border-color); text-align: left; vertical-align: middle; font-size: 13px; }
td { padding: 12px 14px; border-bottom: 1px solid var(--border-color); text-align: left; vertical-align: middle; font-size: 13px; }
th { color: var(--text-muted); background: var(--surface-muted); font-size: 12px; font-weight: 600; white-space: nowrap; }
tbody tr:last-child td { border-bottom: 0; }
tbody tr { transition: background-color var(--motion-fast) var(--motion-ease-standard); }
tbody tr { transition: background-color var(--motion-fast) var(--motion-ease-standard), transform var(--motion-fast) var(--motion-ease); }
tbody tr:hover { background: var(--surface-muted); }
.name-cell,
.color-cell,
.url-cell,
.muted { display: inline-flex; align-items: center; gap: 8px; }
.name-cell { font-weight: 600; }
.name-cell i { width: 18px; color: var(--hover-link-color); text-align: center; }
.inline-chip { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
.name-cell { max-width: 260px; font-weight: 600; }
.name-cell i { width: 18px; flex: 0 0 auto; color: var(--hover-link-color); text-align: center; transition: transform var(--motion-base) var(--motion-ease); }
tbody tr:hover .name-cell i { transform: translateY(-2px) rotate(-5deg); }
.name-cell,
.url-cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.url-cell { max-width: 360px; color: var(--text-muted); }
.url-cell:hover { color: var(--hover-link-color); }
.url-cell { overflow-wrap: anywhere; }
.url-cell i { font-size: 10px; }
code { padding: 3px 6px; border-radius: 4px; color: var(--text-muted); background: var(--surface-muted); font: 11px Consolas, monospace; }
.color-cell i { width: 13px; height: 13px; border: 1px solid var(--border-color); border-radius: 50%; }
.muted { color: var(--text-muted); }
.row-actions { width: 94px; text-align: right; }
.url-cell i { flex: 0 0 auto; font-size: 10px; }
code { max-width: 260px; display: inline-block; overflow: hidden; padding: 3px 6px; border-radius: 4px; color: var(--text-muted); background: var(--surface-muted); font: 11px Consolas, monospace; text-overflow: ellipsis; vertical-align: middle; white-space: nowrap; }
.color-cell i { width: 13px; height: 13px; flex: 0 0 auto; border: 1px solid var(--border-color); border-radius: 50%; }
.inline-chip { border: 0; color: var(--text-muted); background: transparent; cursor: pointer; }
.inline-chip:hover { color: var(--hover-link-color); }
.row-actions { width: 190px; text-align: right; }
:deep(.reorder-buttons),
:deep(.action-buttons) { display: inline-flex; gap: 5px; }
:deep(.action-buttons button) { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid transparent; border-radius: 6px; color: var(--text-muted); background: transparent; cursor: pointer; }
:deep(.action-buttons button:hover) { border-color: var(--border-color); color: var(--text-color); background: var(--surface-solid); }
:deep(.reorder-buttons button),
:deep(.action-buttons button),
:deep(.action-buttons a) { width: 32px; height: 32px; display: grid; place-items: center; border: 1px solid transparent; border-radius: 6px; color: var(--text-muted); background: transparent; cursor: pointer; transition: color var(--motion-fast) var(--motion-ease-standard), background-color var(--motion-fast) var(--motion-ease-standard), border-color var(--motion-fast) var(--motion-ease-standard), transform var(--motion-fast) var(--motion-ease); }
:deep(.reorder-buttons button:hover:not(:disabled)),
:deep(.action-buttons button:hover),
:deep(.action-buttons a:hover) { border-color: var(--border-color); color: var(--text-color); background: var(--surface-solid); transform: translateY(-1px); }
:deep(.action-buttons button.delete:hover) { border-color: var(--danger-color); color: var(--danger-color); }
:deep(.reorder-buttons button:disabled) { opacity: 0.28; cursor: not-allowed; }
.empty-state { min-height: 260px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 30px; color: var(--text-muted); text-align: center; }
.empty-state > i { font-size: 28px; color: var(--hover-link-color); }
.empty-state strong { color: var(--text-color); }
.mobile-list { display: none; }
.qr-preview-backdrop { position: fixed; inset: 0; z-index: 2100; display: grid; place-items: center; padding: 20px; border: 0; background: rgba(0, 0, 0, 0.58); cursor: pointer; }
.qr-preview-dialog { width: min(320px, 86vw); display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 20px; border: 1px solid var(--border-color); border-radius: 8px; color: var(--text-color); background: var(--surface-solid); box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28); cursor: default; }
.qr-preview-dialog img { width: min(220px, 68vw); aspect-ratio: 1; object-fit: contain; padding: 12px; border-radius: 6px; background: white; }
.qr-preview-dialog strong,
.qr-preview-dialog span { max-width: 100%; overflow-wrap: anywhere; }
.qr-preview-dialog span { color: var(--text-muted); font-size: 11px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.table-row-enter-active,
.table-row-leave-active,
.table-row-move,
@@ -114,21 +312,43 @@ code { padding: 3px 6px; border-radius: 4px; color: var(--text-muted); backgroun
.mobile-row-enter-from,
.mobile-row-leave-to { opacity: 0; transform: translateY(10px) scale(0.98); }
.mobile-row-leave-active { position: absolute; width: 100%; }
.mobile-list { display: none; }
.empty-state { min-height: 280px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 30px; color: var(--text-muted); text-align: center; }
.empty-state > i { font-size: 28px; color: var(--hover-link-color); }
.empty-state strong { color: var(--text-color); }
.empty-state p { margin: 0; font-size: 13px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.dirty-pill-enter-active,
.dirty-pill-leave-active { transition: opacity var(--motion-fast) var(--motion-ease-standard), transform var(--motion-fast) var(--motion-ease); }
.dirty-pill-enter-from,
.dirty-pill-leave-to { opacity: 0; transform: translateY(-4px); }
.qr-preview-enter-active,
.qr-preview-leave-active { transition: opacity var(--motion-base) var(--motion-ease-standard); }
.qr-preview-enter-active .qr-preview-dialog,
.qr-preview-leave-active .qr-preview-dialog { transition: opacity var(--motion-base) var(--motion-ease), transform var(--motion-emphasis) var(--motion-ease); }
.qr-preview-enter-from,
.qr-preview-leave-to { opacity: 0; }
.qr-preview-enter-from .qr-preview-dialog,
.qr-preview-leave-to .qr-preview-dialog { opacity: 0; transform: translateY(18px) scale(0.96); }
@keyframes collection-in { from { opacity: 0; transform: translateY(12px); } }
@media (max-width: 900px) {
.collection-summary { align-items: flex-start; flex-direction: column; }
.summary-actions { width: 100%; }
.summary-actions button { flex: 1; }
}
@media (max-width: 720px) {
.desktop-table { display: none; }
.mobile-list { position: relative; display: block; }
.mobile-item { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 13px; border-bottom: 1px solid var(--border-color); }
.mobile-item { display: grid; grid-template-columns: 58px 40px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 13px; border-bottom: 1px solid var(--border-color); }
.mobile-item:last-child { border-bottom: 0; }
.mobile-icon { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 7px; background: var(--surface-muted); }
.mobile-content { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.mobile-content span { overflow: hidden; color: var(--text-muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.mobile-content strong,
.mobile-content span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mobile-content span { color: var(--text-muted); font-size: 12px; }
.mobile-content small { color: var(--text-muted); font-size: 10px; }
:deep(.mobile-item .reorder-buttons) { flex-direction: column; }
:deep(.mobile-item .action-buttons) { flex-wrap: wrap; justify-content: flex-end; width: 74px; }
}
@media (max-width: 520px) {
.summary-actions { flex-direction: column-reverse; }
.mobile-item { grid-template-columns: 38px minmax(0, 1fr) auto; }
.mobile-item :deep(.reorder-buttons) { grid-row: span 2; }
.mobile-icon { display: none; }
}
</style>
+69 -4
View File
@@ -34,6 +34,19 @@
<label class="field"><span>版权结束年份</span><input v-model="config.footerYearEnd" inputmode="numeric" placeholder="留空则只显示起始年份" /></label>
</div>
<div v-else-if="activeSection === '主页显示'" class="form-grid">
<label class="field"><span>主页问候语</span><input v-model="config.greetingText" type="text" placeholder="Hi," /></label>
<label class="field"><span>在线状态文本</span><input v-model="config.onlineStatusText" type="text" placeholder="在线中" /></label>
<label class="field field--full"><span>页脚署名文本</span><input v-model="config.footerLabel" type="text" placeholder="Made by" /></label>
<label class="switch-field field--full"><span><strong>显示关于入口</strong><small>允许点击头像打开关于弹窗</small></span><input v-model="config.showAbout" type="checkbox" role="switch" /></label>
<label class="switch-field field--full"><span><strong>显示站点导航</strong><small>隐藏后不会加载站点卡片区域</small></span><input v-model="config.showSites" type="checkbox" role="switch" /></label>
<label class="switch-field field--full"><span><strong>显示联系方式</strong><small>主题切换按钮可以独立保留</small></span><input v-model="config.showContacts" type="checkbox" role="switch" /></label>
<label class="switch-field field--full"><span><strong>显示主题切换</strong><small>在联系方式区域显示明暗主题按钮</small></span><input v-model="config.showThemeToggle" type="checkbox" role="switch" /></label>
<label class="switch-field field--full"><span><strong>显示页脚</strong><small>控制版权和备案信息整体显示</small></span><input v-model="config.showFooter" type="checkbox" role="switch" /></label>
<label class="field"><span>站点每页数量</span><select v-model.number="config.sitePageSize"><option :value="6">6 </option><option :value="9">9 </option><option :value="12">12 </option></select></label>
<label class="switch-field field--full"><span><strong>外链新窗口打开</strong><small>应用于站点联系方式和关于链接</small></span><input v-model="config.openLinksInNewTab" type="checkbox" role="switch" /></label>
</div>
<div v-else-if="activeSection === '用户信息'" class="form-grid">
<label class="field field--full"><span>主页用户名</span><input v-model="config.userName" type="text" placeholder="显示在 Hi, I'm 后方" /></label>
<div class="field field--full"><span>头像</span><IconSelector v-model="config.profileImageURL" :default-icon-path="''" /><small>支持上传图片或填写外部图片 URL</small></div>
@@ -49,6 +62,25 @@
<label class="field"><span>公安备案号</span><input v-model="config.policeNumber" type="text" placeholder="留空则不显示" /></label>
</div>
<div v-else-if="activeSection === '关于信息'" class="form-grid">
<label class="field field--full"><span>关于标题</span><input v-model="config.aboutTitle" type="text" placeholder="关于本站" /></label>
<label class="field field--full"><span>关于简介</span><textarea v-model="config.aboutDescription" rows="3" placeholder="介绍这个主页或你的个人信息"></textarea></label>
<div class="field field--full">
<span>相关链接</span>
<div class="about-links-editor">
<div v-for="(link, index) in config.aboutLinks" :key="index" class="about-link-row">
<input v-model="link.title" type="text" placeholder="链接标题" />
<input v-model="link.description" type="text" placeholder="链接描述" />
<input v-model="link.url" type="url" placeholder="https://example.com" />
<input v-model="link.icon" type="text" placeholder="fas fa-link" />
<button type="button" title="删除链接" :aria-label="`删除第 ${index + 1} 条链接`" @click="config.aboutLinks.splice(index, 1)"><i class="fas fa-trash" aria-hidden="true"></i></button>
</div>
<button type="button" class="secondary-button" :disabled="config.aboutLinks.length >= 8" @click="config.aboutLinks.push({ title: '', description: '', url: '', icon: 'fas fa-link' })"><i class="fas fa-plus" aria-hidden="true"></i>添加链接</button>
</div>
<small>最多 8 链接地址必须使用 http https</small>
</div>
</div>
<div v-else-if="activeSection === '前端配置'" class="form-grid">
<label class="field field--full"><span>网页标题</span><input v-model="config.pageTitle" type="text" placeholder="个人主页" /></label>
<label class="field field--full"><span>图标库 CDN 地址</span><input v-model="config.iconLibrary" type="url" placeholder="Font Awesome 样式地址" /></label>
@@ -80,8 +112,24 @@
</div>
<div v-else class="form-grid">
<label class="field field--full"><span>Umami 统计脚本地址</span><input v-model="config.umamiScript" type="url" placeholder="https://analytics.example.com/script.js" /><small>留空时不加载统计脚本</small></label>
<div class="analytics-state field--full" :class="config.analyticsProvider === 'umami' && !config.umamiCredentialConfigured ? 'analytics-state--warning' : ''">
<i class="fas fa-circle-info" aria-hidden="true"></i>
<span>当前采集来源{{ config.analyticsProvider === 'umami' ? 'Umami' : 'SQLite 本地统计' }}管理端数据源{{ config.analyticsProvider === 'umami' && config.umamiCredentialConfigured ? '已配置' : config.analyticsProvider === 'umami' ? '未配置 API 凭据' : 'SQLite 本地统计' }}</span>
</div>
<label class="field"><span>统计来源</span><select v-model="config.analyticsProvider"><option value="local">SQLite 本地统计</option><option value="umami">Umami 统计</option></select></label>
<label class="field"><span>Umami API 模式</span><select v-model="config.umamiApiMode"><option value="selfhost">自托管</option><option value="cloud">Umami Cloud</option></select></label>
<label class="field field--full"><span>Umami 统计脚本地址</span><input v-model="config.umamiScript" type="url" placeholder="https://analytics.example.com/script.js" /><small>脚本和 Website ID 同时填写后主页才会加载 Umami</small></label>
<label class="field field--full"><span>Umami 网站 ID</span><input v-model="config.umamiWebsiteId" type="text" placeholder="网站 UUID" /></label>
<label class="field field--full"><span>Umami API 地址</span><input v-model="config.umamiApiUrl" type="url" placeholder="自托管:https://analytics.example.com/apiCloud 可留空" /></label>
<label class="field field--full"><span>Umami API 凭据</span><input v-model="config.umamiCredentialDraft" type="password" autocomplete="new-password" placeholder="留空表示保持当前凭据" /><small v-if="config.umamiCredentialConfigured">当前已有凭据输入新值可替换</small></label>
<label v-if="config.umamiCredentialConfigured" class="switch-field field--full"><span><strong>清除已保存凭据</strong><small>清除后管理仪表盘将无法读取 Umami 数据</small></span><input v-model="config.clearUmamiCredential" type="checkbox" role="switch" /></label>
<div class="field field--full inline-actions"><button type="button" class="secondary-button" :disabled="actionLoading" @click="emit('testAnalytics')"><i class="fas fa-plug" aria-hidden="true"></i>测试 Umami 连接</button></div>
<label class="field"><span>允许统计域名</span><input v-model="config.umamiDomains" type="text" placeholder="example.com,www.example.com" /></label>
<label class="field"><span>Umami 标签</span><input v-model="config.umamiTag" type="text" placeholder="可选标签" /></label>
<label class="switch-field field--full"><span><strong>尊重 Do Not Track</strong><small>遵循浏览器的隐私偏好</small></span><input v-model="config.umamiDoNotTrack" type="checkbox" role="switch" /></label>
<label class="switch-field field--full"><span><strong>排除查询参数</strong><small>不记录 URL 中的 query 参数</small></span><input v-model="config.umamiExcludeSearch" type="checkbox" role="switch" /></label>
<label class="switch-field field--full"><span><strong>排除 hash</strong><small>不记录 URL hash 片段</small></span><input v-model="config.umamiExcludeHash" type="checkbox" role="switch" /></label>
<label class="switch-field field--full"><span><strong>采集性能指标</strong><small>需要当前 Umami 版本支持性能采集</small></span><input v-model="config.umamiPerformance" type="checkbox" role="switch" /></label>
</div>
</div>
</Transition>
@@ -97,7 +145,7 @@
<i v-else key="fallback" class="fas fa-user" aria-hidden="true"></i>
</Transition>
</span>
<div><strong>Hi,</strong><strong>I'm <mark>{{ config.userName || '用户' }}</mark></strong></div>
<div><strong>{{ config.greetingText || 'Hi,' }}</strong><strong>I'm <mark>{{ config.userName || '用户' }}</mark></strong></div>
</div>
<p class="preview-text">{{ previewText }}</p>
<div class="preview-icons"><i class="fas fa-envelope"></i><i class="fab fa-github"></i><i class="fas fa-moon"></i></div>
@@ -113,18 +161,21 @@
import { computed, ref, watch } from 'vue'
import IconSelector from '../IconSelector.vue'
const emit = defineEmits(['update:activeSection', 'addText', 'removeText', 'saveTexts', 'testAnalytics'])
const props = defineProps({
config: { type: Object, required: true },
rotatingTexts: { type: Array, required: true },
activeSection: { type: String, required: true },
actionLoading: { type: Boolean, default: false },
})
const emit = defineEmits(['update:activeSection', 'addText', 'removeText', 'saveTexts'])
const previewImageFailed = ref(false)
const sections = [
{ name: '基础信息', icon: 'fas fa-circle-info', description: '站点身份、搜索信息和版权年份' },
{ name: '用户信息', icon: 'fas fa-user', description: '主页展示的用户名与头像' },
{ name: '主页显示', icon: 'fas fa-eye', description: '主页文案、模块显示和外链行为' },
{ name: '图标配置', icon: 'fas fa-image', description: '站点图标和浏览器标签图标' },
{ name: '备案信息', icon: 'fas fa-shield-halved', description: '页脚展示的备案信息' },
{ name: '关于信息', icon: 'fas fa-circle-question', description: '关于弹窗标题、简介和相关链接' },
{ name: '前端配置', icon: 'fas fa-code', description: '网页标题、外部资源和计时器' },
{ name: '轮换文本', icon: 'fas fa-i-cursor', description: '主页逐条打字展示的短句,最多八条' },
{ name: '统计配置', icon: 'fas fa-chart-line', description: 'Umami 访问统计接入信息' },
@@ -136,7 +187,7 @@ const previewFooter = computed(() => {
const start = String(props.config.footerYearStart || '').trim()
const end = String(props.config.footerYearEnd || '').trim()
const year = start && end && start !== end ? `${start}~${end}` : start || end || currentYear
return `© ${year} Made by ${props.config.userName || '用户'}`
return `© ${year} ${props.config.footerLabel || 'Made by'} ${props.config.userName || '用户'}`
})
watch(() => props.config.profileImageURL, () => { previewImageFailed.value = false })
@@ -165,8 +216,10 @@ watch(() => props.config.profileImageURL, () => { previewImageFailed.value = fal
.switch-field small { color: var(--text-muted); font-size: 12px; font-weight: 400; }
.field input,
.field textarea,
.field select,
.rotating-row input { width: 100%; border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-color); background: var(--surface-muted); }
.field input { height: 40px; padding: 0 11px; }
.field select { height: 40px; padding: 0 11px; }
.field textarea { padding: 10px 11px; resize: vertical; }
.field input:focus,
.field textarea:focus,
@@ -182,7 +235,16 @@ watch(() => props.config.profileImageURL, () => { previewImageFailed.value = fal
.rotating-row button { width: 36px; height: 36px; border: 1px solid transparent; border-radius: 6px; color: var(--danger-color); background: transparent; cursor: pointer; }
.rotating-row button:hover:not(:disabled) { border-color: currentColor; background: rgba(201, 54, 43, 0.07); }
.rotating-row button:disabled { opacity: 0.35; cursor: not-allowed; }
.about-links-editor { display: flex; flex-direction: column; gap: 10px; }
.about-link-row { display: grid; grid-template-columns: minmax(90px, 0.8fr) minmax(90px, 0.9fr) minmax(150px, 1.4fr) minmax(90px, 0.7fr) 36px; gap: 7px; align-items: center; }
.about-link-row input { min-width: 0; height: 38px; padding: 0 9px; }
.about-link-row button { width: 36px; height: 36px; border: 1px solid transparent; border-radius: 6px; color: var(--danger-color); background: transparent; cursor: pointer; }
.about-link-row button:hover { border-color: currentColor; background: rgba(201, 54, 43, 0.07); }
.inline-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 10px; }
.analytics-state { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-left: 3px solid var(--success-color); color: var(--text-muted); background: var(--surface-muted); font-size: 12px; }
.analytics-state i { color: var(--success-color); }
.analytics-state--warning { border-left-color: var(--warning-color); }
.analytics-state--warning i { color: var(--warning-color); }
.primary-button,
.secondary-button { min-height: 38px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 14px; border-radius: 6px; cursor: pointer; }
.primary-button { border: 1px solid #d1a700; color: #292100; background: var(--hover-link-color); font-weight: 700; }
@@ -234,5 +296,8 @@ watch(() => props.config.profileImageURL, () => { previewImageFailed.value = fal
.field--full { grid-column: auto; }
.inline-actions { flex-direction: column-reverse; }
.inline-actions button { width: 100%; }
.about-link-row { grid-template-columns: 1fr 36px; }
.about-link-row input { grid-column: 1; }
.about-link-row button { grid-column: 2; grid-row: 1 / span 4; }
}
</style>
+81
View File
@@ -0,0 +1,81 @@
import api from '../api'
let scriptSignature = ''
let scriptPromise = null
const trackedPages = new Set()
function removeUmamiScript() {
document.getElementById('umami-script')?.remove()
try { window.umami = undefined } catch {}
scriptSignature = ''
scriptPromise = null
}
function setOptionalAttribute(script, name, value) {
if (value) script.setAttribute(name, String(value))
else script.removeAttribute(name)
}
function loadUmamiScript(config) {
const src = String(config.umamiScript || '').trim()
const websiteId = String(config.umamiWebsiteId || '').trim()
if (config.analyticsProvider !== 'umami' || !src || !websiteId) {
removeUmamiScript()
return Promise.resolve(false)
}
const signature = JSON.stringify({
src, websiteId, domains: config.umamiDomains, doNotTrack: config.umamiDoNotTrack,
excludeSearch: config.umamiExcludeSearch, excludeHash: config.umamiExcludeHash,
performance: config.umamiPerformance, tag: config.umamiTag,
})
const existing = document.getElementById('umami-script')
if (existing && scriptSignature === signature) return scriptPromise || Promise.resolve(true)
removeUmamiScript()
scriptPromise = new Promise((resolve) => {
const script = document.createElement('script')
script.id = 'umami-script'
script.defer = true
script.src = src
script.setAttribute('data-website-id', websiteId)
script.setAttribute('data-auto-track', 'false')
setOptionalAttribute(script, 'data-domains', String(config.umamiDomains || '').trim())
setOptionalAttribute(script, 'data-do-not-track', config.umamiDoNotTrack ? 'true' : '')
setOptionalAttribute(script, 'data-exclude-search', config.umamiExcludeSearch ? 'true' : '')
setOptionalAttribute(script, 'data-exclude-hash', config.umamiExcludeHash ? 'true' : '')
setOptionalAttribute(script, 'data-performance', config.umamiPerformance ? 'true' : '')
setOptionalAttribute(script, 'data-tag', String(config.umamiTag || '').trim())
script.addEventListener('load', () => resolve(true), { once: true })
script.addEventListener('error', () => resolve(false), { once: true })
document.head.appendChild(script)
})
scriptSignature = signature
return scriptPromise
}
export async function applyAnalytics(config) {
if (config.analyticsProvider !== 'umami') {
removeUmamiScript()
return false
}
return loadUmamiScript(config)
}
export async function recordHomePageView(config) {
const trackerKey = config.analyticsProvider === 'umami'
? `umami:${config.umamiWebsiteId}:${config.umamiScript}`
: 'local'
const pageKey = `${trackerKey}:${window.location.pathname}${window.location.search}${window.location.hash}`
if (trackedPages.has(pageKey)) return
if (config.analyticsProvider === 'umami') {
const loaded = await applyAnalytics(config)
if (loaded && window.umami?.track) {
window.umami.track()
trackedPages.add(pageKey)
}
return
}
trackedPages.add(pageKey)
api.post('/track-visit', { path: window.location.pathname, referer: document.referrer || '' }).catch(() => {})
}
+70
View File
@@ -0,0 +1,70 @@
import { reactive, ref } from 'vue'
import { getSiteConfig } from '../api'
const defaultTexts = [
'你好鸭,欢迎来到我的主页!!',
'随时可以联系我,期待与你交流。',
'愿你历尽千帆,归来仍是少年。',
'梦想还是要有的,万一实现了呢?',
'I hope you have a happy day every day.',
]
const defaultConfig = {
siteName: '个人主页', siteURL: 'https://example.com', siteIcon: '/favicon.ico',
siteDescription: '一个基于Vue3的个人主页', siteKeywords: '个人主页,Vue3', userName: '用户',
profileImageURL: '', icpNumber: '', policeNumber: '', pageTitle: '个人主页', favicon: '/favicon.ico',
iconLibrary: '', fontLibrary: '', footerYearStart: '', footerYearEnd: '', showVisitTimer: true,
rotatingTexts: defaultTexts, greetingText: 'Hi,', onlineStatusText: '在线中', footerLabel: 'Made by',
showAbout: true, showSites: true, showContacts: true, showThemeToggle: true, showFooter: true,
aboutTitle: '关于本站', aboutDescription: '', aboutLinks: [], sitePageSize: 6, openLinksInNewTab: true,
analyticsProvider: 'local', umamiScript: '', umamiScriptUrl: '', umamiWebsiteId: '', umamiDomains: '', umamiDoNotTrack: true,
umamiExcludeSearch: false, umamiExcludeHash: false, umamiPerformance: false, umamiTag: '',
}
export const publicConfig = reactive({ ...defaultConfig })
export const configLoading = ref(false)
let pendingRequest = null
function normalizeConfig(value = {}) {
const trackerOptions = value.umamiTrackerOptions || {}
return {
...defaultConfig,
...value,
umamiScript: value.umamiScriptUrl || value.umamiScript || '',
umamiScriptUrl: value.umamiScriptUrl || value.umamiScript || '',
umamiDomains: trackerOptions.domains ?? value.umamiDomains ?? '',
umamiDoNotTrack: trackerOptions.doNotTrack ?? value.umamiDoNotTrack ?? true,
umamiExcludeSearch: trackerOptions.excludeSearch ?? value.umamiExcludeSearch ?? false,
umamiExcludeHash: trackerOptions.excludeHash ?? value.umamiExcludeHash ?? false,
umamiPerformance: trackerOptions.performance ?? value.umamiPerformance ?? false,
umamiTag: trackerOptions.tag ?? value.umamiTag ?? '',
rotatingTexts: Array.isArray(value.rotatingTexts) && value.rotatingTexts.length ? [...value.rotatingTexts] : [...defaultTexts],
aboutLinks: Array.isArray(value.aboutLinks) ? value.aboutLinks.map((link) => ({ ...link })) : [],
showVisitTimer: value.showVisitTimer === undefined ? true : Boolean(value.showVisitTimer),
showAbout: value.showAbout === undefined ? true : Boolean(value.showAbout),
showSites: value.showSites === undefined ? true : Boolean(value.showSites),
showContacts: value.showContacts === undefined ? true : Boolean(value.showContacts),
showThemeToggle: value.showThemeToggle === undefined ? true : Boolean(value.showThemeToggle),
showFooter: value.showFooter === undefined ? true : Boolean(value.showFooter),
openLinksInNewTab: value.openLinksInNewTab === undefined ? true : Boolean(value.openLinksInNewTab),
}
}
export async function loadPublicConfig(force = false) {
if (pendingRequest && !force) return pendingRequest
configLoading.value = true
pendingRequest = getSiteConfig()
.then(({ data }) => {
Object.assign(publicConfig, normalizeConfig(data))
return publicConfig
})
.finally(() => {
configLoading.value = false
pendingRequest = null
})
return pendingRequest
}
export function resetPublicConfig(value = {}) {
Object.assign(publicConfig, normalizeConfig(value))
}
+44 -59
View File
@@ -1,54 +1,25 @@
import { getFrontendConfig } from '../api'
/**
* 从API获取前端配置并更新页面
*/
export async function loadAndApplyFrontendConfig() {
try {
const res = await getFrontendConfig()
const config = res.data
// 更新页面标题
if (config.title) {
document.title = config.title
}
// 更新meta标签
if (config.keywords) {
updateMetaTag('keywords', config.keywords)
}
if (config.description) {
updateMetaTag('description', config.description)
}
// 更新favicon
if (config.favicon) {
updateFavicon(config.favicon)
}
// 动态加载图标库
if (config.iconLibrary) {
loadStylesheet(config.iconLibrary, 'icon-library')
}
// 动态加载字体库
if (config.fontLibrary) {
loadStylesheet(config.fontLibrary, 'font-library')
}
// 动态加载Umami统计脚本
if (config.umamiScript && config.umamiWebsiteId) {
loadUmamiScript(config.umamiScript, config.umamiWebsiteId)
}
const { data: config } = await getFrontendConfig()
if (config.title) document.title = config.title
updateMetaTag('keywords', config.keywords || '')
updateMetaTag('description', config.description || '')
updateMetaProperty('og:title', config.title || '')
updateMetaProperty('og:site_name', config.siteName || '')
updateMetaProperty('og:description', config.description || '')
updateMetaProperty('og:url', config.siteURL || window.location.href)
updateCanonical(config.siteURL)
if (config.favicon) updateFavicon(config.favicon)
updateStylesheet(config.iconLibrary, 'icon-library')
updateStylesheet(config.fontLibrary, 'font-library')
} catch (error) {
console.error('加载前端配置失败:', error)
// 如果API失败,使用默认值(从环境变量或index.html中的占位符)
}
}
function updateMetaTag(name, content) {
if (!content) return
let meta = document.querySelector(`meta[name="${name}"]`)
if (!meta) {
meta = document.createElement('meta')
@@ -58,6 +29,27 @@ function updateMetaTag(name, content) {
meta.setAttribute('content', content)
}
function updateMetaProperty(property, content) {
let meta = document.querySelector(`meta[property="${property}"]`)
if (!meta) {
meta = document.createElement('meta')
meta.setAttribute('property', property)
document.head.appendChild(meta)
}
meta.setAttribute('content', content)
}
function updateCanonical(href) {
if (!href) return
let link = document.querySelector('link[rel="canonical"]')
if (!link) {
link = document.createElement('link')
link.rel = 'canonical'
document.head.appendChild(link)
}
link.href = href
}
function updateFavicon(href) {
let link = document.querySelector("link[rel*='icon']")
if (!link) {
@@ -68,28 +60,21 @@ function updateFavicon(href) {
link.href = href
}
function loadStylesheet(href, id) {
// 检查是否已加载
if (document.getElementById(id)) {
function updateStylesheet(href, id) {
const existing = document.getElementById(id)
if (!href) {
existing?.remove()
return
}
const normalizedHref = href.startsWith('//') ? `https:${href}` : href
if (!/^https?:\/\//i.test(normalizedHref) && !normalizedHref.startsWith('/')) return
if (existing) {
if (existing.href !== new URL(normalizedHref, document.baseURI).href) existing.href = normalizedHref
return
}
const link = document.createElement('link')
link.id = id
link.rel = 'stylesheet'
link.href = href.startsWith('//') ? `https:${href}` : href
link.href = normalizedHref
document.head.appendChild(link)
}
function loadUmamiScript(src, websiteId) {
// 检查是否已加载
if (document.querySelector(`script[data-website-id="${websiteId}"]`)) {
return
}
const script = document.createElement('script')
script.defer = true
script.src = src
script.setAttribute('data-website-id', websiteId)
document.head.appendChild(script)
}
+4 -2
View File
@@ -1,6 +1,8 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
const apiPort = process.env.API_PORT || '1551';
export default defineConfig({
plugins: [vue()],
css: {
@@ -14,11 +16,11 @@ export default defineConfig({
port: 1552,
proxy: {
'/api': {
target: 'http://localhost:1551',
target: `http://localhost:${apiPort}`,
changeOrigin: true,
},
'/uploads': {
target: 'http://localhost:1551',
target: `http://localhost:${apiPort}`,
changeOrigin: true,
},
},