diff --git a/.gitignore b/.gitignore
index 129d522..3e23352 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
build/bin
+bin/
node_modules
frontend/dist
+frontend/bindings
+*.syso
diff --git a/app.go b/app.go
index 2f40642..8fbea11 100644
--- a/app.go
+++ b/app.go
@@ -1,19 +1,23 @@
package main
import (
+ "bytes"
"context"
"database/sql"
"errors"
"fmt"
+ "io"
"os"
"path/filepath"
"strings"
"sync"
+ "sync/atomic"
"time"
"view/platform"
"view/service"
- "github.com/wailsapp/wails/v2/pkg/runtime"
+ "github.com/wailsapp/wails/v3/pkg/application"
+ "github.com/wailsapp/wails/v3/pkg/services/notifications"
)
type App struct {
@@ -21,15 +25,52 @@ type App struct {
store *Store
bootstrapFile string
bootstrap BootstrapStatus
- runtimeReady bool
mu sync.Mutex
tasks map[string]context.CancelFunc
+ taskResults map[string]string
+ aiStreams map[int64]context.CancelFunc
+ shell *shellState
+ notifier *notifications.NotificationService
+ syncing atomic.Bool
+ syncOnline atomic.Bool
+ syncMu sync.Mutex
+ syncLastErr string
+ // 全局文件存储配置的实时读取缓存(见 filestorage.go currentFileStorage)。
+ fsMu sync.Mutex
+ fsCfg FileStorageConfig
+ fsCfgAt time.Time
+}
+
+func NewApp() *App {
+ return &App{tasks: map[string]context.CancelFunc{}, taskResults: map[string]string{}, aiStreams: map[int64]context.CancelFunc{}}
+}
+
+// emit 向前端广播事件;纯测试环境(无 Wails 应用实例)下静默忽略。
+func (a *App) emit(name string, data any) {
+ if app := application.Get(); app != nil {
+ app.Event.Emit(name, data)
+ }
+}
+
+// ServiceStartup 由 Wails v3 在应用启动时调用,执行数据库引导流程。
+func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error {
+ a.startup(ctx)
+ a.RefreshShell()
+ go a.runAutoUpdateLoop()
+ go a.runReminderLoop()
+ go a.runSyncLoop()
+ go a.runTeamDigestLoop()
+ return nil
+}
+
+// ServiceShutdown 由 Wails v3 在应用退出时调用。
+func (a *App) ServiceShutdown() error {
+ a.shutdown(context.Background())
+ return nil
}
-func NewApp() *App { return &App{tasks: map[string]context.CancelFunc{}} }
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
- a.runtimeReady = true
defaultPath, e := defaultDBPath()
if e != nil {
a.bootstrap = BootstrapStatus{State: BootstrapRecovery, ErrorCode: "DB_DEFAULT_PATH_FAILED", ErrorDetail: e.Error()}
@@ -66,7 +107,6 @@ func (a *App) startup(ctx context.Context) {
s, e := OpenStore(c.DatabasePath)
if e != nil {
a.bootstrap = bootstrapFailure(defaultPath, c.DatabasePath, e)
- runtime.LogError(ctx, e.Error())
return
}
a.store = s
@@ -80,7 +120,12 @@ func (a *App) SelectInitialDatabaseFile(defaultPath string) (string, error) {
if strings.TrimSpace(defaultPath) == "" {
defaultPath = a.bootstrap.DefaultPath
}
- return runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{Title: "Initialize Code Count database", DefaultDirectory: filepath.Dir(defaultPath), DefaultFilename: filepath.Base(defaultPath), Filters: []runtime.FileFilter{{DisplayName: "SQLite Database", Pattern: "*.db"}}})
+ return application.Get().Dialog.SaveFile().
+ SetMessage("Initialize 年糕崽崽 PMS database").
+ SetDirectory(filepath.Dir(defaultPath)).
+ SetFilename(filepath.Base(defaultPath)).
+ AddFilter("SQLite Database", "*.db").
+ PromptForSingleSelection()
}
func (a *App) InitializeDatabase(path string) (BootstrapStatus, error) {
a.mu.Lock()
@@ -122,7 +167,12 @@ func (a *App) SelectDirectory() (string, error) {
if e := a.ready(); e != nil {
return "", e
}
- return runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{Title: a.localized("选择代码目录", "Select code directory")})
+ return application.Get().Dialog.OpenFile().
+ SetTitle(a.localized("选择代码目录", "Select code directory")).
+ CanChooseDirectories(true).
+ CanChooseFiles(false).
+ CanCreateDirectories(true).
+ PromptForSingleSelection()
}
// ListWSLDistros 返回本机可用的 WSL 发行版,供前端创建 UNC 项目路径。
@@ -134,7 +184,12 @@ func (a *App) SelectWSLDirectory(distro string) (string, error) {
return "", errors.New("WSL_DISTRO_REQUIRED")
}
root := "\\\\wsl.localhost\\" + distro + "\\"
- return runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{Title: a.localized("选择 WSL 代码目录", "Select WSL code directory"), DefaultDirectory: root})
+ return application.Get().Dialog.OpenFile().
+ SetTitle(a.localized("选择 WSL 代码目录", "Select WSL code directory")).
+ SetDirectory(root).
+ CanChooseDirectories(true).
+ CanChooseFiles(false).
+ PromptForSingleSelection()
}
func (a *App) localized(zh, en string) string {
@@ -146,7 +201,11 @@ func (a *App) localized(zh, en string) string {
return zh
}
func (a *App) SelectDatabaseFile() (string, error) {
- return runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{Title: "Select database location", DefaultFilename: "code-count.db", Filters: []runtime.FileFilter{{DisplayName: "SQLite Database", Pattern: "*.db"}}})
+ return application.Get().Dialog.SaveFile().
+ SetMessage("Select database location").
+ SetFilename("code-count.db").
+ AddFilter("SQLite Database", "*.db").
+ PromptForSingleSelection()
}
func (a *App) ListProjects() ([]Project, error) {
if e := a.ready(); e != nil {
@@ -194,22 +253,14 @@ func (a *App) GetProject(id int64) (Project, error) {
}
func (a *App) SaveProject(id int64, in ProjectInput) (Project, error) {
if e := a.ready(); e != nil {
- if a.runtimeReady {
- runtime.LogError(a.ctx, "SaveProject: "+e.Error())
- }
return Project{}, e
}
p, e := a.store.SaveProject(id, in)
if e == nil {
a.store.Log("info", "项目", "项目保存成功", p.Path)
- if a.runtimeReady {
- runtime.LogInfo(a.ctx, "Project saved: "+p.Path)
- }
+ a.RefreshShell() // 托盘「打开项目」子菜单跟随项目列表
} else {
a.store.Log("error", "项目", "项目保存失败", in.Path+" | "+e.Error())
- if a.runtimeReady {
- runtime.LogError(a.ctx, "Project save failed: "+in.Path+" | "+e.Error())
- }
}
return p, e
}
@@ -218,9 +269,6 @@ func (a *App) ReportClientError(category, message, detail string) {
if a.store != nil {
a.store.Log("error", category, message, detail)
}
- if a.runtimeReady {
- runtime.LogError(a.ctx, category+": "+message+" | "+detail)
- }
}
func (a *App) DeleteProject(id int64) error {
if e := a.ready(); e != nil {
@@ -229,6 +277,7 @@ func (a *App) DeleteProject(id int64) error {
e := a.store.DeleteProject(id)
if e == nil {
a.store.Log("info", "项目", "项目删除成功", fmt.Sprint(id))
+ a.RefreshShell() // 托盘「打开项目」子菜单跟随项目列表
}
return e
}
@@ -318,6 +367,9 @@ func (a *App) GetGitDiagnostics(id int64) (GitDiagnostics, error) {
// GetGitStatsForRef 只改变统计视图,不执行 checkout。
func (a *App) GetGitStatsForRef(id int64, ref string) (GitStats, error) {
+ if e := a.ready(); e != nil {
+ return GitStats{}, e
+ }
p, e := a.store.GetProject(id)
if e != nil {
return GitStats{}, e
@@ -332,6 +384,9 @@ func (a *App) GetGitStatsForRef(id int64, ref string) (GitStats, error) {
// GetCommitDetails 按需查询提交涉及的文件,避免 Git 首页一次加载所有明细。
func (a *App) GetCommitDetails(id int64, hash string) (GitCommitDetail, error) {
+ if e := a.ready(); e != nil {
+ return GitCommitDetail{}, e
+ }
p, e := a.store.GetProject(id)
if e != nil {
return GitCommitDetail{}, e
@@ -341,6 +396,9 @@ func (a *App) GetCommitDetails(id int64, hash string) (GitCommitDetail, error) {
// CheckoutBranch 安全切换工作区分支;服务层会拒绝任何脏工作区。
func (a *App) CheckoutBranch(id int64, ref string) (CheckoutResult, error) {
+ if e := a.ready(); e != nil {
+ return CheckoutResult{}, e
+ }
p, e := a.store.GetProject(id)
if e != nil {
return CheckoutResult{}, e
@@ -377,6 +435,81 @@ func (a *App) GetLogs(level string) ([]LogEntry, error) {
}
return a.store.Logs(level)
}
+func (a *App) SearchLogs(query, level, category string, offset, limit int64) (LogPage, error) {
+ if e := a.ready(); e != nil {
+ return LogPage{}, e
+ }
+ return a.store.SearchLogs(query, level, category, offset, limit)
+}
+func (a *App) GetLogCategories() ([]string, error) {
+ if e := a.ready(); e != nil {
+ return nil, e
+ }
+ return a.store.LogCategories()
+}
+
+// previewMaxBytes 是文件预览读取上限(1MB),超出部分截断。
+const previewMaxBytes = 1 << 20
+
+// ReadProjectFile 读取项目内的相对路径文件用于预览:限制大小、检测二进制、阻止路径穿越。
+func (a *App) ReadProjectFile(projectID int64, relPath string) (FilePreview, error) {
+ if e := a.ready(); e != nil {
+ return FilePreview{}, e
+ }
+ p, e := a.store.GetProject(projectID)
+ if e != nil {
+ return FilePreview{}, e
+ }
+ rel := strings.TrimSpace(relPath)
+ if rel == "" {
+ return FilePreview{}, errors.New("FILE_PATH_REQUIRED")
+ }
+ desc, e := platform.ResolvePath(p.Path)
+ if e != nil {
+ return FilePreview{}, e
+ }
+ root := desc.WindowsPath
+ full := filepath.Join(root, filepath.FromSlash(rel))
+ if back, e := filepath.Rel(root, full); e != nil || back == ".." || strings.HasPrefix(back, ".."+string(filepath.Separator)) {
+ return FilePreview{}, errors.New("FILE_PATH_INVALID")
+ }
+ st, e := os.Stat(full)
+ if e != nil {
+ if os.IsNotExist(e) {
+ return FilePreview{}, coded("FILE_NOT_FOUND", e)
+ }
+ return FilePreview{}, coded("FILE_READ_FAILED", e)
+ }
+ if st.IsDir() {
+ return FilePreview{}, errors.New("FILE_IS_DIRECTORY")
+ }
+ out := FilePreview{Path: filepath.ToSlash(rel), Name: st.Name(), Extension: strings.ToLower(filepath.Ext(st.Name())), Size: st.Size()}
+ f, e := os.Open(full)
+ if e != nil {
+ return out, coded("FILE_READ_FAILED", e)
+ }
+ defer f.Close()
+ buf := make([]byte, min(st.Size(), previewMaxBytes))
+ n, e := io.ReadFull(f, buf)
+ if e != nil && !errors.Is(e, io.ErrUnexpectedEOF) && !errors.Is(e, io.EOF) {
+ return out, coded("FILE_READ_FAILED", e)
+ }
+ buf = buf[:n]
+ out.Truncated = st.Size() > previewMaxBytes
+ probe := buf
+ if len(probe) > 8192 {
+ probe = probe[:8192]
+ }
+ if bytes.IndexByte(probe, 0) >= 0 {
+ out.Binary = true
+ return out, nil
+ }
+ out.Content = string(buf)
+ if out.Content != "" {
+ out.Lines = strings.Count(out.Content, "\n") + 1
+ }
+ return out, nil
+}
func (a *App) ClearLogs() error {
if e := a.ready(); e != nil {
return e
@@ -393,13 +526,21 @@ func (a *App) SaveSettings(x AppSettings) error {
if e := a.ready(); e != nil {
return e
}
- return a.store.SaveSettings(x)
+ if e := a.store.SaveSettings(x); e != nil {
+ return e
+ }
+ a.RefreshShell()
+ return nil
}
func (a *App) ClearData(mode string, projectID int64) error {
if e := a.ready(); e != nil {
return e
}
- return a.store.ClearData(mode, projectID)
+ e := a.store.ClearData(mode, projectID)
+ if e == nil {
+ a.RefreshShell() // 托盘「打开项目」子菜单跟随项目列表
+ }
+ return e
}
func (a *App) MigrateDatabase(target string) error {
@@ -483,18 +624,23 @@ func (a *App) StartAnalysis(projectID int64, kind string) (string, error) {
a.tasks[taskID] = cancel
a.mu.Unlock()
go func() {
+ finalStage := "completed"
defer func() {
if recovered := recover(); recovered != nil {
detail := fmt.Sprintf("%v", recovered)
+ finalStage = "error"
a.store.Log("error", "代码分析", "后台分析异常", p.Name+" | "+detail)
- runtime.EventsEmit(a.ctx, "analysis:progress", TaskEvent{TaskID: taskID, ProjectID: projectID, Stage: "error", Progress: 100, MessageKey: "task.failed", Params: map[string]any{"project": p.Name}})
+ a.emit("analysis:progress", TaskEvent{TaskID: taskID, ProjectID: projectID, Stage: "error", Progress: 100, MessageKey: "task.failed", Params: map[string]any{"project": p.Name}})
}
a.mu.Lock()
+ if a.taskResults != nil {
+ a.taskResults[taskID] = finalStage
+ }
delete(a.tasks, taskID)
a.mu.Unlock()
}()
emit := func(stage string, progress int, messageKey string) {
- runtime.EventsEmit(a.ctx, "analysis:progress", TaskEvent{TaskID: taskID, ProjectID: projectID, Stage: stage, Progress: progress, MessageKey: messageKey, Params: map[string]any{"project": p.Name}})
+ a.emit("analysis:progress", TaskEvent{TaskID: taskID, ProjectID: projectID, Stage: stage, Progress: progress, MessageKey: messageKey, Params: map[string]any{"project": p.Name}})
}
emit("start", 2, "task.start")
a.store.Log("info", "代码分析", "开始分析项目", p.Name)
@@ -510,10 +656,23 @@ func (a *App) StartAnalysis(projectID int64, kind string) (string, error) {
}
if err == nil && (kind == "all" || kind == "git") {
emit("git", 80, "task.git")
+ scopeAll := false
+ if st, se := a.store.Settings(); se == nil && st.GitScope == "all" {
+ scopeAll = true
+ }
var g GitStats
- g, err = (GitAnalyzer{}).Analyze(ctx, p.Path)
+ var incremental bool
+ g, incremental, err = (GitAnalyzer{}).AnalyzeWithOptions(ctx, p.Path, service.GitAnalyzeOptions{
+ AllBranches: scopeAll,
+ SinceHash: a.store.LatestCommitHash(projectID),
+ })
if err == nil {
- err = a.store.ReplaceGit(projectID, g)
+ if incremental {
+ err = a.store.MergeGit(projectID, g)
+ a.store.Log("info", "Git分析", "增量分析完成", fmt.Sprintf("%s | 新增 %d 个提交", p.Name, len(g.Commits)))
+ } else {
+ err = a.store.ReplaceGit(projectID, g)
+ }
} else if kind == "all" && (err.Error() == "NOT_GIT_REPOSITORY" || err.Error() == "GIT_NOT_INSTALLED") {
a.store.Log("warning", "Git分析", "已跳过 Git 分析", err.Error())
err = nil
@@ -524,12 +683,29 @@ func (a *App) StartAnalysis(projectID int64, kind string) (string, error) {
if errors.Is(err, context.Canceled) {
level, stage = "warning", "cancelled"
}
+ finalStage = stage
a.store.Log(level, "代码分析", "项目分析失败", p.Name+" | "+err.Error())
emit(stage, 100, "task.failed")
return
}
a.store.Log("info", "代码分析", "项目分析完成", p.Name)
emit("completed", 100, "task.completed")
+ // 数据就绪后异步生成各模块的 AI 分析(未配置 Key 时静默跳过)。
+ // 代码数据变化会使洞察过期,先重算再交给 AI 解读。
+ switch kind {
+ case "code":
+ go func() {
+ a.RefreshProjectInsights(projectID)
+ a.generateAISummaries(projectID, "project", "structure", "insights")
+ }()
+ case "git":
+ go a.generateAISummaries(projectID, "git")
+ default:
+ go func() {
+ a.RefreshProjectInsights(projectID)
+ a.generateAISummaries(projectID, "project", "git", "structure", "insights")
+ }()
+ }
}()
return taskID, nil
}
@@ -546,6 +722,9 @@ func (a *App) StartBatchAnalysis() ([]string, error) {
return a.StartBatchAnalysisByGroup(0)
}
func (a *App) StartBatchAnalysisByGroup(groupID int64) ([]string, error) {
+ if e := a.ready(); e != nil {
+ return nil, e
+ }
ps, e := a.store.ListProjects(groupID)
if e != nil {
return nil, e
@@ -555,9 +734,12 @@ func (a *App) StartBatchAnalysisByGroup(groupID int64) ([]string, error) {
ids = append(ids, fmt.Sprintf("%d-all", p.ID))
}
go func() {
+ summary := BatchSummary{Total: len(ps), Failures: []string{}}
for _, p := range ps {
tid, e := a.StartAnalysis(p.ID, "all")
if e != nil {
+ summary.Failed++
+ summary.Failures = append(summary.Failures, p.Name+": "+e.Error())
continue
}
for {
@@ -573,7 +755,46 @@ func (a *App) StartBatchAnalysisByGroup(groupID int64) ([]string, error) {
case <-time.After(100 * time.Millisecond):
}
}
+ a.mu.Lock()
+ result := a.taskResults[tid]
+ delete(a.taskResults, tid)
+ a.mu.Unlock()
+ switch result {
+ case "cancelled":
+ summary.Cancelled++
+ case "error":
+ summary.Failed++
+ summary.Failures = append(summary.Failures, p.Name)
+ default:
+ summary.Completed++
+ }
}
+ a.store.Log("info", "代码分析", "批量统计完成",
+ fmt.Sprintf("共 %d 个 | 成功 %d | 失败 %d | 取消 %d | %s", summary.Total, summary.Completed, summary.Failed, summary.Cancelled, strings.Join(summary.Failures, ";")))
+ a.emit("batch:done", summary)
+ a.notifyBatchDone(summary)
}()
return ids, nil
}
+
+// notifyBatchDone 将批量统计结果写入消息中心,失败时附系统通知。
+func (a *App) notifyBatchDone(s BatchSummary) {
+ locale := "zh-CN"
+ if st, e := a.store.Settings(); e == nil {
+ locale = st.Locale
+ }
+ title := "批量统计完成"
+ body := fmt.Sprintf("共 %d 个项目:成功 %d,失败 %d,取消 %d", s.Total, s.Completed, s.Failed, s.Cancelled)
+ if locale == "en" {
+ title = "Batch analysis finished"
+ body = fmt.Sprintf("%d projects: %d succeeded, %d failed, %d cancelled", s.Total, s.Completed, s.Failed, s.Cancelled)
+ }
+ if len(s.Failures) > 0 {
+ sep := ";失败:"
+ if locale == "en" {
+ sep = "; failed: "
+ }
+ body += sep + strings.Join(s.Failures, ", ")
+ }
+ a.pushMessage("analysis", title, body, "", 0, s.Failed > 0)
+}
diff --git a/build/README.md b/build/README.md
deleted file mode 100644
index 1ae2f67..0000000
--- a/build/README.md
+++ /dev/null
@@ -1,35 +0,0 @@
-# Build Directory
-
-The build directory is used to house all the build files and assets for your application.
-
-The structure is:
-
-* bin - Output directory
-* darwin - macOS specific files
-* windows - Windows specific files
-
-## Mac
-
-The `darwin` directory holds files specific to Mac builds.
-These may be customised and used as part of the build. To return these files to the default state, simply delete them
-and
-build with `wails build`.
-
-The directory contains the following files:
-
-- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`.
-- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`.
-
-## Windows
-
-The `windows` directory contains the manifest and rc files used when building with `wails build`.
-These may be customised for your application. To return these files to the default state, simply delete them and
-build with `wails build`.
-
-- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to
- use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file
- will be created using the `appicon.png` file in the build directory.
-- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`.
-- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer,
- as well as the application itself (right click the exe -> properties -> details)
-- `wails.exe.manifest` - The main application manifest file.
\ No newline at end of file
diff --git a/build/appicon.png b/build/appicon.png
index 63617fe..c12c325 100644
Binary files a/build/appicon.png and b/build/appicon.png differ
diff --git a/build/darwin/Info.dev.plist b/build/darwin/Info.dev.plist
index 14121ef..eef67e3 100644
--- a/build/darwin/Info.dev.plist
+++ b/build/darwin/Info.dev.plist
@@ -2,67 +2,33 @@
CFBundlePackageType
- APPL
+ APPL
CFBundleName
- {{.Info.ProductName}}
+ My Product
CFBundleExecutable
- {{.OutputFilename}}
+ code-count.exe
CFBundleIdentifier
- com.wails.{{.Name}}
+ com.wails.code-count
CFBundleVersion
- {{.Info.ProductVersion}}
+ 0.1.0
CFBundleGetInfoString
- {{.Info.Comments}}
+ 本地代码统计与项目工作台
CFBundleShortVersionString
- {{.Info.ProductVersion}}
+ 0.1.0
CFBundleIconFile
- iconfile
+ icons
+ CFBundleIconName
+ appicon
LSMinimumSystemVersion
- 10.13.0
+ 12.0.0
NSHighResolutionCapable
- true
+ true
NSHumanReadableCopyright
- {{.Info.Copyright}}
- {{if .Info.FileAssociations}}
- CFBundleDocumentTypes
-
- {{range .Info.FileAssociations}}
-
- CFBundleTypeExtensions
-
- {{.Ext}}
-
- CFBundleTypeName
- {{.Name}}
- CFBundleTypeRole
- {{.Role}}
- CFBundleTypeIconFile
- {{.IconName}}
-
- {{end}}
-
- {{end}}
- {{if .Info.Protocols}}
- CFBundleURLTypes
-
- {{range .Info.Protocols}}
-
- CFBundleURLName
- com.wails.{{.Scheme}}
- CFBundleURLSchemes
-
- {{.Scheme}}
-
- CFBundleTypeRole
- {{.Role}}
-
- {{end}}
-
- {{end}}
+ © now, My Company
NSAppTransportSecurity
NSAllowsLocalNetworking
-
+
\ No newline at end of file
diff --git a/build/darwin/Info.plist b/build/darwin/Info.plist
index d17a747..b347a77 100644
--- a/build/darwin/Info.plist
+++ b/build/darwin/Info.plist
@@ -2,62 +2,28 @@
CFBundlePackageType
- APPL
+ APPL
CFBundleName
- {{.Info.ProductName}}
+ My Product
CFBundleExecutable
- {{.OutputFilename}}
+ code-count.exe
CFBundleIdentifier
- com.wails.{{.Name}}
+ com.wails.code-count
CFBundleVersion
- {{.Info.ProductVersion}}
+ 0.1.0
CFBundleGetInfoString
- {{.Info.Comments}}
+ 本地代码统计与项目工作台
CFBundleShortVersionString
- {{.Info.ProductVersion}}
+ 0.1.0
CFBundleIconFile
- iconfile
+ icons
+ CFBundleIconName
+ appicon
LSMinimumSystemVersion
- 10.13.0
+ 12.0.0
NSHighResolutionCapable
- true
+ true
NSHumanReadableCopyright
- {{.Info.Copyright}}
- {{if .Info.FileAssociations}}
- CFBundleDocumentTypes
-
- {{range .Info.FileAssociations}}
-
- CFBundleTypeExtensions
-
- {{.Ext}}
-
- CFBundleTypeName
- {{.Name}}
- CFBundleTypeRole
- {{.Role}}
- CFBundleTypeIconFile
- {{.IconName}}
-
- {{end}}
-
- {{end}}
- {{if .Info.Protocols}}
- CFBundleURLTypes
-
- {{range .Info.Protocols}}
-
- CFBundleURLName
- com.wails.{{.Scheme}}
- CFBundleURLSchemes
-
- {{.Scheme}}
-
- CFBundleTypeRole
- {{.Role}}
-
- {{end}}
-
- {{end}}
+ © now, My Company
-
+
\ No newline at end of file
diff --git a/build/windows/icon.ico b/build/windows/icon.ico
index f334798..d5da220 100644
Binary files a/build/windows/icon.ico and b/build/windows/icon.ico differ
diff --git a/build/windows/info.json b/build/windows/info.json
index 9727946..d336bef 100644
--- a/build/windows/info.json
+++ b/build/windows/info.json
@@ -1,15 +1,15 @@
{
"fixed": {
- "file_version": "{{.Info.ProductVersion}}"
+ "file_version": "0.1.0"
},
"info": {
"0000": {
- "ProductVersion": "{{.Info.ProductVersion}}",
- "CompanyName": "{{.Info.CompanyName}}",
- "FileDescription": "{{.Info.ProductName}}",
- "LegalCopyright": "{{.Info.Copyright}}",
- "ProductName": "{{.Info.ProductName}}",
- "Comments": "{{.Info.Comments}}"
+ "ProductVersion": "0.1.0",
+ "CompanyName": "liqi",
+ "FileDescription": "My Product Description",
+ "LegalCopyright": "© now, My Company",
+ "ProductName": "My Product",
+ "Comments": "本地代码统计与项目工作台"
}
}
}
\ No newline at end of file
diff --git a/build/windows/installer/project.nsi b/build/windows/installer/project.nsi
deleted file mode 100644
index 654ae2e..0000000
--- a/build/windows/installer/project.nsi
+++ /dev/null
@@ -1,114 +0,0 @@
-Unicode true
-
-####
-## Please note: Template replacements don't work in this file. They are provided with default defines like
-## mentioned underneath.
-## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo.
-## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually
-## from outside of Wails for debugging and development of the installer.
-##
-## For development first make a wails nsis build to populate the "wails_tools.nsh":
-## > wails build --target windows/amd64 --nsis
-## Then you can call makensis on this file with specifying the path to your binary:
-## For a AMD64 only installer:
-## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
-## For a ARM64 only installer:
-## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
-## For a installer with both architectures:
-## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
-####
-## The following information is taken from the ProjectInfo file, but they can be overwritten here.
-####
-## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}"
-## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}"
-## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}"
-## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}"
-## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}"
-###
-## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
-## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
-####
-## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
-####
-## Include the wails tools
-####
-!include "wails_tools.nsh"
-
-# The version information for this two must consist of 4 parts
-VIProductVersion "${INFO_PRODUCTVERSION}.0"
-VIFileVersion "${INFO_PRODUCTVERSION}.0"
-
-VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
-VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
-VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
-VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
-VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
-VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
-
-# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
-ManifestDPIAware true
-
-!include "MUI.nsh"
-
-!define MUI_ICON "..\icon.ico"
-!define MUI_UNICON "..\icon.ico"
-# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
-!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
-!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
-
-!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
-# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
-!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
-!insertmacro MUI_PAGE_INSTFILES # Installing page.
-!insertmacro MUI_PAGE_FINISH # Finished installation page.
-
-!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page
-
-!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
-
-## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
-#!uninstfinalize 'signtool --file "%1"'
-#!finalize 'signtool --file "%1"'
-
-Name "${INFO_PRODUCTNAME}"
-OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
-InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
-ShowInstDetails show # This will always show the installation details.
-
-Function .onInit
- !insertmacro wails.checkArchitecture
-FunctionEnd
-
-Section
- !insertmacro wails.setShellContext
-
- !insertmacro wails.webview2runtime
-
- SetOutPath $INSTDIR
-
- !insertmacro wails.files
-
- CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
- CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
-
- !insertmacro wails.associateFiles
- !insertmacro wails.associateCustomProtocols
-
- !insertmacro wails.writeUninstaller
-SectionEnd
-
-Section "uninstall"
- !insertmacro wails.setShellContext
-
- RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
-
- RMDir /r $INSTDIR
-
- Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
- Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
-
- !insertmacro wails.unassociateFiles
- !insertmacro wails.unassociateCustomProtocols
-
- !insertmacro wails.deleteUninstaller
-SectionEnd
diff --git a/build/windows/installer/wails_tools.nsh b/build/windows/installer/wails_tools.nsh
deleted file mode 100644
index 2f6d321..0000000
--- a/build/windows/installer/wails_tools.nsh
+++ /dev/null
@@ -1,249 +0,0 @@
-# DO NOT EDIT - Generated automatically by `wails build`
-
-!include "x64.nsh"
-!include "WinVer.nsh"
-!include "FileFunc.nsh"
-
-!ifndef INFO_PROJECTNAME
- !define INFO_PROJECTNAME "{{.Name}}"
-!endif
-!ifndef INFO_COMPANYNAME
- !define INFO_COMPANYNAME "{{.Info.CompanyName}}"
-!endif
-!ifndef INFO_PRODUCTNAME
- !define INFO_PRODUCTNAME "{{.Info.ProductName}}"
-!endif
-!ifndef INFO_PRODUCTVERSION
- !define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}"
-!endif
-!ifndef INFO_COPYRIGHT
- !define INFO_COPYRIGHT "{{.Info.Copyright}}"
-!endif
-!ifndef PRODUCT_EXECUTABLE
- !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
-!endif
-!ifndef UNINST_KEY_NAME
- !define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
-!endif
-!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
-
-!ifndef REQUEST_EXECUTION_LEVEL
- !define REQUEST_EXECUTION_LEVEL "admin"
-!endif
-
-RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
-
-!ifdef ARG_WAILS_AMD64_BINARY
- !define SUPPORTS_AMD64
-!endif
-
-!ifdef ARG_WAILS_ARM64_BINARY
- !define SUPPORTS_ARM64
-!endif
-
-!ifdef SUPPORTS_AMD64
- !ifdef SUPPORTS_ARM64
- !define ARCH "amd64_arm64"
- !else
- !define ARCH "amd64"
- !endif
-!else
- !ifdef SUPPORTS_ARM64
- !define ARCH "arm64"
- !else
- !error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
- !endif
-!endif
-
-!macro wails.checkArchitecture
- !ifndef WAILS_WIN10_REQUIRED
- !define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
- !endif
-
- !ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
- !define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
- !endif
-
- ${If} ${AtLeastWin10}
- !ifdef SUPPORTS_AMD64
- ${if} ${IsNativeAMD64}
- Goto ok
- ${EndIf}
- !endif
-
- !ifdef SUPPORTS_ARM64
- ${if} ${IsNativeARM64}
- Goto ok
- ${EndIf}
- !endif
-
- IfSilent silentArch notSilentArch
- silentArch:
- SetErrorLevel 65
- Abort
- notSilentArch:
- MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
- Quit
- ${else}
- IfSilent silentWin notSilentWin
- silentWin:
- SetErrorLevel 64
- Abort
- notSilentWin:
- MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
- Quit
- ${EndIf}
-
- ok:
-!macroend
-
-!macro wails.files
- !ifdef SUPPORTS_AMD64
- ${if} ${IsNativeAMD64}
- File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
- ${EndIf}
- !endif
-
- !ifdef SUPPORTS_ARM64
- ${if} ${IsNativeARM64}
- File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
- ${EndIf}
- !endif
-!macroend
-
-!macro wails.writeUninstaller
- WriteUninstaller "$INSTDIR\uninstall.exe"
-
- SetRegView 64
- WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
- WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
- WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
- WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
- WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
- WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
-
- ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
- IntFmt $0 "0x%08X" $0
- WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
-!macroend
-
-!macro wails.deleteUninstaller
- Delete "$INSTDIR\uninstall.exe"
-
- SetRegView 64
- DeleteRegKey HKLM "${UNINST_KEY}"
-!macroend
-
-!macro wails.setShellContext
- ${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
- SetShellVarContext all
- ${else}
- SetShellVarContext current
- ${EndIf}
-!macroend
-
-# Install webview2 by launching the bootstrapper
-# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
-!macro wails.webview2runtime
- !ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
- !define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
- !endif
-
- SetRegView 64
- # If the admin key exists and is not empty then webview2 is already installed
- ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
- ${If} $0 != ""
- Goto ok
- ${EndIf}
-
- ${If} ${REQUEST_EXECUTION_LEVEL} == "user"
- # If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
- ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
- ${If} $0 != ""
- Goto ok
- ${EndIf}
- ${EndIf}
-
- SetDetailsPrint both
- DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
- SetDetailsPrint listonly
-
- InitPluginsDir
- CreateDirectory "$pluginsdir\webview2bootstrapper"
- SetOutPath "$pluginsdir\webview2bootstrapper"
- File "tmp\MicrosoftEdgeWebview2Setup.exe"
- ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
-
- SetDetailsPrint both
- ok:
-!macroend
-
-# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
-!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
- ; Backup the previously associated file class
- ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
- WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
-
- WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
-
- WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
- WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
- WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
- WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
- WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
-!macroend
-
-!macro APP_UNASSOCIATE EXT FILECLASS
- ; Backup the previously associated file class
- ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
- WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
-
- DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
-!macroend
-
-!macro wails.associateFiles
- ; Create file associations
- {{range .Info.FileAssociations}}
- !insertmacro APP_ASSOCIATE "{{.Ext}}" "{{.Name}}" "{{.Description}}" "$INSTDIR\{{.IconName}}.ico" "Open with ${INFO_PRODUCTNAME}" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
-
- File "..\{{.IconName}}.ico"
- {{end}}
-!macroend
-
-!macro wails.unassociateFiles
- ; Delete app associations
- {{range .Info.FileAssociations}}
- !insertmacro APP_UNASSOCIATE "{{.Ext}}" "{{.Name}}"
-
- Delete "$INSTDIR\{{.IconName}}.ico"
- {{end}}
-!macroend
-
-!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
- DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
- WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
- WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
- WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
- WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
- WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
- WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
-!macroend
-
-!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
- DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
-!macroend
-
-!macro wails.associateCustomProtocols
- ; Create custom protocols associations
- {{range .Info.Protocols}}
- !insertmacro CUSTOM_PROTOCOL_ASSOCIATE "{{.Scheme}}" "{{.Description}}" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
-
- {{end}}
-!macroend
-
-!macro wails.unassociateCustomProtocols
- ; Delete app custom protocol associations
- {{range .Info.Protocols}}
- !insertmacro CUSTOM_PROTOCOL_UNASSOCIATE "{{.Scheme}}"
- {{end}}
-!macroend
diff --git a/build/windows/wails.exe.manifest b/build/windows/wails.exe.manifest
index 17e1a23..3754c6b 100644
--- a/build/windows/wails.exe.manifest
+++ b/build/windows/wails.exe.manifest
@@ -1,6 +1,6 @@
-
+
@@ -12,4 +12,11 @@
permonitorv2,permonitor
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/database.go b/database.go
index 4299a5b..8cebdd5 100644
--- a/database.go
+++ b/database.go
@@ -58,9 +58,32 @@ func OpenStore(path string) (*Store, error) {
return s, nil
}
+// schemaVersion 是当前本地库结构版本;仅当已记录版本低于它时才执行迁移语句,
+// 避免应用每次启动都重复执行建表/回填(版本一致时启动零迁移)。
+// v3:新增 festival_images(节日背景图本地缓存)。
+// v4:todos/tickets 新增 history 生命周期轨迹列。
+// v5:新增 ai_summaries(分析完成后 AI 生成的模块介绍)。
+// v6:新增 day_briefs(工作台 AI 今日规划 / 下班日报)。
+// v7:新增 launch_apps(启动台保存的应用与启停命令)。
+// v8:todos/tickets 新增 team_id 团队共享列。
+const schemaVersion = 8
+
func (s *Store) migrate() error {
+ // PRAGMA 是连接级/文件级配置,不属于迁移,每次打开都需执行。
+ for _, q := range []string{`PRAGMA journal_mode=WAL`, `PRAGMA foreign_keys=ON`} {
+ if _, err := s.db.Exec(q); err != nil {
+ return fmt.Errorf("database migration: %w", err)
+ }
+ }
+ // 历史数据修正:早期同步日志把警告级别写成了 "warn",统一为 "warning"。
+ // 全新数据库此时表还不存在,忽略错误即可。
+ _, _ = s.db.Exec(`UPDATE app_logs SET level='warning' WHERE level='warn'`)
+ var applied int
+ _ = s.db.QueryRow(`SELECT COALESCE(MAX(version),0) FROM schema_migrations`).Scan(&applied)
+ if applied >= schemaVersion {
+ return nil
+ }
stmts := []string{
- `PRAGMA journal_mode=WAL`, `PRAGMA foreign_keys=ON`,
`CREATE TABLE IF NOT EXISTS schema_migrations(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS project_groups(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`,
@@ -70,12 +93,28 @@ func (s *Store) migrate() error {
`CREATE TABLE IF NOT EXISTS file_entries(project_id INTEGER NOT NULL, path TEXT NOT NULL, name TEXT NOT NULL, extension TEXT NOT NULL, size INTEGER NOT NULL, is_dir INTEGER NOT NULL, parent TEXT NOT NULL, PRIMARY KEY(project_id,path), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS git_commits(project_id INTEGER NOT NULL, hash TEXT NOT NULL, author TEXT NOT NULL, email TEXT NOT NULL, message TEXT NOT NULL, committed_at TEXT NOT NULL, added INTEGER NOT NULL, deleted INTEGER NOT NULL, PRIMARY KEY(project_id,hash), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS git_refs(project_id INTEGER NOT NULL, name TEXT NOT NULL, hash TEXT NOT NULL, kind TEXT NOT NULL, is_current INTEGER NOT NULL, PRIMARY KEY(project_id,name), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
- `CREATE TABLE IF NOT EXISTS commit_refs(project_id INTEGER NOT NULL, commit_hash TEXT NOT NULL, ref_name TEXT NOT NULL, PRIMARY KEY(project_id,commit_hash,ref_name), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
+ `DROP TABLE IF EXISTS commit_refs`,
`CREATE TABLE IF NOT EXISTS contributors(project_id INTEGER NOT NULL, email TEXT NOT NULL, name TEXT NOT NULL, commits INTEGER NOT NULL, added INTEGER NOT NULL, deleted INTEGER NOT NULL, PRIMARY KEY(project_id,email), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS project_insights(project_id INTEGER PRIMARY KEY, health_score INTEGER NOT NULL, generated_at TEXT NOT NULL, high INTEGER NOT NULL, medium INTEGER NOT NULL, low INTEGER NOT NULL, todo_count INTEGER NOT NULL, long_files INTEGER NOT NULL, large_files INTEGER NOT NULL, FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS insight_issues(project_id INTEGER NOT NULL, idx INTEGER NOT NULL, severity TEXT NOT NULL, type TEXT NOT NULL, title TEXT NOT NULL, detail TEXT NOT NULL, path TEXT NOT NULL DEFAULT '', line INTEGER NOT NULL DEFAULT 0, suggestion TEXT NOT NULL DEFAULT '', evidence TEXT NOT NULL DEFAULT '', PRIMARY KEY(project_id,idx), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS exclusion_rules(id INTEGER PRIMARY KEY AUTOINCREMENT, pattern TEXT NOT NULL UNIQUE, category TEXT NOT NULL, builtin INTEGER NOT NULL DEFAULT 0)`,
`CREATE TABLE IF NOT EXISTS app_logs(id INTEGER PRIMARY KEY AUTOINCREMENT, level TEXT NOT NULL, category TEXT NOT NULL, message TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL)`,
+ `CREATE TABLE IF NOT EXISTS git_hotspots(project_id INTEGER NOT NULL, path TEXT NOT NULL, changes INTEGER NOT NULL, added INTEGER NOT NULL, deleted INTEGER NOT NULL, PRIMARY KEY(project_id,path), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
+ `CREATE TABLE IF NOT EXISTS todos(id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, title TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', project_id INTEGER NOT NULL DEFAULT 0, due_at TEXT NOT NULL DEFAULT '', priority TEXT NOT NULL DEFAULT 'medium', status TEXT NOT NULL DEFAULT 'open', reminded INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, dirty INTEGER NOT NULL DEFAULT 1, history TEXT NOT NULL DEFAULT '', team_id INTEGER NOT NULL DEFAULT 0)`,
+ `CREATE TABLE IF NOT EXISTS tickets(id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', type TEXT NOT NULL DEFAULT 'task', project_id INTEGER NOT NULL, start_at TEXT NOT NULL, due_at TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', priority TEXT NOT NULL DEFAULT 'medium', reminded INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, dirty INTEGER NOT NULL DEFAULT 1, history TEXT NOT NULL DEFAULT '', team_id INTEGER NOT NULL DEFAULT 0)`,
+ `CREATE TABLE IF NOT EXISTS notes(id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, content TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, dirty INTEGER NOT NULL DEFAULT 1)`,
+ `CREATE TABLE IF NOT EXISTS favorites(project_id INTEGER PRIMARY KEY, created_at TEXT NOT NULL)`,
+ `CREATE TABLE IF NOT EXISTS messages(id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', source_type TEXT NOT NULL DEFAULT '', source_id INTEGER NOT NULL DEFAULT 0, is_read INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL)`,
+ `CREATE TABLE IF NOT EXISTS ai_conversations(id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER NOT NULL DEFAULT 0, provider TEXT NOT NULL DEFAULT '', title TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`,
+ `CREATE TABLE IF NOT EXISTS ai_messages(id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id INTEGER NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL)`,
+ `CREATE TABLE IF NOT EXISTS festival_images(fest_key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '', dirty INTEGER NOT NULL DEFAULT 0)`,
+ `CREATE TABLE IF NOT EXISTS ai_summaries(project_id INTEGER NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', provider TEXT NOT NULL DEFAULT '', generated_at TEXT NOT NULL, PRIMARY KEY(project_id,kind), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
+ `CREATE TABLE IF NOT EXISTS day_briefs(kind TEXT PRIMARY KEY, content TEXT NOT NULL DEFAULT '', provider TEXT NOT NULL DEFAULT '', generated_at TEXT NOT NULL)`,
+ `CREATE TABLE IF NOT EXISTS launch_apps(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'other', port INTEGER NOT NULL DEFAULT 0, dir TEXT NOT NULL DEFAULT '', start_cmd TEXT NOT NULL DEFAULT '', stop_cmd TEXT NOT NULL DEFAULT '', last_pid INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`,
+ `CREATE INDEX IF NOT EXISTS idx_ai_messages_conv ON ai_messages(conversation_id)`,
+ `CREATE INDEX IF NOT EXISTS idx_messages_read ON messages(is_read)`,
+ `CREATE INDEX IF NOT EXISTS idx_todos_status ON todos(deleted,status)`,
+ `CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(deleted,status)`,
}
for _, q := range stmts {
if _, err := s.db.Exec(q); err != nil {
@@ -89,6 +128,28 @@ func (s *Store) migrate() error {
return fmt.Errorf("database migration: %w", err)
}
}
+ // 待办/工单生命周期:history 存 JSON 状态轨迹(进入某状态的时间点)。
+ for _, table := range []string{"todos", "tickets"} {
+ if ok, err := s.columnExists(table, "history"); err != nil {
+ return err
+ } else if !ok {
+ if _, err = s.db.Exec(`ALTER TABLE ` + table + ` ADD COLUMN history TEXT NOT NULL DEFAULT ''`); err != nil {
+ return fmt.Errorf("database migration: %w", err)
+ }
+ // 存量数据补一个创建节点,保证时间线至少有起点。
+ _, _ = s.db.Exec(`UPDATE ` + table + ` SET history='[{"status":"open","at":"'||created_at||'"}]' WHERE history=''`)
+ }
+ }
+ // 团队共享:team_id>0 表示该条对团队管理员可见(0 私密)。
+ for _, table := range []string{"todos", "tickets"} {
+ if ok, err := s.columnExists(table, "team_id"); err != nil {
+ return err
+ } else if !ok {
+ if _, err = s.db.Exec(`ALTER TABLE ` + table + ` ADD COLUMN team_id INTEGER NOT NULL DEFAULT 0`); err != nil {
+ return fmt.Errorf("database migration: %w", err)
+ }
+ }
+ }
_, _ = s.db.Exec(`INSERT OR IGNORE INTO project_groups(id,name,created_at,updated_at) VALUES(1,'My Projects',?,?)`, time.Now().Format(time.RFC3339), time.Now().Format(time.RFC3339))
_, _ = s.db.Exec(`UPDATE projects SET group_id=1 WHERE group_id IS NULL OR group_id=0`)
defaults := map[string][]string{"general": {".git", ".idea", ".vscode", "node_modules", "dist", "coverage", "*.min.js", "*.min.css", ".DS_Store"}, "php": {"vendor", "storage/framework", "bootstrap/cache", ".phpunit.cache"}, "go": {"go.sum"}, "vue": {".nuxt", ".next", ".output", "unpackage"}}
@@ -99,7 +160,7 @@ func (s *Store) migrate() error {
}
_, _ = s.db.Exec(`INSERT OR IGNORE INTO settings(key,value) VALUES('theme','dark'),('locale','zh-CN'),('gitScope','current'),('autoRefresh','true'),('glassOpacity','55'),('loadingStyle','fullscreen-orbit')`)
_, _ = s.db.Exec(`UPDATE settings SET value='fullscreen-orbit' WHERE key='loadingStyle' AND value IN ('bar','fullscreen') AND NOT EXISTS(SELECT 1 FROM settings WHERE key='loadingStyleSet' AND value='true')`)
- _, _ = s.db.Exec(`INSERT OR IGNORE INTO schema_migrations(version,applied_at) VALUES(1,?)`, time.Now().Format(time.RFC3339))
+ _, _ = s.db.Exec(`INSERT OR IGNORE INTO schema_migrations(version,applied_at) VALUES(?,?)`, schemaVersion, time.Now().Format(time.RFC3339))
// 旧版本曾写入英文日志;迁移时转换已知固定文案,Git 提交消息等用户内容不做修改。
translations := map[string]string{"Application started": "应用程序启动", "Desktop runtime connected": "桌面运行时已连接", "Project saved": "项目保存成功", "Project save failed": "项目保存失败", "Project deleted": "项目删除成功", "Analysis completed": "项目分析完成", "Analysis failed": "项目分析失败", "Database initialized": "数据库初始化成功", "Database migrated": "数据库迁移成功"}
for en, zh := range translations {
@@ -343,14 +404,62 @@ func (s *Store) languages(id int64) ([]LanguageStat, error) {
}
func (s *Store) Dashboard(groupID int64) (Dashboard, error) {
var d Dashboard
+ var e error
if groupID > 0 {
- e := s.db.QueryRow(`SELECT COUNT(*),COALESCE((SELECT SUM(ls.code+ls.comments+ls.blanks) FROM language_stats ls JOIN projects p ON p.id=ls.project_id WHERE p.group_id=?),0),COALESCE((SELECT COUNT(*) FROM git_commits gc JOIN projects p ON p.id=gc.project_id WHERE p.group_id=?),0) FROM projects WHERE group_id=?`, groupID, groupID, groupID).Scan(&d.Projects, &d.TotalLines, &d.Commits)
+ e = s.db.QueryRow(`SELECT COUNT(*),
+ COALESCE((SELECT SUM(ls.code+ls.comments+ls.blanks) FROM language_stats ls JOIN projects p ON p.id=ls.project_id WHERE p.group_id=?),0),
+ COALESCE((SELECT SUM(ls.code) FROM language_stats ls JOIN projects p ON p.id=ls.project_id WHERE p.group_id=?),0),
+ COALESCE((SELECT SUM(ls.comments) FROM language_stats ls JOIN projects p ON p.id=ls.project_id WHERE p.group_id=?),0),
+ COALESCE((SELECT SUM(ls.blanks) FROM language_stats ls JOIN projects p ON p.id=ls.project_id WHERE p.group_id=?),0),
+ COALESCE((SELECT SUM(ls.files) FROM language_stats ls JOIN projects p ON p.id=ls.project_id WHERE p.group_id=?),0),
+ COALESCE((SELECT COUNT(*) FROM git_commits gc JOIN projects p ON p.id=gc.project_id WHERE p.group_id=?),0),
+ COALESCE((SELECT COUNT(DISTINCT gc.email) FROM git_commits gc JOIN projects p ON p.id=gc.project_id WHERE p.group_id=?),0)
+ FROM projects WHERE group_id=?`, groupID, groupID, groupID, groupID, groupID, groupID, groupID, groupID).
+ Scan(&d.Projects, &d.TotalLines, &d.CodeLines, &d.CommentLines, &d.BlankLines, &d.FileCount, &d.Commits, &d.Contributors)
+ } else {
+ e = s.db.QueryRow(`SELECT COUNT(*),
+ COALESCE((SELECT SUM(code+comments+blanks) FROM language_stats),0),
+ COALESCE((SELECT SUM(code) FROM language_stats),0),
+ COALESCE((SELECT SUM(comments) FROM language_stats),0),
+ COALESCE((SELECT SUM(blanks) FROM language_stats),0),
+ COALESCE((SELECT SUM(files) FROM language_stats),0),
+ COALESCE((SELECT COUNT(*) FROM git_commits),0),
+ COALESCE((SELECT COUNT(DISTINCT email) FROM git_commits),0)
+ FROM projects`).
+ Scan(&d.Projects, &d.TotalLines, &d.CodeLines, &d.CommentLines, &d.BlankLines, &d.FileCount, &d.Commits, &d.Contributors)
+ }
+ if e != nil {
return d, e
}
- e := s.db.QueryRow(`SELECT COUNT(*),COALESCE((SELECT SUM(code+comments+blanks) FROM language_stats),0),COALESCE((SELECT COUNT(*) FROM git_commits),0) FROM projects`).Scan(&d.Projects, &d.TotalLines, &d.Commits)
+ d.Languages, e = s.AggregateLanguages(groupID)
return d, e
}
+// AggregateLanguages 跨项目聚合语言分布;groupID>0 时仅统计该项目组。
+func (s *Store) AggregateLanguages(groupID int64) ([]LanguageStat, error) {
+ q := `SELECT ls.name,SUM(ls.files),SUM(ls.code),SUM(ls.comments),SUM(ls.blanks) FROM language_stats ls`
+ args := []any{}
+ if groupID > 0 {
+ q += ` JOIN projects p ON p.id=ls.project_id WHERE p.group_id=?`
+ args = append(args, groupID)
+ }
+ q += ` GROUP BY ls.name ORDER BY SUM(ls.code) DESC`
+ r, e := s.db.Query(q, args...)
+ if e != nil {
+ return nil, e
+ }
+ defer r.Close()
+ out := []LanguageStat{}
+ for r.Next() {
+ var x LanguageStat
+ if e = r.Scan(&x.Name, &x.Files, &x.Code, &x.Comments, &x.Blanks); e != nil {
+ return nil, e
+ }
+ out = append(out, x)
+ }
+ return out, r.Err()
+}
+
func (s *Store) ReplaceScan(id int64, langs []LanguageStat, files []FileEntry) error {
tx, e := s.db.Begin()
if e != nil {
@@ -487,6 +596,9 @@ func (s *Store) DeleteRule(id int64) error {
return nil
}
func (s *Store) Log(level, category, message, detail string) {
+ if level == "warn" {
+ level = "warning" // UI 与统计只认 info/warning/error
+ }
_, _ = s.db.Exec(`INSERT INTO app_logs(level,category,message,detail,created_at) VALUES(?,?,?,?,?)`, level, category, message, detail, now())
}
func (s *Store) Logs(level string) ([]LogEntry, error) {
@@ -513,8 +625,90 @@ func (s *Store) Logs(level string) ([]LogEntry, error) {
return o, r.Err()
}
func (s *Store) ClearLogs() error { _, e := s.db.Exec(`DELETE FROM app_logs`); return e }
+
+// SearchLogs 支持关键词(消息/详情/分类)、级别、分类过滤和分页。
+// 计数按当前关键词+分类统计(不含级别),供前端标签页展示。
+func (s *Store) SearchLogs(query, level, category string, offset, limit int64) (LogPage, error) {
+ page := LogPage{Items: []LogEntry{}}
+ where, args := []string{}, []any{}
+ if q := strings.TrimSpace(query); q != "" {
+ where = append(where, `(message LIKE ? OR detail LIKE ? OR category LIKE ?)`)
+ like := "%" + q + "%"
+ args = append(args, like, like, like)
+ }
+ if category != "" && category != "all" {
+ where = append(where, `category=?`)
+ args = append(args, category)
+ }
+ base := ""
+ if len(where) > 0 {
+ base = " WHERE " + strings.Join(where, " AND ")
+ }
+ e := s.db.QueryRow(`SELECT COUNT(*),COALESCE(SUM(level='info'),0),COALESCE(SUM(level='warning'),0),COALESCE(SUM(level='error'),0) FROM app_logs`+base, args...).
+ Scan(&page.Total, &page.Info, &page.Warning, &page.Error)
+ if e != nil {
+ return page, e
+ }
+ itemWhere, itemArgs := where, args
+ if level != "" && level != "all" {
+ itemWhere = append(append([]string{}, where...), `level=?`)
+ itemArgs = append(append([]any{}, args...), level)
+ switch level {
+ case "info":
+ page.Total = page.Info
+ case "warning":
+ page.Total = page.Warning
+ case "error":
+ page.Total = page.Error
+ }
+ }
+ q := `SELECT id,level,category,message,detail,created_at FROM app_logs`
+ if len(itemWhere) > 0 {
+ q += ` WHERE ` + strings.Join(itemWhere, " AND ")
+ }
+ if limit <= 0 || limit > 200 {
+ limit = 50
+ }
+ if offset < 0 {
+ offset = 0
+ }
+ q += ` ORDER BY id DESC LIMIT ? OFFSET ?`
+ itemArgs = append(itemArgs, limit, offset)
+ r, e := s.db.Query(q, itemArgs...)
+ if e != nil {
+ return page, e
+ }
+ defer r.Close()
+ for r.Next() {
+ var x LogEntry
+ if e = r.Scan(&x.ID, &x.Level, &x.Category, &x.Message, &x.Detail, &x.CreatedAt); e != nil {
+ return page, e
+ }
+ page.Items = append(page.Items, x)
+ }
+ return page, r.Err()
+}
+
+// LogCategories 返回日志中出现过的分类,供筛选下拉框使用。
+func (s *Store) LogCategories() ([]string, error) {
+ r, e := s.db.Query(`SELECT DISTINCT category FROM app_logs ORDER BY category`)
+ if e != nil {
+ return nil, e
+ }
+ defer r.Close()
+ out := []string{}
+ for r.Next() {
+ var c string
+ if e = r.Scan(&c); e != nil {
+ return nil, e
+ }
+ out = append(out, c)
+ }
+ return out, r.Err()
+}
func (s *Store) Settings() (AppSettings, error) {
- x := AppSettings{DatabasePath: s.path, Theme: "dark", Locale: "zh-CN", GitScope: "current", AutoRefresh: true, GlassOpacity: 55, LoadingStyle: "fullscreen-orbit"}
+ x := AppSettings{DatabasePath: s.path, Theme: "dark", Locale: "zh-CN", GitScope: "current", AutoRefresh: true, GlassOpacity: 55, LoadingStyle: "fullscreen-orbit",
+ MinimizeToTray: true, AutoUpdateMode: "daily", AutoUpdateInterval: 1, AutoUpdateTime: "09:00", AIProvider: "spark", ImageMode: "base64"}
r, e := s.db.Query(`SELECT key,value FROM settings`)
if e != nil {
return x, e
@@ -540,6 +734,42 @@ func (s *Store) Settings() (AppSettings, error) {
if validLoadingStyle(v) {
x.LoadingStyle = v
}
+ case "minimizeToTray":
+ x.MinimizeToTray = v == "true"
+ case "autoUpdateEnabled":
+ x.AutoUpdateEnabled = v == "true"
+ case "autoUpdateMode":
+ if v == "daily" || v == "everyNDays" || v == "everyNHours" {
+ x.AutoUpdateMode = v
+ }
+ case "autoUpdateInterval":
+ if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 720 {
+ x.AutoUpdateInterval = n
+ }
+ case "aiProvider":
+ if v == "spark" || v == "deepseek" {
+ x.AIProvider = v
+ }
+ case "sparkKey":
+ x.SparkKey = v
+ case "deepSeekKey":
+ x.DeepSeekKey = v
+ case "syncApiKeys":
+ x.SyncAPIKeys = v == "true"
+ case "avatarMode":
+ if v == "base64" || v == "url" || v == "path" {
+ x.AvatarMode = v
+ }
+ case "avatarValue":
+ x.AvatarValue = v
+ case "imageMode":
+ if v == "base64" || v == "path" || v == "server" {
+ x.ImageMode = v
+ }
+ case "autoUpdateTime":
+ if len(v) == 5 && v[2] == ':' {
+ x.AutoUpdateTime = v
+ }
}
}
return x, nil
@@ -551,7 +781,37 @@ func (s *Store) SaveSettings(x AppSettings) error {
if !validLoadingStyle(x.LoadingStyle) {
x.LoadingStyle = "fullscreen-orbit"
}
- vals := map[string]string{"theme": x.Theme, "locale": x.Locale, "gitScope": x.GitScope, "autoRefresh": fmt.Sprint(x.AutoRefresh), "glassOpacity": strconv.Itoa(x.GlassOpacity), "loadingStyle": x.LoadingStyle, "loadingStyleSet": "true"}
+ if x.AutoUpdateInterval < 1 || x.AutoUpdateInterval > 720 {
+ x.AutoUpdateInterval = 1
+ }
+ if x.AutoUpdateMode != "daily" && x.AutoUpdateMode != "everyNDays" && x.AutoUpdateMode != "everyNHours" {
+ x.AutoUpdateMode = "daily"
+ }
+ if len(x.AutoUpdateTime) != 5 || x.AutoUpdateTime[2] != ':' {
+ x.AutoUpdateTime = "09:00"
+ }
+ if x.AIProvider != "spark" && x.AIProvider != "deepseek" {
+ x.AIProvider = "spark"
+ }
+ if x.ImageMode != "base64" && x.ImageMode != "path" && x.ImageMode != "server" {
+ x.ImageMode = "base64"
+ }
+ vals := map[string]string{"theme": x.Theme, "locale": x.Locale, "gitScope": x.GitScope, "autoRefresh": fmt.Sprint(x.AutoRefresh), "glassOpacity": strconv.Itoa(x.GlassOpacity), "loadingStyle": x.LoadingStyle, "loadingStyleSet": "true",
+ "minimizeToTray": fmt.Sprint(x.MinimizeToTray), "autoUpdateEnabled": fmt.Sprint(x.AutoUpdateEnabled), "autoUpdateMode": x.AutoUpdateMode, "autoUpdateInterval": strconv.Itoa(x.AutoUpdateInterval), "autoUpdateTime": x.AutoUpdateTime,
+ "aiProvider": x.AIProvider, "sparkKey": x.SparkKey, "deepSeekKey": x.DeepSeekKey, "syncApiKeys": fmt.Sprint(x.SyncAPIKeys),
+ "avatarMode": x.AvatarMode, "avatarValue": x.AvatarValue, "imageMode": x.ImageMode}
+ if x.AvatarMode != "base64" && x.AvatarMode != "url" && x.AvatarMode != "path" {
+ vals["avatarMode"], vals["avatarValue"] = "", ""
+ }
+ // API Key / 头像变化时记录修改时间,作为同步的 LWW 依据。
+ if prev, e := s.Settings(); e == nil {
+ if prev.SparkKey != x.SparkKey || prev.DeepSeekKey != x.DeepSeekKey {
+ vals["api_keys_updated_at"] = nowRFC()
+ }
+ if prev.AvatarMode != vals["avatarMode"] || prev.AvatarValue != vals["avatarValue"] {
+ vals["avatar_updated_at"] = nowRFC()
+ }
+ }
tx, e := s.db.Begin()
if e != nil {
return e
@@ -565,6 +825,19 @@ func (s *Store) SaveSettings(x AppSettings) error {
return tx.Commit()
}
+// Meta 读取 settings 表中的任意键(用于内部状态,如上次自动更新时间)。
+func (s *Store) Meta(key string) string {
+ var v string
+ _ = s.db.QueryRow(`SELECT value FROM settings WHERE key=?`, key).Scan(&v)
+ return v
+}
+
+// SetMeta 写入 settings 表中的任意键。
+func (s *Store) SetMeta(key, value string) error {
+ _, e := s.db.Exec(`INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, key, value)
+ return e
+}
+
func validLoadingStyle(v string) bool {
switch v {
case "bar", "fullscreen", "fullscreen-orbit", "fullscreen-grid", "fullscreen-warp":
@@ -579,9 +852,9 @@ func (s *Store) ClearData(mode string, projectID int64) error {
return e
}
defer tx.Rollback()
- tables := []string{"language_stats", "file_entries", "git_commits", "git_refs", "commit_refs", "contributors", "project_insights", "insight_issues", "analysis_runs"}
+ tables := []string{"language_stats", "file_entries", "git_commits", "git_refs", "contributors", "git_hotspots", "project_insights", "insight_issues", "analysis_runs"}
if mode == "all" {
- tables = append(tables, "projects")
+ tables = append(tables, "projects", "todos", "tickets", "notes", "favorites", "messages", "ai_conversations", "ai_messages")
}
for _, t := range tables {
q := "DELETE FROM " + t
@@ -608,7 +881,7 @@ func (s *Store) ReplaceGit(id int64, g GitStats) error {
return e
}
defer tx.Rollback()
- for _, t := range []string{"git_commits", "git_refs", "commit_refs", "contributors"} {
+ for _, t := range []string{"git_commits", "git_refs", "contributors", "git_hotspots"} {
if _, e = tx.Exec("DELETE FROM "+t+" WHERE project_id=?", id); e != nil {
return e
}
@@ -628,14 +901,69 @@ func (s *Store) ReplaceGit(id int64, g GitStats) error {
return e
}
}
+ for _, h := range g.Hotspots {
+ if _, e = tx.Exec(`INSERT INTO git_hotspots VALUES(?,?,?,?,?)`, id, h.Path, h.Changes, h.Added, h.Deleted); e != nil {
+ return e
+ }
+ }
_, e = tx.Exec(`INSERT INTO analysis_runs(project_id,kind,status,started_at,completed_at) VALUES(?,'git','completed',?,?)`, id, now(), now())
if e != nil {
return e
}
return tx.Commit()
}
+
+// MergeGit 增量合并新提交:追加提交与热点,替换引用,再从提交表重算贡献者。
+func (s *Store) MergeGit(id int64, g GitStats) error {
+ tx, e := s.db.Begin()
+ if e != nil {
+ return e
+ }
+ defer tx.Rollback()
+ for _, c := range g.Commits {
+ if _, e = tx.Exec(`INSERT INTO git_commits VALUES(?,?,?,?,?,?,?,?)
+ ON CONFLICT(project_id,hash) DO UPDATE SET author=excluded.author,email=excluded.email,message=excluded.message,committed_at=excluded.committed_at,added=excluded.added,deleted=excluded.deleted`,
+ id, c.Hash, c.Author, c.Email, c.Message, c.Date, c.Added, c.Deleted); e != nil {
+ return e
+ }
+ }
+ if _, e = tx.Exec(`DELETE FROM git_refs WHERE project_id=?`, id); e != nil {
+ return e
+ }
+ for _, r := range g.Refs {
+ if _, e = tx.Exec(`INSERT INTO git_refs VALUES(?,?,?,?,?)`, id, r.Name, r.Hash, r.Kind, boolInt(r.Current)); e != nil {
+ return e
+ }
+ }
+ for _, h := range g.Hotspots {
+ if _, e = tx.Exec(`INSERT INTO git_hotspots VALUES(?,?,?,?,?)
+ ON CONFLICT(project_id,path) DO UPDATE SET changes=changes+excluded.changes,added=added+excluded.added,deleted=deleted+excluded.deleted`,
+ id, h.Path, h.Changes, h.Added, h.Deleted); e != nil {
+ return e
+ }
+ }
+ if _, e = tx.Exec(`DELETE FROM contributors WHERE project_id=?`, id); e != nil {
+ return e
+ }
+ if _, e = tx.Exec(`INSERT INTO contributors(project_id,email,name,commits,added,deleted)
+ SELECT project_id,email,MAX(author),COUNT(*),SUM(added),SUM(deleted) FROM git_commits WHERE project_id=? GROUP BY email`, id); e != nil {
+ return e
+ }
+ _, e = tx.Exec(`INSERT INTO analysis_runs(project_id,kind,status,started_at,completed_at) VALUES(?,'git','completed',?,?)`, id, now(), now())
+ if e != nil {
+ return e
+ }
+ return tx.Commit()
+}
+
+// LatestCommitHash 返回项目已入库的最新提交哈希,用于增量分析起点。
+func (s *Store) LatestCommitHash(id int64) string {
+ var h string
+ _ = s.db.QueryRow(`SELECT hash FROM git_commits WHERE project_id=? ORDER BY committed_at DESC LIMIT 1`, id).Scan(&h)
+ return h
+}
func (s *Store) GitStats(id int64) (GitStats, error) {
- g := GitStats{Available: true, Commits: []GitCommit{}, Refs: []GitRef{}, Contributors: []Contributor{}, Heatmap: []HeatDay{}}
+ g := GitStats{Available: true, Commits: []GitCommit{}, Refs: []GitRef{}, Contributors: []Contributor{}, Heatmap: []HeatDay{}, Hotspots: []GitFileHotspot{}}
r, e := s.db.Query(`SELECT hash,author,email,message,committed_at,added,deleted FROM git_commits WHERE project_id=? ORDER BY committed_at DESC`, id)
if e != nil {
return g, e
@@ -681,16 +1009,34 @@ func (s *Store) GitStats(id int64) (GitStats, error) {
g.CommitCount = int64(len(g.Commits))
g.ContributorCount = int64(len(g.Contributors))
cut := time.Now().AddDate(-1, 0, 0)
- hm := map[string]int64{}
+ hm := map[string]*HeatDay{}
for _, c := range g.Commits {
if t, e := time.Parse(time.RFC3339, c.Date); e == nil && t.After(cut) {
- hm[t.Local().Format("2006-01-02")]++
+ key := t.Local().Format("2006-01-02")
+ d := hm[key]
+ if d == nil {
+ d = &HeatDay{Date: key}
+ hm[key] = d
+ }
+ d.Count++
+ d.Added += c.Added
+ d.Deleted += c.Deleted
}
}
- for d, c := range hm {
- g.Heatmap = append(g.Heatmap, HeatDay{Date: d, Count: c})
+ for _, d := range hm {
+ g.Heatmap = append(g.Heatmap, *d)
}
sort.Slice(g.Heatmap, func(i, j int) bool { return g.Heatmap[i].Date < g.Heatmap[j].Date })
+ hr, e := s.db.Query(`SELECT path,changes,added,deleted FROM git_hotspots WHERE project_id=? ORDER BY changes DESC,added+deleted DESC LIMIT 20`, id)
+ if e != nil {
+ return g, e
+ }
+ for hr.Next() {
+ var h GitFileHotspot
+ _ = hr.Scan(&h.Path, &h.Changes, &h.Added, &h.Deleted)
+ g.Hotspots = append(g.Hotspots, h)
+ }
+ hr.Close()
return g, nil
}
diff --git a/database_test.go b/database_test.go
index 44a98f8..71eaeb9 100644
--- a/database_test.go
+++ b/database_test.go
@@ -34,6 +34,62 @@ func TestStoreProjectAndSnapshot(t *testing.T) {
}
}
+func TestAISummaryRoundTrip(t *testing.T) {
+ s, e := OpenStore(filepath.Join(t.TempDir(), "test.db"))
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer s.db.Close()
+ p, e := s.SaveProject(0, ProjectInput{Name: "demo", Path: t.TempDir()})
+ if e != nil {
+ t.Fatal(e)
+ }
+ if e = s.SaveAISummary(p.ID, "project", "spark", "第一版介绍"); e != nil {
+ t.Fatal(e)
+ }
+ // 覆盖更新(同项目同模块只保留最新一份)
+ if e = s.SaveAISummary(p.ID, "project", "deepseek", "第二版介绍"); e != nil {
+ t.Fatal(e)
+ }
+ if e = s.SaveAISummary(p.ID, "git", "spark", "Git 介绍"); e != nil {
+ t.Fatal(e)
+ }
+ got, e := s.GetAISummaries(p.ID)
+ if e != nil || len(got) != 2 {
+ t.Fatalf("expected 2 summaries, got %d (%v)", len(got), e)
+ }
+ for _, x := range got {
+ switch x.Kind {
+ case "project":
+ if x.Content != "第二版介绍" || x.Provider != "deepseek" {
+ t.Fatalf("project summary mismatch: %#v", x)
+ }
+ case "git":
+ if x.Content != "Git 介绍" || x.Provider != "spark" {
+ t.Fatalf("git summary mismatch: %#v", x)
+ }
+ }
+ }
+}
+
+func TestAISummaryCleanup(t *testing.T) {
+ if got := stripFence("```markdown\n介绍正文\n- 列表\n```"); got != "介绍正文\n- 列表" {
+ t.Fatalf("stripFence: %q", got)
+ }
+ if got := stripFence("普通文本"); got != "普通文本" {
+ t.Fatalf("stripFence should keep plain text: %q", got)
+ }
+ if got := stripFence("```\n# 标题\n正文没闭合"); got != "# 标题\n正文没闭合" {
+ t.Fatalf("stripFence should drop unclosed opening fence: %q", got)
+ }
+ if got := dedentCommon(" 第一行\n 第二行"); got != "第一行\n第二行" {
+ t.Fatalf("dedentCommon: %q", got)
+ }
+ if got := dedentCommon("第一行\n 嵌套保留"); got != "第一行\n 嵌套保留" {
+ t.Fatalf("dedentCommon should keep relative indent: %q", got)
+ }
+}
+
func TestNormalizePath(t *testing.T) {
if _, e := normalizePath(filepath.Join(t.TempDir(), "missing")); e == nil {
t.Fatal("missing directory accepted")
@@ -199,6 +255,43 @@ func TestLoadingStylePersistsAndValidates(t *testing.T) {
}
}
+func TestMigrateSkippedWhenVersionCurrent(t *testing.T) {
+ db := filepath.Join(t.TempDir(), "versioned.db")
+ s, e := OpenStore(db)
+ if e != nil {
+ t.Fatal(e)
+ }
+ // 删除一张迁移会创建的表:若重开时迁移被跳过,该表应保持缺失。
+ if _, e = s.db.Exec(`DROP TABLE git_hotspots`); e != nil {
+ t.Fatal(e)
+ }
+ s.db.Close()
+ s, e = OpenStore(db)
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer s.db.Close()
+ var n int
+ _ = s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='git_hotspots'`).Scan(&n)
+ if n != 0 {
+ t.Fatal("migration ran again despite current schema version")
+ }
+ // 回退版本号后重开,迁移应重新执行并补回该表。
+ if _, e = s.db.Exec(`DELETE FROM schema_migrations`); e != nil {
+ t.Fatal(e)
+ }
+ s.db.Close()
+ s, e = OpenStore(db)
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer s.db.Close()
+ _ = s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='git_hotspots'`).Scan(&n)
+ if n != 1 {
+ t.Fatal("migration did not run after version reset")
+ }
+}
+
func TestLegacyLoadingStyleDefaultMigratesOnce(t *testing.T) {
db := filepath.Join(t.TempDir(), "settings.db")
s, e := OpenStore(db)
@@ -208,6 +301,10 @@ func TestLegacyLoadingStyleDefaultMigratesOnce(t *testing.T) {
if _, e = s.db.Exec(`UPDATE settings SET value='bar' WHERE key='loadingStyle'`); e != nil {
t.Fatal(e)
}
+ // 模拟旧版本数据库:回退已记录的结构版本,重开时才会执行一次迁移转换。
+ if _, e = s.db.Exec(`DELETE FROM schema_migrations`); e != nil {
+ t.Fatal(e)
+ }
s.db.Close()
s, e = OpenStore(db)
if e != nil {
@@ -237,3 +334,52 @@ func TestLegacyLoadingStyleDefaultMigratesOnce(t *testing.T) {
t.Fatalf("explicit loadingStyle=%q", got.LoadingStyle)
}
}
+
+func TestMultiNoteCRUD(t *testing.T) {
+ s, e := OpenStore(filepath.Join(t.TempDir(), "notes.db"))
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer s.db.Close()
+ a, e := s.SaveNoteByID(0, "第一条")
+ if e != nil || a.ID == 0 || a.UUID == "" {
+ t.Fatalf("create note: %#v %v", a, e)
+ }
+ b, e := s.SaveNoteByID(0, "第二条")
+ if e != nil || b.ID == a.ID {
+ t.Fatalf("second note: %#v %v", b, e)
+ }
+ notes, e := s.ListNotes(0)
+ if e != nil || len(notes) != 2 {
+ t.Fatalf("list notes: %d %v", len(notes), e)
+ }
+ if notes[0].ID != b.ID {
+ t.Fatalf("latest first, got %#v", notes[0])
+ }
+ // GetNote 返回最近更新的一条;SaveNote 兼容入口更新它
+ if n, _ := s.GetNote(); n.ID != b.ID {
+ t.Fatalf("GetNote should return latest, got %d", n.ID)
+ }
+ if a2, e := s.SaveNoteByID(a.ID, "第一条改"); e != nil || a2.Content != "第一条改" {
+ t.Fatalf("update note: %#v %v", a2, e)
+ }
+ // 更新过的 a 变为最近一条
+ if n, _ := s.GetNote(); n.ID != a.ID {
+ t.Fatalf("GetNote should follow update, got %d", n.ID)
+ }
+ if _, e := s.SaveNoteByID(99999, "missing"); e == nil || e.Error() != "NOTE_NOT_FOUND" {
+ t.Fatalf("expect NOTE_NOT_FOUND, got %v", e)
+ }
+ if e := s.DeleteNote(b.ID); e != nil {
+ t.Fatal(e)
+ }
+ if notes, _ = s.ListNotes(0); len(notes) != 1 || notes[0].ID != a.ID {
+ t.Fatalf("after delete: %#v", notes)
+ }
+ // 软删行仍在表中且 dirty(供同步推送)
+ var deleted, dirty int
+ _ = s.db.QueryRow(`SELECT deleted,dirty FROM notes WHERE id=?`, b.ID).Scan(&deleted, &dirty)
+ if deleted != 1 || dirty != 1 {
+ t.Fatalf("soft delete flags: deleted=%d dirty=%d", deleted, dirty)
+ }
+}
diff --git a/frontend/index.html b/frontend/index.html
index 0011c98..695289c 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,7 +3,8 @@
- view
+
+ 年糕崽崽项目管理(PMS)
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index d866e0e..af24c55 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,8 +8,12 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
+ "@wailsio/runtime": "^3.0.0-beta.5",
"echarts": "^6.1.0",
+ "highlight.js": "^11.11.2",
"lucide-vue-next": "^1.0.0",
+ "lunar-javascript": "^1.7.7",
+ "marked": "^18.0.9",
"pinia": "^4.0.1",
"vue": "^3.2.37",
"vue-i18n": "^9.14.5",
@@ -271,6 +275,12 @@
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz",
"integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="
},
+ "node_modules/@wailsio/runtime": {
+ "version": "3.0.0-beta.5",
+ "resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-beta.5.tgz",
+ "integrity": "sha512-WPJcKfD/iN5EbzLAE0B2AjwRVVSSnhpGQSjgFORtIA3h1bkiw/k7rTd06emNsI1ZFGFgMiUEEmXgEcVfeWoRVQ==",
+ "license": "MIT"
+ },
"node_modules/birpc": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
@@ -711,6 +721,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/highlight.js": {
+ "version": "11.11.2",
+ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.2.tgz",
+ "integrity": "sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/hookable": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
@@ -741,6 +760,12 @@
"vue": ">=3.0.1"
}
},
+ "node_modules/lunar-javascript": {
+ "version": "1.7.7",
+ "resolved": "https://registry.npmjs.org/lunar-javascript/-/lunar-javascript-1.7.7.tgz",
+ "integrity": "sha512-u/KYiwPIBo/0bT+WWfU7qO1d+aqeB90Tuy4ErXenr2Gam0QcWeezUvtiOIyXR7HbVnW2I1DKfU0NBvzMZhbVQw==",
+ "license": "MIT"
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -749,6 +774,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/marked": {
+ "version": "18.0.9",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.9.tgz",
+ "integrity": "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 44c1032..3a5eec4 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -6,11 +6,16 @@
"scripts": {
"dev": "vite",
"build": "vite build",
+ "build:dev": "vite build --mode development --minify false",
"preview": "vite preview"
},
"dependencies": {
+ "@wailsio/runtime": "^3.0.0-beta.5",
"echarts": "^6.1.0",
+ "highlight.js": "^11.11.2",
"lucide-vue-next": "^1.0.0",
+ "lunar-javascript": "^1.7.7",
+ "marked": "^18.0.9",
"pinia": "^4.0.1",
"vue": "^3.2.37",
"vue-i18n": "^9.14.5",
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index c95a3c2..f461df6 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -2,12 +2,20 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
-import { LayoutDashboard, ScrollText, Settings, X, Database, SlidersHorizontal } from 'lucide-vue-next'
+import { LayoutDashboard, ScrollText, Settings, X, Database, SlidersHorizontal, Home, ListTodo, TicketCheck, CalendarDays, CalendarCheck2, Sparkles, UserRound, ClipboardList, Wrench, Rocket, ChevronDown, Bell, StickyNote, ListFilter, Palette, Bot, Users, NotebookPen, CloudUpload, Power } from 'lucide-vue-next'
import { useAppStore } from './store'
import DatabaseSetup from './components/DatabaseSetup.vue'
import BrowserBlocked from './components/BrowserBlocked.vue'
import AnalysisCanvas from './components/AnalysisCanvas.vue'
-import { isNative } from './api'
+import LoginModal from './components/LoginModal.vue'
+import CommandPalette from './components/CommandPalette.vue'
+import DailyCard from './components/DailyCard.vue'
+import AboutModal from './components/AboutModal.vue'
+import MessageBell from './components/MessageBell.vue'
+import TaskCenter from './components/TaskCenter.vue'
+import NoteCenter from './components/NoteCenter.vue'
+import TeamSwitcher from './components/TeamSwitcher.vue'
+import { call, isNative, on } from './api'
const route = useRoute()
const router = useRouter()
@@ -15,8 +23,11 @@ const store = useAppStore()
const { t, locale } = useI18n()
const native = isNative()
const quickOpen = ref(false)
+const aboutOpen = ref(false)
+const appVersion = ref('1.0.0')
const displayedTask = ref(null)
let off
+let offMenus = []
let loadingHoldTimer
const activeTask = computed(() => Object.values(store.tasks).find(x => !['completed', 'error', 'cancelled'].includes(x.stage)))
@@ -35,19 +46,138 @@ function openSettings() {
router.push('/settings')
}
+// 侧边栏底部退出:与原生菜单/托盘「退出」一致,绕过最小化到托盘直接退出应用。
+async function quitApp() {
+ if (!confirm(t('quitConfirm'))) return
+ try { await call('QuitApp') } catch { /* 预览模式无原生桥 */ }
+}
+
+// ---- 双列导航:一级分类(rail)+ 二级菜单(sub),窄屏点一级弹浮层 ----
+// 设置页无 query 时沿用本地记忆的分区,保证二级高亮与页面内容一致。
+const settingsTab = r => String(r.query.tab || localStorage.getItem('cc-settings-tab') || 'rules')
+const navGroups = [
+ { id: 'overview', icon: Home, label: 'navOverview', items: [
+ { to: '/', icon: Home, label: 'workbench', match: r => r.path === '/' },
+ { to: '/today', icon: CalendarCheck2, label: 'todayTasks', badge: 'todayDue' },
+ { to: '/calendar', icon: CalendarDays, label: 'calendar', dot: 'calendarDot' },
+ { to: '/messages', icon: Bell, label: 'messages', badge: 'unread' }
+ ] },
+ { id: 'projects', icon: LayoutDashboard, label: 'navProjects', items: [
+ { to: '/projects', icon: LayoutDashboard, label: 'projects', match: r => r.path === '/projects' || r.path.startsWith('/project/') },
+ { to: '/ai', icon: Sparkles, label: 'aiChat' }
+ ] },
+ { id: 'work', icon: ClipboardList, label: 'navWork', items: [
+ { to: '/todos', icon: ListTodo, label: 'todos', badge: 'todosOpen' },
+ { to: '/tickets', icon: TicketCheck, label: 'tickets', badge: 'ticketsActive' },
+ { to: '/notes', icon: StickyNote, label: 'notesPage' }
+ ] },
+ { id: 'team', icon: Users, label: 'navTeam', items: [
+ { to: '/team', icon: Users, label: 'teamHome', match: r => r.path === '/team' },
+ { to: '/team/tasks', icon: ClipboardList, label: 'teamTasks', badge: 'teamAssigned' },
+ { to: '/team/reports', icon: NotebookPen, label: 'teamReports' }
+ ] },
+ { id: 'system', icon: Wrench, label: 'navSystem', items: [
+ { to: '/launchpad', icon: Rocket, label: 'launchpad' },
+ { to: '/logs', icon: ScrollText, label: 'logs' }
+ ] },
+ { id: 'settings', icon: Settings, label: 'settings', items: [
+ { to: '/settings?tab=rules', icon: ListFilter, label: 'tabRules', match: r => r.path === '/settings' && settingsTab(r) === 'rules' },
+ { to: '/settings?tab=appearance', icon: Palette, label: 'tabAppearance', match: r => r.path === '/settings' && settingsTab(r) === 'appearance' },
+ { to: '/settings?tab=ai', icon: Bot, label: 'aiAnalysis', match: r => r.path === '/settings' && settingsTab(r) === 'ai' },
+ // 全局文件存储配置:权威数据在服务器,仅管理员(云端账号 id=1)可见可改
+ { to: '/settings?tab=filestorage', icon: CloudUpload, label: 'tabFileStorage', adminOnly: true, match: r => r.path === '/settings' && settingsTab(r) === 'filestorage' },
+ { to: '/settings?tab=database', icon: Database, label: 'tabDatabase', match: r => r.path === '/settings' && settingsTab(r) === 'database' }
+ ] }
+]
+// adminOnly 项只对云端管理员(id=1)展示
+const visibleItems = g => g.items.filter(it => !it.adminOnly || store.syncStatus.userId === 1)
+const itemActive = it => it.match ? it.match(route) : route.path === it.to.split('?')[0]
+// 导航徽标:badge 显示数字,dot 只显示小圆点;一级 rail 在组内任一非零时亮点
+const badgeVal = it => it.badge ? (store.badges[it.badge] || 0) : 0
+const badgeText = it => { const n = badgeVal(it); return n > 99 ? '99+' : String(n) }
+const dotVal = it => it.dot ? !!store.badges[it.dot] : false
+const groupDot = g => g.items.some(it => badgeVal(it) > 0 || dotVal(it))
+const childActive = c => route.path === '/settings' && String(route.query.tab || '') === c.tab
+const groupOfRoute = () => navGroups.find(g => g.items.some(it => itemActive(it)))?.id
+const activeGroupId = ref(groupOfRoute() || 'overview')
+const activeGroup = computed(() => navGroups.find(g => g.id === activeGroupId.value) || navGroups[0])
+const expandedParents = ref({})
+const railFlyout = ref(null) // 窄屏浮层 { group, top }
+
+watch(() => route.fullPath, () => {
+ const g = groupOfRoute()
+ if (g) activeGroupId.value = g
+ railFlyout.value = null
+ for (const grp of navGroups) {
+ for (const it of grp.items) if (it.children && itemActive(it)) expandedParents.value[it.label] = true
+ }
+}, { immediate: true })
+
+const isNarrow = () => window.matchMedia('(max-width: 1150px)').matches
+function clickGroup(g, ev) {
+ if (isNarrow()) {
+ railFlyout.value = railFlyout.value?.group.id === g.id
+ ? null
+ : { group: g, top: Math.min(ev.currentTarget.getBoundingClientRect().top, innerHeight - 320) }
+ return
+ }
+ activeGroupId.value = g.id
+ const first = g.items[0]
+ if (first && !itemActive(first)) router.push(first.to)
+}
+function toggleParent(it, ev) {
+ ev.preventDefault()
+ ev.stopPropagation()
+ expandedParents.value[it.label] = !expandedParents.value[it.label]
+}
+function onFlyoutAway(e) {
+ if (railFlyout.value && !e.target.closest('.rail-flyout') && !e.target.closest('.rail-item')) railFlyout.value = null
+}
+
+// Ctrl+K / Cmd+K 呼出全局搜索面板
+function onGlobalKey(e) {
+ if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') {
+ e.preventDefault()
+ store.paletteOpen = !store.paletteOpen
+ }
+}
onMounted(async () => {
+ addEventListener('keydown', onGlobalKey)
+ addEventListener('click', onFlyoutAway)
if (!native) return
off = store.listen()
+ offMenus = [
+ on('menu:navigate', p => router.push(String(p))),
+ on('menu:action', k => {
+ if (k === 'addProject') {
+ store.pendingAction = 'addProject'
+ router.push('/projects')
+ } else if (k === 'account') {
+ store.openAccount(router)
+ } else if (k === 'about') {
+ aboutOpen.value = true
+ } else if (k === 'sync-refresh') {
+ store.refreshSyncStatus()
+ }
+ }),
+ on('menu:set', p => p?.key && updateSetting(p.key, p.value))
+ ]
await store.boot()
locale.value = store.settings.locale || 'zh-CN'
+ try { appVersion.value = await call('GetAppVersion') } catch { /* keep fallback */ }
})
onUnmounted(() => {
+ removeEventListener('keydown', onGlobalKey)
+ removeEventListener('click', onFlyoutAway)
clearTimeout(loadingHoldTimer)
off?.()
+ offMenus.forEach(f => f?.())
})
watch(() => store.settings.locale, v => {
if (v) locale.value = v
})
+// 切页时顺带刷新同步徽标(待推送数在本地增删改后保持新鲜)
+watch(() => route.path, () => { if (store.syncStatus.loggedIn) store.refreshSyncStatus() })
watch(activeTask, task => {
clearTimeout(loadingHoldTimer)
if (task) {
@@ -65,8 +195,8 @@ watch(activeTask, task => {
- {{ hover.date }} · {{ hover.count }} {{ locale === 'zh-CN' ? '次提交' : 'commits' }}
+ {{ hover.date }} · {{ hover.count }} {{ locale === 'zh-CN' ? '次提交' : 'commits' }} · +{{ hover.added }} -{{ hover.deleted }}
diff --git a/frontend/src/components/HelloWorld.vue b/frontend/src/components/HelloWorld.vue
deleted file mode 100644
index 29c023f..0000000
--- a/frontend/src/components/HelloWorld.vue
+++ /dev/null
@@ -1,71 +0,0 @@
-
-
-
-
- {{ data.resultText }}
-
-
-
-
-
-
-
-
diff --git a/frontend/src/git.css b/frontend/src/git.css
index dc6bcdb..3240643 100644
--- a/frontend/src/git.css
+++ b/frontend/src/git.css
@@ -1,4 +1,4 @@
*{scrollbar-width:thin;scrollbar-color:rgba(145,136,255,.55) transparent}::-webkit-scrollbar{width:9px;height:9px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:rgba(145,136,255,.38);border:2px solid transparent;background-clip:padding-box;border-radius:8px}::-webkit-scrollbar-thumb:hover{background:rgba(145,136,255,.7);border:2px solid transparent;background-clip:padding-box}
.wsl-picker{display:flex;gap:8px;margin:12px 24px 0}.wsl-picker select{flex:1;background:var(--glass-soft);border:1px solid var(--border);border-radius:7px;color:var(--text);padding:0 10px}.icon-actions button:disabled{opacity:.5;cursor:not-allowed}
-.page{overflow-x:hidden}.project-title>div:first-child{min-width:0;flex:1}.project-title p{max-width:100%!important}.icon-actions{flex:none}.project-card{min-width:0}
+.page{overflow-x:clip}.project-title>div:first-child{min-width:0;flex:1}.project-title p{max-width:100%!important}.icon-actions{flex:none}.project-card{min-width:0}
.git-context{display:flex;align-items:center;gap:18px;margin:-8px 0 18px;padding:11px 15px;border:1px solid var(--glass-border);border-radius:7px;background:var(--glass-soft);color:var(--muted)}.git-context span{display:flex;align-items:center;gap:7px}.git-context svg{width:17px;color:#9188ff}.git-context b{color:var(--text)}.heat-panel{height:255px}.heat-panel .chart{height:185px}.branch.interactive{position:relative;padding-right:52px;cursor:pointer;transition:border-color .2s,background .2s}.branch.interactive:hover,.branch.interactive.selected{background:rgba(123,115,255,.12);outline:1px solid rgba(145,136,255,.35)}.checkout-btn{position:absolute;right:12px;top:20px;width:32px;height:32px;border:0;border-radius:6px;background:rgba(123,115,255,.15);color:#9188ff;display:grid;place-items:center;cursor:pointer}.checkout-btn svg{width:16px}.commit{width:100%;border:0;color:var(--text);text-align:left;cursor:pointer}.commit:hover{outline:1px solid rgba(145,136,255,.3)}.git-trend{position:relative}.git-trend>.segments{position:absolute;right:0;top:-38px;z-index:2}.git-trend .chart{height:280px}.drawer-mask{position:fixed;inset:0;z-index:60;background:rgba(0,0,0,.55);backdrop-filter:blur(5px);display:flex;justify-content:flex-end;animation:overlay-in .2s}.commit-drawer{width:min(620px,90vw);height:100%;overflow:auto;background:var(--glass-strong);border-left:1px solid var(--glass-border);box-shadow:-20px 0 50px rgba(0,0,0,.3);padding:26px;animation:drawer-in .3s cubic-bezier(.16,1,.3,1)}.commit-drawer header{display:flex;justify-content:space-between;gap:20px;border-bottom:1px solid var(--border);padding-bottom:18px}.commit-drawer header small{color:var(--muted)}.commit-drawer h2{margin:7px 0 0;font-size:20px}.commit-drawer header button,.commit-meta button{border:0;background:var(--glass-soft);color:var(--text);width:34px;height:34px;border-radius:6px;display:grid;place-items:center;cursor:pointer}.commit-drawer svg{width:17px}.commit-meta{display:grid;grid-template-columns:1fr auto auto auto;gap:10px;align-items:center;margin:20px 0;padding:15px;background:var(--glass-soft);border-radius:7px}.commit-meta code{min-width:0;overflow:hidden;text-overflow:ellipsis}.commit-meta span,.commit-meta time{grid-column:1/3;color:var(--muted)}.change-file{display:grid;grid-template-columns:22px 1fr auto auto auto;gap:10px;align-items:center;padding:11px;border-bottom:1px solid var(--border)}.change-file span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.change-file small{color:var(--muted)}@keyframes drawer-in{from{transform:translateX(100%)}to{transform:none}}@media(prefers-reduced-motion:reduce){.commit-drawer{animation:none}}
diff --git a/frontend/src/main.js b/frontend/src/main.js
index 1c322ca..694f35a 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -3,10 +3,24 @@ import { createPinia } from 'pinia'
import { createRouter, createWebHashHistory } from 'vue-router'
import { createI18n } from 'vue-i18n'
import App from './App.vue'
-import Dashboard from './views/Dashboard.vue'
-import ProjectDetail from './views/ProjectDetail.vue'
-import Logs from './views/Logs.vue'
-import Settings from './views/Settings.vue'
+// 首屏 Workbench 静态引入;其余路由懒加载(echarts / lunar 等大依赖随页面按需加载)
+import Workbench from './views/Workbench.vue'
+const Dashboard = () => import('./views/Dashboard.vue')
+const ProjectDetail = () => import('./views/ProjectDetail.vue')
+const AIChat = () => import('./views/AIChat.vue')
+const Todos = () => import('./views/Todos.vue')
+const Tickets = () => import('./views/Tickets.vue')
+const CalendarPage = () => import('./views/CalendarPage.vue')
+const Logs = () => import('./views/Logs.vue')
+const Settings = () => import('./views/Settings.vue')
+const Profile = () => import('./views/Profile.vue')
+const Launchpad = () => import('./views/Launchpad.vue')
+const Messages = () => import('./views/Messages.vue')
+const Today = () => import('./views/Today.vue')
+const Notes = () => import('./views/Notes.vue')
+const TeamHome = () => import('./views/TeamHome.vue')
+const TeamTasks = () => import('./views/TeamTasks.vue')
+const TeamReports = () => import('./views/TeamReports.vue')
import './style.css'
import './motion.css'
import './database.css'
@@ -15,10 +29,245 @@ import './git.css'
import './polish.css'
const zh = {
- app: 'Code Count',
+ app: '年糕崽崽 PMS',
dashboard: '仪表盘',
logs: '运行日志',
settings: '设置',
+ navOverview: '概览',
+ navProjects: '项目',
+ navWork: '事务',
+ navSystem: '系统',
+ launchpad: '启动台',
+ launchpadSubtitle: '本机服务端口与应用启停管理',
+ lpMyApps: '我的应用',
+ lpScanned: '检测到的服务',
+ lpAddApp: '添加应用',
+ lpEditApp: '编辑应用',
+ lpRefresh: '刷新',
+ lpShowSys: '显示系统进程',
+ lpName: '应用名称',
+ lpKind: '种类',
+ lpPort: '端口',
+ lpDir: '工作目录',
+ lpStartCmd: '启动命令',
+ lpStopCmd: '停止命令',
+ lpStopBlank: '留空则直接结束进程树',
+ lpSuggest: '推荐',
+ lpStart: '启动',
+ lpStop: '停止',
+ lpPin: '存为应用',
+ lpRunning: '运行中',
+ lpStopped: '未运行',
+ lpMem: '内存',
+ lpNeedCmd: '首次启动请先选择或填写启动命令',
+ lpStopConfirm: '确认停止 {name}?',
+ lpDelConfirm: '删除应用 {name}?(不会停止正在运行的进程)',
+ lpEmptyApps: '还没有保存的应用:从下方检测列表「存为应用」或手动添加',
+ lpEmptyScan: '未检测到监听端口的服务',
+ workbench: '工作台',
+ workbenchSubtitle: '收藏项目、今日待办与速记',
+ todos: '待办事项',
+ todosSubtitle: '管理待办、跟踪进度',
+ tickets: '需求工单',
+ ticketsSubtitle: '工单需要绑定项目与排期',
+ calendar: '排期日历',
+ calendarSubtitle: '聚合待办截止日与工单排期',
+ statsOverview: '数据总览',
+ statsCollapseBtn: '折叠',
+ statsExpandBtn: '展开统计',
+ calJumpPh: '跳转 0501 / 2026-05-01',
+ calJumpTitle: '输入日期回车跳转;未填年份按当前视图年份补全',
+ calBadDate: '无法识别的日期格式',
+ dpClear: '清除',
+ messages: '消息中心',
+ boardView: '看板',
+ listView: '列表',
+ addTodo: '新建待办',
+ editTodo: '编辑待办',
+ quickAddTodo: '快速添加待办,回车确认...',
+ todoTitle: '标题',
+ todoContent: '备注(可选)',
+ relatedProject: '关联项目',
+ noProject: '不关联项目',
+ selectProject: '请选择项目',
+ dueDate: '截止时间',
+ startDate: '开始日期',
+ priorityLabel: '优先级',
+ statusLabel: '状态',
+ noTodos: '暂无待办事项',
+ todoStatus: { open: '待处理', doing: '进行中', done: '已完成' },
+ priority: { low: '低', medium: '中', high: '高' },
+ addTicket: '新建工单',
+ editTicket: '编辑工单',
+ ticketTitle: '工单标题',
+ ticketDesc: '需求描述',
+ ticketTypeLabel: '类型',
+ ticketType: { feature: '新功能', bug: '缺陷', task: '任务', improvement: '优化' },
+ ticketStatus: { open: '待处理', in_progress: '处理中', resolved: '已解决', closed: '已关闭' },
+ ticketFlow: { start: '开始处理', resolve: '标记解决', close: '关闭', reopen: '重新打开' },
+ noTickets: '暂无工单',
+ today: '今天',
+ selectDate: '选择日期',
+ noSchedule: '当日无排期',
+ weekdays: { sun: '日', mon: '一', tue: '二', wed: '三', thu: '四', fri: '五', sat: '六' },
+ favoriteProjects: '收藏项目',
+ noFavorites: '还没有收藏项目,去项目卡片点击星标收藏',
+ unfavorite: '取消收藏',
+ favorite: '收藏',
+ openTodos: '未完成待办',
+ openTickets: '进行中工单',
+ todayTodos: '今日待办',
+ noTodayTodos: '今天没有到期的待办',
+ weekTickets: '本周工单',
+ noWeekTickets: '本周没有到期的工单',
+ notepad: '记事本',
+ notepadPlaceholder: '随手记点什么,自动保存...',
+ autoSaved: '已自动保存',
+ noteCenter: '笔记',
+ noteNew: '新建笔记',
+ noteEdit: '编辑笔记',
+ noteEmpty: '还没有笔记,点右上角新建一条',
+ noteUntitled: '(空白笔记)',
+ noteDeleteConfirm: '删除这条笔记?',
+ notesPage: '笔记',
+ notesSubtitle: '共 {n} 条笔记,点击卡片编辑',
+ noteSearchPh: '搜索笔记…',
+ viewAll: '查看全部',
+ todayTasks: '今日任务',
+ todaySubtitle: '聚合今天需要处理的待办与工单',
+ secOverdue: '已逾期',
+ secToday: '今天到期',
+ secDoing: '进行中',
+ secUpcoming: '未来 7 天',
+ todayEmpty: '今天没有需要处理的任务,好好休息!',
+ messagesSubtitle: '系统通知与提醒汇总',
+ msgUnreadOnly: '仅看未读',
+ msgKindAll: '全部',
+ msgKind: { todo_due: '待办提醒', ticket_due: '工单提醒', analysis: '统计分析', sync: '同步' },
+ aiScopeBtn: 'AI 总结',
+ aiScopeEmpty: '还没有生成过总结,点击下方按钮让 AI 帮你分析。',
+ aiScopeGen: '生成总结',
+ aiScopeRegen: '重新生成',
+ cloudPendingTitle: '云端项目待绑定',
+ cloudPendingDesc: '这些项目来自你在其它电脑的同步数据,选择本机目录后即可继续统计',
+ cloudBindBtn: '绑定目录',
+ cloudBindTitle: '绑定云端项目到本机',
+ cloudBindDone: '云端项目已绑定,可以开始统计了',
+ navTeam: '团队',
+ teamHome: '团队概览',
+ teamHomeSubtitle: '成员、角色与团队设置',
+ teamTasks: '团队任务',
+ teamTasksSubtitle: '团队内的任务指派、流转与催办',
+ teamReports: '团队日报',
+ teamReportsSubtitle: '成员日报提交与 AI 摘要',
+ teamLoginHint: '团队功能需要登录账号(成员连接同一台 MySQL 服务器)',
+ teamNoneHint: '你还没有加入任何团队,先创建一个吧',
+ teamNamePh: '团队名称…',
+ teamCreateBtn: '创建团队',
+ teamCreateMore: '再创建一个团队',
+ teamCreatedToast: '团队已创建',
+ teamGoHome: '进入团队页',
+ teamSwitcher: '切换团队',
+ teamSwitchedToast: '已切换到「{name}」',
+ teamName: '团队名称',
+ teamRenameBtn: '重命名',
+ teamRole_owner: '拥有者',
+ teamRole_admin: '管理员',
+ teamRole_member: '成员',
+ teamMembersCount: '{n} 名成员',
+ teamDigestTime: '摘要时间',
+ teamDigestTimeHint: '到点后管理员客户端自动生成当日日报摘要',
+ teamInviteBtn: '邀请成员',
+ teamInviteUser: '账号用户名',
+ teamInviteUserPh: '输入对方的登录用户名',
+ teamInviteRole: '角色',
+ teamInvitedToast: '已加入团队',
+ teamMakeAdmin: '设为管理员',
+ teamMakeMember: '设为普通成员',
+ teamRemoveBtn: '移出团队',
+ teamRemoveConfirm: '把 {name} 移出团队?',
+ teamLeaveBtn: '退出团队',
+ teamLeaveConfirm: '确定退出这个团队?',
+ teamDissolveBtn: '解散团队',
+ teamDissolveConfirm: '解散团队「{name}」?所有团队任务与日报将被删除',
+ teamFilter_all: '全部',
+ teamFilter_mine: '指派给我',
+ teamFilter_created: '我创建的',
+ teamFilter_open: '未完成',
+ teamTaskNew: '新建任务',
+ teamTaskEdit: '编辑任务',
+ teamTaskTitlePh: '要做什么…',
+ teamTaskDesc: '描述(支持 Markdown)',
+ teamKindTodo: '待办',
+ teamKindTicket: '工单',
+ teamQuickCreate: '快捷创建任务 / 工单',
+ teamQuickCreateBtn: '创建并指派',
+ teamQuickFor: '给 {name} 创建',
+ teamQuickDoneToast: '已创建并指派给 {name}',
+ teamTasksEmpty: '还没有团队任务',
+ teamStatus_open: '待处理',
+ teamStatus_doing: '进行中',
+ teamStatus_done: '已完成',
+ teamStatus_closed: '已关闭',
+ teamReopen: '重新打开',
+ teamAssignee: '负责人',
+ teamCreator: '创建人',
+ teamUnassigned: '未指派',
+ teamUrgeBtn: '催办',
+ teamUrgedToast: '已发送催办提醒',
+ teamUrgedAt: '催办于',
+ teamTaskDeleteConfirm: '删除任务「{title}」?',
+ teamSharedItems: '成员共享的个人条目',
+ teamSharedEmpty: '成员还没有共享待办或工单',
+ teamMyReport: '我的日报',
+ teamReportPh: '今天做了什么、遇到什么问题、明天的计划…(支持 Markdown)',
+ teamReportSubmitBtn: '提交日报',
+ teamReportUpdateBtn: '更新日报',
+ teamReportSubmittedToast: '日报已提交',
+ teamReportSubmittedAt: '已于 {at} 提交',
+ teamReportNotSubmitted: '今天还没有提交',
+ teamReportMissing: '未提交',
+ teamReportContentHidden: '仅管理员可见日报内容',
+ teamQuoteDayReport: '引用下班总结',
+ teamQuoteDayReportHint: '把工作台生成的下班日报填入编辑器',
+ teamNoDayReport: '还没有生成过下班总结,先去工作台生成',
+ teamDigest: 'AI 团队摘要',
+ teamDigestNone: '汇总当日全部成员日报,生成团队摘要',
+ teamDigestAt: '{at} 由 {provider} 生成',
+ teamDigestGen: '生成摘要',
+ teamDigestRegen: '重新生成',
+ teamDigestStartedToast: '摘要生成中,完成后自动刷新',
+ teamDigestDoneToast: '团队摘要已生成',
+ generating: '生成中…',
+ prevDay: '前一天',
+ nextDay: '后一天',
+ retry: '重试',
+ loading: '加载中…',
+ noDescription: '(无描述)',
+ savedToast: '已保存',
+ profileTabInfo: '个人资料',
+ profileTabTeams: '我的团队',
+ profileInfoHint: '资料对同服务器的团队成员可见',
+ profileNickname: '昵称',
+ profileNicknamePh: '怎么称呼你',
+ profileJobTitle: '头衔',
+ profileJobTitlePh: '如:前端工程师',
+ profileEmail: '邮箱',
+ profileBio: '简介',
+ profileBioPh: '一句话介绍自己…',
+ profileTags: '技术栈标签',
+ profileTagsPh: '回车添加标签…',
+ profileTagsHint: '最多 20 个,回车或逗号添加,点 × 删除',
+ profileSaveBtn: '保存资料',
+ profileSavedToast: '资料已保存',
+ profileTeamsHint: '切换当前团队或创建新团队',
+ shareToTeam: '共享到团队',
+ sharePrivate: '仅自己可见',
+ sharedToTeamToast: '共享设置已更新',
+ recentMessages: '最近消息',
+ noMessages: '暂无消息',
+ markAllRead: '全部已读',
+ clearMessages: '清空消息',
projects: '我的项目',
addProject: '添加项目',
batch: '批量统计',
@@ -52,6 +301,7 @@ const zh = {
editProjectTitle: '编辑项目',
projectGroup: '项目组',
allProjectGroups: '全部项目组',
+ allProjects: '全部项目',
myProjectGroup: '我的项目组',
addProjectGroup: '新增项目组',
editProjectGroup: '编辑项目组',
@@ -140,7 +390,447 @@ const zh = {
warning: '警告',
error: '错误',
noLogs: '暂无日志记录',
+ searchLogs: '搜索日志内容...',
+ allCategories: '全部分类',
+ menuFile: '文件',
+ menuView: '视图',
+ menuTools: '工具',
+ menuHelp: '帮助',
+ menuDatabase: '数据管理',
+ menuQuit: '退出',
+ menuAnalyzeNow: '立即分析全部',
+ menuAbout: '关于',
+ aiChat: 'AI 分析',
+ aiSubtitle: '基于项目数据的智能分析与问答',
+ aiSparkLite: '星火 Lite',
+ aiKeys: 'Key 配置',
+ aiNewChat: '新建会话',
+ aiHistory: '历史会话',
+ aiNoHistory: '还没有会话,从下方开始提问',
+ aiDeleteConfirm: '删除该会话及全部消息?',
+ aiNoProject: '不绑定项目',
+ aiNeedProject: '请先在上方选择一个项目',
+ aiQuickProject: '项目分析',
+ aiQuickGit: 'Git 贡献',
+ aiQuickTodo: 'Todo 分析',
+ aiQuickTicket: '需求分析',
+ aiPromptProject: '请分析这个项目的技术栈、代码规模和质量风险,并给出改进建议。',
+ aiPromptGit: '请分析这个项目的 Git 贡献情况:贡献者结构、活跃度和热点文件风险。',
+ aiPromptTodo: '请分析这个项目的待办事项:优先级是否合理、有哪些逾期风险、建议的执行顺序。',
+ aiPromptTicket: '请从需求管理角度分析这个项目的工单:排期合理性、类型分布和处理顺序建议。',
+ aiWelcome: '选择项目后可使用快捷分析,也可以直接提问',
+ aiAskPlaceholder: '输入问题,Enter 发送,Shift+Enter 换行',
+ aiSend: '发送',
+ aiStop: '停止',
+ aiNoKeyTitle: '尚未配置 AI Key',
+ aiNoKeyHint: '前往设置填写讯飞星火或 DeepSeek 的 API Key 后即可使用 AI 分析。',
+ aiConfigureNow: '去配置',
+ aiAnalysis: 'AI 分析',
+ batchSummaryTitle: '批量统计结果',
+ batchTotal: '共 {n} 个项目',
+ batchOk: '成功',
+ batchFail: '失败',
+ batchCancelled: '取消',
+ batchDoneToast: '批量统计完成:{completed}/{total} 成功',
+ fileHotspots: '文件热点',
+ fileHotspotsHint: '按历史变更次数排序',
+ changesUnit: '次变更',
+ previewLoading: '正在读取文件...',
+ clickPreview: '点击预览文件',
+ previewBinary: '二进制文件,无法预览',
+ previewTruncated: '文件过大,仅显示前 1MB',
+ copyContent: '复制内容',
+ copied: '已复制到剪贴板',
+ copyFailed: '复制失败',
+ settingsSubtitle: '管理排除规则、界面和数据库配置',
+ tabRules: '排除规则',
+ tabAppearance: '界面设置',
+ tabSync: '登录与同步',
+ tabDatabase: '数据管理',
+ aboutSlogan: '本地优先的一站式项目工作台',
+ aboutIntro: '年糕崽崽项目管理(NL PMS)是一款本地优先的桌面项目工作台:一键统计代码规模与语言分布、洞察 Git 协作热度,聚合待办、工单与排期日历,内置 AI 帮你解读项目、规划每一天。数据默认保存在本地 SQLite,登录后可加密同步到云端,多台设备无缝衔接。',
+ aboutFeatCode: '代码统计',
+ aboutFeatGit: 'Git 分析',
+ aboutFeatTask: '待办与工单',
+ aboutFeatCal: '排期日历',
+ aboutFeatAI: 'AI 分析',
+ aboutFeatSync: '加密云同步',
+ aboutNameTitle: '名字的由来',
+ aboutNameStory: 'NL 是"奶酪"的拼音缩写,年糕崽崽则是一只圆脸短腿的拿破仑小黄猫——它最喜欢趴在奶酪上打盹。就像这只小猫守着它的奶酪,NL PMS 替你守着每一行代码、每一个待办。',
+ aboutFoot: '用心打磨 · 年糕崽崽出品',
+ addRule: '添加排除规则',
+ rulePatternPh: '例如 *.log, cache, temp',
+ catGeneral: '通用',
+ catCustom: '自定义',
+ ruleHint: '支持 * 通配符;目录名会在任意层级匹配',
+ rulesCount: '{n} 条规则',
+ builtinTag: '默认',
+ langThemeTitle: '语言与主题',
+ uiLanguage: '界面语言',
+ gitScopeDefault: 'Git 默认范围',
+ scopeCurrent: '当前分支',
+ scopeAll: '所有分支',
+ loadingStyleOrbit: '能量轨道',
+ loadingStyleOrbitDesc: '环形粒子、扫描光束和能量核心',
+ loadingStyleGrid: '数据星云',
+ loadingStyleGridDesc: '粒子流场汇聚成星云与光核',
+ loadingStyleWarp: '星际穿越',
+ loadingStyleWarpDesc: '高速星流粒子拉伸出光迹',
+ loadingStyleMatrix: '黑客帝国',
+ loadingStyleMatrixDesc: '绿色数字雨倾泻而下',
+ loadingStyleBar: '底部进度条',
+ loadingStyleBarDesc: '保留当前页面,只显示底部进度',
+ sysIntegration: '系统集成',
+ onWindowClose: '关闭窗口时',
+ closeToTray: '最小化到系统托盘',
+ closeQuit: '直接退出应用',
+ autostartLabel: '开机自启',
+ autostartOnBtn: '已开启,点击关闭',
+ autostartOffBtn: '已关闭,点击开启',
+ autostartOnHint: '登录系统后自动启动年糕崽崽 PMS',
+ autostartOffHint: '不随系统启动',
+ autostartOnToast: '已开启开机自启',
+ autostartOffToast: '已关闭开机自启',
+ autostartFail: '开机自启设置失败:{err}',
+ autoUpdateTitle: '自动更新项目',
+ autoUpdateEnable: '定时批量统计',
+ optOn: '开启',
+ optOff: '关闭',
+ updateFreq: '更新频率',
+ freqDaily: '每天',
+ freqNDays: '每 N 天',
+ freqNHours: '每 N 小时',
+ intervalN: '间隔 N',
+ triggerTime: '触发时刻',
+ autoUpdateHint: '到达设定时间后自动对所有项目执行批量统计,结果写入运行日志。',
+ previewModeTitle: '当前是浏览器预览模式',
+ previewModeSync: '浏览器无法连接 MySQL,请运行 code-count.exe 使用同步功能。',
+ previewModeDb: '浏览器无法访问本地数据库和文件选择器,请运行 code-count.exe 使用数据库功能。',
+ accountSync: '账号与同步',
+ menuAccount: '账号',
+ loginOrRegister: '登录 / 注册',
+ registerAndLogin: '注册并登录',
+ loginModalHint: '登录后 Todo、工单、记事本、API Key 与头像会随账号在设备间同步',
+ switchToRegister: '还没有账号?立即注册',
+ loginWelcome: '欢迎回来',
+ registerWelcome: '创建新账号',
+ confirmPh: '再次输入密码',
+ showPassword: '显示密码',
+ hidePassword: '隐藏密码',
+ dailyCard: '每日心语',
+ dailyYi: '宜',
+ dailyJi: '忌',
+ dailyGotIt: '今日已读',
+ dailyTodayBtn: '今日心语',
+ dailyViewBtn: '查看当日心语',
+ dailyHistory: '历史',
+ dailyFuture: '未来的心语还没写好',
+ dailyPrevDay: '前一天',
+ dailyNextDay: '后一天',
+ festImgTitle: '节日格样式(管理员)',
+ festImgSet: '设置图片',
+ festImgReplace: '更换',
+ festImgRemove: '移除',
+ festImgSaved: '节日图片已保存,将同步给所有用户',
+ festImgRemoved: '节日图片已移除',
+ festModeArtCard: '动态插画',
+ festModePhotoCard: '自定义图片',
+ fieldUser: '用户名',
+ fieldPass: '密码',
+ fieldConfirm: '确认密码',
+ dqToday: '今天',
+ dqTomorrow: '明天',
+ dqDayAfter: '后天',
+ dqDays: '{n}天后',
+ dqSuffix: '天后',
+ dqAddTip: '添加自定义天数',
+ dqRemove: '删除标签',
+ loginTagline: '本地优先\n云端随行',
+ loginSecureBadge: 'API Key 加密存储',
+ switchToLogin: '已有账号?直接登录',
+ usernamePh: '用户名(3-64 个字符)',
+ passwordPh: '密码(至少 6 位)',
+ loginBtn: '登录',
+ loggingIn: '登录中…',
+ registerBtn: '注册新账号',
+ registering: '注册中…',
+ syncLoginHint: '登录后 Todo、工单、记事本会自动同步到服务器(每 5 分钟一次);离线时数据保存在本地,联网后自动补同步。',
+ notLoggedIn: '未登录',
+ loginNow: '点击登录账号',
+ pendingSync: '{n} 条改动待同步',
+ close: '关闭',
+ ctxNewTodo: '新建待办',
+ ctxNewTicket: '新建工单',
+ ctxNewReminder: '快速提醒',
+ ctxNeedProject: '需要先创建项目才能建工单',
+ quickTitlePh: '输入标题,Enter 创建',
+ quickCreate: '创建',
+ quickCreated: '已创建',
+ yesterday: '昨天',
+ justNow: '刚刚',
+ minAgo: '{n} 分钟前',
+ hourAgo: '{n} 小时前',
+ dayAgo: '{n} 天前',
+ copyBtn: '复制',
+ copiedShort: '已复制',
+ noLogsHint: '应用运行事件会记录在这里',
+ searchPlaceholder: '搜索项目、待办、工单、AI 会话…',
+ searchEmpty: '没有匹配的结果',
+ searchHint: '输入关键字开始搜索,↑↓ 选择,Enter 打开',
+ kindProject: '项目',
+ kindTodo: '待办',
+ kindTicket: '工单',
+ kindConversation: 'AI 会话',
+ changePasswordTitle: '修改密码',
+ oldPasswordLabel: '旧密码',
+ newPasswordLabel: '新密码',
+ confirmPasswordLabel: '确认新密码',
+ changePasswordBtn: '确认修改',
+ changingPassword: '修改中…',
+ passwordChangedToast: '密码已修改',
+ passwordMismatch: '两次输入的新密码不一致',
+ changePasswordHint: '修改密码需要在线。云端 API Key 会用新密码派生的密钥重新加密;其他已登录设备需用新密码重新登录,否则无法解密云端 Key。',
+ profileTitle: '个人资料',
+ avatarSource: '头像来源',
+ avatarNone: '不设置头像',
+ avatarBase64: '本地图片(存为 Base64,随账号同步)',
+ avatarUrl: '图片 URL(OSS / 图床直链)',
+ avatarPath: '本地图片路径(仅本机显示)',
+ avatarUrlLabel: '图片地址',
+ avatarUrlPh: '例如 https://bucket.oss-cn-hangzhou.aliyuncs.com/avatar.png',
+ avatarPick: '选择图片',
+ avatarHint: '图片会缩放到 256px 以内。Base64 与 URL 模式登录后会随账号同步到其他设备;本地路径模式只在本机生效,不参与同步。',
+ contentImageStore: '内容图片存储',
+ imgModeBase64: '内嵌 Base64(随内容同步)',
+ imgModePath: '本地文件(仅本机显示)',
+ imgModeServer: '上传到服务器(跨设备可见)',
+ contentImageHint: '待办 / 工单正文里粘贴或插入的图片按此方式保存:内嵌 Base64 会随内容同步到云端;本地文件体积更小,但换设备后无法显示;上传到服务器后以链接引用,任何设备都能访问。图片统一压缩到 1100px 以内。',
+ avatarServer: '上传到服务器(管理员配置的文件服务)',
+ fsTitle: '文件存储(全局)',
+ fsHint: '管理员专属:配置随同步下发全员生效。服务器模式下内容图与头像上传到文件服务,跨设备可访问',
+ fsMode: '存储方式',
+ fsModeLocal: '本地(默认)',
+ fsModeServer: '服务器(nl-pms-api)',
+ fsBaseUrl: '服务器地址',
+ fsApiKey: '上传密钥',
+ fsApiKeyPh: '与 nl-pms-api 配置文件里的 api_key 一致',
+ fsTestBtn: '测试连接',
+ fsTesting: '测试中…',
+ fsSaveBtn: '保存配置',
+ fsSavedToast: '文件存储配置已保存,立即全员生效',
+ fsTestOkToast: '文件服务器连接成功',
+ tabFileStorage: '文件存储',
+ fsPageHint: '配置保存在服务器数据库,保存后立即全员生效:每个客户端上传图片时都会实时读取该配置决定存储位置,无需等待同步。',
+ fsAdminOnlyTitle: '仅管理员可配置',
+ fsAdminOnlyDesc: '只有管理员账号(ID=1)可以修改全局文件存储方式,请联系管理员。',
+ storageFollowHint: '头像与正文图片的存储方式由管理员统一配置,无需手动选择。当前:{mode}。',
+ avatarClear: '清除头像',
+ quitApp: '退出应用',
+ quitConfirm: '确定退出应用?',
+ assetsTab: '素材库',
+ assetsHint: '管理上传到文件服务器的图片:本人管自己的,团队管理员管团队的,管理员(id=1)管全部',
+ assetsNeedServer: '未启用服务器存储。管理员在「设置 → 文件存储」切到服务器模式后,上传的图片会在这里展示。',
+ assetsScopeMine: '我的上传',
+ assetsScopeTeam: '团队素材',
+ assetsScopeAll: '全部(管理员)',
+ assetsCount: '共 {n} 张',
+ assetsRefresh: '刷新',
+ assetsEmpty: '还没有图片。待办 / 工单正文粘贴的图片或上传的头像会出现在这里。',
+ assetsLoadMore: '加载更多',
+ assetsCopy: '复制链接',
+ assetsCopiedToast: '链接已复制',
+ assetsCopyFailed: '复制失败',
+ assetsDeleteConfirm: '删除这张图片?引用它的内容将无法再显示。',
+ assetsDeletedToast: '图片已删除',
+ assetsKindAvatar: '头像',
+ assetsKindContent: '内容图',
+ mdEdit: '编辑',
+ mdPreviewTab: '预览',
+ insertImage: '插图',
+ mdInserting: '处理中…',
+ mdPlaceholder: '支持 Markdown:**加粗**、- 列表、`代码`、[链接](url);图片可直接粘贴或点右上角插图',
+ mdEmpty: '*(暂无内容)*',
+ lifecycle: '生命周期',
+ lifecycleCreated: '创建',
+ lifecycleNow: '进行中…',
+ lifecycleTotal: '从创建到完结,共 {dur}',
+ durMoment: '片刻',
+ durMin: '{n} 分钟',
+ durHour: '{n} 小时',
+ durDay: '{n} 天',
+ taskCenter: '任务中心',
+ taskDoingGroup: '进行中',
+ taskPendingGroup: '待开始',
+ taskStart: '开始',
+ taskDone: '完成',
+ taskResolve: '解决',
+ taskEmpty: '没有待处理的任务',
+ aiBriefProject: 'AI 项目介绍',
+ aiBriefGit: 'AI Git 介绍',
+ aiBriefStructure: 'AI 结构分析',
+ aiBriefInsights: 'AI 检查解读',
+ aiBriefEmpty: '尚未生成,更新项目数据后将自动生成',
+ aiBriefGenerating: 'AI 正在生成介绍…',
+ aiBriefRegen: '重新生成',
+ aiAskThisProject: '就这个项目问点什么…',
+ aiAskGo: '问一问',
+ aiAskDrawer: 'AI 问答',
+ aiOpenFull: '完整页面',
+ aiLoadEarlier: '加载更早的 {n} 条消息',
+ online: '在线',
+ offline: '离线',
+ lastSync: '上次同步:{time}',
+ profilePage: '个人主页',
+ profileSubtitle: '账号信息、头像与数据同步',
+ profileSecurity: '账号安全',
+ profileGuest: '登录后可同步数据、设置头像与云端资料',
+ profileTabSync: '数据同步',
+ avatarEdit: '编辑头像',
+ avatarModalTitle: '头像与图片',
+ accountLabel: '登录账号',
+ connectionState: '连接状态',
+ lastSyncLabel: '上次同步',
+ pendingLabel: '待推送',
+ profileSyncHint: '登录后,待办、工单、设置、收藏、项目定义与文档会自动在设备间同步;离线的修改会在网络恢复后自动补推。',
+ greetMorning: '早上好',
+ greetNoon: '中午好',
+ greetAfternoon: '下午好',
+ greetEvening: '晚上好',
+ greetNight: '夜深了',
+ phDoneTodos: '已完成待办',
+ phResolvedTickets: '已解决工单',
+ pwdWeak: '较弱',
+ pwdMedium: '中等',
+ pwdStrong: '强',
+ syncScope: '同步范围',
+ scopeFavs: '收藏',
+ scopeDocs: '文档',
+ wbAiPlan: 'AI 今日规划',
+ wbPlanGen: '生成规划',
+ wbPlanRegen: '重新规划',
+ wbReport: '下班日报',
+ wbReportGen: '下班总结',
+ wbGenerating: '生成中…',
+ wbPlanEmpty: '点击「生成规划」,AI 会根据今天的待办与工单为你安排优先级',
+ wbReportEmpty: '下班前点一下「下班总结」,AI 把今天处理的事项写成一段日报',
+ wbAiNoKey: '配置 AI Key 后,可让 AI 生成今日工作规划与下班日报',
+ pendingPush: '待推送 {n} 条',
+ neverSynced: '尚未同步',
+ syncNowBtn: '立即同步',
+ syncingBtn: '同步中…',
+ logoutBtn: '退出登录',
+ registerOkToast: '注册成功',
+ loginOkToast: '登录成功,正在同步数据',
+ logoutToast: '已退出登录',
+ syncDoneToast: '同步完成:推送 {pushed} 条,拉取 {pulled} 条',
+ syncFailToast: '同步失败',
+ aiProviderTitle: 'AI 服务商',
+ aiCurrentProvider: '当前服务商',
+ aiSparkOption: '讯飞星火 Lite(免费)',
+ sparkKeyLabel: '星火 APIPassword',
+ sparkKeyPh: '讯飞开放平台 → Spark Lite → HTTP 服务接口认证信息',
+ deepseekKeyLabel: 'DeepSeek API Key',
+ deepseekKeyPh: 'platform.deepseek.com → API Keys',
+ aiKeyHint: 'Key 默认只保存在本地数据库。星火 Lite 免费(spark-api-open.xf-yun.com),DeepSeek 按量计费(api.deepseek.com)。配置后到「AI 分析」页即可对项目、Git 贡献、待办与工单做智能分析。',
+ syncApiKeysLabel: '同步 API Key 到服务器',
+ syncApiKeysOn: '开启(加密存储)',
+ syncApiKeysOff: '关闭(仅保存在本地)',
+ syncApiKeysHint: '开启后 API Key 会用登录密码派生的密钥加密(AES-256-GCM)再上传,服务器无法解密;换设备用同一账号登录即可自动取回。需要先登录。',
+ dbLocation: '数据库位置',
+ dbConnected: '已连接',
+ dbCurrentLoc: '当前位置',
+ dbNoPath: '未获取到数据库路径',
+ copyPathTitle: '复制路径',
+ dbMigratedMsg: '数据库已迁移并切换到新位置',
+ dbMigrateFail: '数据库迁移失败',
+ dbPathCopied: '数据库路径已复制',
+ dbCopyFail: '无法复制路径',
+ migrateBtn: '选择新位置并迁移',
+ dangerZone: '清空数据',
+ dangerDesc: '选择要清空的数据类型,此操作不可恢复。',
+ clearStats: '清空统计数据',
+ clearStatsDesc: '删除分析记录,保留项目配置',
+ clearProject: '清空单个项目',
+ clearProjectDesc: '删除指定项目的统计数据',
+ clearAllData: '清空所有数据',
+ clearAllDesc: '删除所有项目和分析记录',
+ projectIdPrompt: '项目 ID',
+ confirmIrreversible: '此操作不可恢复,确认继续?',
+ srcLocal: '本地目录',
+ srcGit: '从 Git 克隆',
+ cloneUrl: 'Git 仓库地址',
+ cloneDir: '克隆到',
+ cloneDirHint: '选择存放仓库的父目录',
+ cloneAndAdd: '克隆并添加',
+ cloning: '克隆中...',
+ cloneDone: '克隆完成,项目已添加',
+ cloneUrlRequired: '请填写 Git 仓库地址',
+ cloneDirRequired: '请选择克隆目录',
errors: {
+ URL_REQUIRED: '请填写 Git 仓库地址',
+ DIR_NOT_FOUND: '克隆目录不存在',
+ NAME_REQUIRED: '无法从地址推导项目名,请手动填写',
+ TARGET_NOT_EMPTY: '目标目录已存在且非空',
+ SYNC_NOT_CONFIGURED: '请先填写完整的 MySQL 服务器配置',
+ SYNC_OFFLINE: '无法连接服务器,请检查网络与配置',
+ SYNC_SCHEMA_MISSING: '数据库或数据表不存在,请先在服务器执行 init.sql 初始化脚本',
+ SYNC_SCHEMA_OUTDATED: '云端表结构需要升级,请在服务器重新执行最新的 init.sql',
+ SYNC_USER_EXISTS: '用户名已被注册',
+ SYNC_BAD_CREDENTIALS: '用户名或密码错误',
+ SYNC_USERNAME_INVALID: '用户名需为 3-64 个字符',
+ SYNC_PASSWORD_TOO_SHORT: '密码至少 6 位',
+ SYNC_NOT_LOGGED_IN: '请先登录',
+ SYNC_OLD_PASSWORD_WRONG: '旧密码错误',
+ SYNC_IN_PROGRESS: '同步正在进行中',
+ SYNC_DECRYPT_FAILED: '云端 API Key 解密失败,请重新登录后再同步',
+ AVATAR_FILE_TOO_LARGE: '图片超过 10MB,请换一张更小的图片',
+ AVATAR_DECODE_FAILED: '无法识别的图片格式(支持 PNG / JPG / GIF / WebP)',
+ FILE_STORAGE_ADMIN_ONLY: '只有管理员(id=1)可以配置文件存储',
+ FILE_STORAGE_BAD_URL: '服务器地址无效,需以 http(s):// 开头',
+ FILE_STORAGE_UNREACHABLE: '无法连接文件服务器,请检查地址与服务状态',
+ FILE_API_UNCONFIGURED: '服务器存储未启用,请联系管理员配置文件存储',
+ IMAGE_UPLOAD_FAILED: '图片上传失败,请检查文件服务器后重试',
+ FILE_PERMISSION_DENIED: '没有权限操作这个文件',
+ FILE_API_REQUEST_FAILED: '文件服务器请求失败,请稍后重试',
+ FILE_NOT_FOUND: '文件不存在',
+ FILE_IS_DIRECTORY: '该路径是目录',
+ FILE_PATH_INVALID: '非法文件路径',
+ FILE_READ_FAILED: '文件读取失败',
+ FILE_PATH_REQUIRED: '文件路径不能为空',
+ TODO_TITLE_REQUIRED: '请输入待办标题',
+ TODO_STATUS_INVALID: '无效的待办状态',
+ TICKET_TITLE_REQUIRED: '请输入工单标题',
+ TICKET_PROJECT_REQUIRED: '工单必须绑定一个项目',
+ TICKET_SCHEDULE_REQUIRED: '工单必须填写排期(开始与截止日期)',
+ TICKET_SCHEDULE_INVALID: '截止日期不能早于开始日期',
+ TICKET_STATUS_INVALID: '无效的工单状态',
+ AI_PROVIDER_INVALID: '无效的 AI 服务商',
+ AI_KEY_MISSING: '尚未配置 AI API Key',
+ AI_EMPTY_RESPONSE: 'AI 返回了空内容,请重试',
+ TEAM_SCHEMA_MISSING: '服务器缺少团队数据表,请重新执行 init.sql',
+ TEAM_FORBIDDEN: '没有权限执行此团队操作',
+ TEAM_NAME_INVALID: '团队名称需为 1-64 个字符',
+ TEAM_TIME_INVALID: '时间格式应为 HH:MM',
+ TEAM_OWNER_CANNOT_LEAVE: '拥有者不能退出,请先解散团队',
+ TEAM_USER_NOT_FOUND: '没有找到这个用户名的账号',
+ TEAM_ALREADY_MEMBER: '该用户已在团队中',
+ TEAM_ROLE_INVALID: '无效的角色设置',
+ TEAM_TASK_TITLE_REQUIRED: '请输入任务标题',
+ TEAM_TASK_NOT_FOUND: '任务不存在或已删除',
+ TEAM_ASSIGNEE_INVALID: '负责人不是团队成员',
+ TEAM_STATUS_INVALID: '无效的任务状态',
+ TEAM_NO_ASSIGNEE: '任务还没有负责人,无法催办',
+ TEAM_DATE_INVALID: '日期格式应为 YYYY-MM-DD',
+ TEAM_REPORT_EMPTY: '日报内容不能为空',
+ TEAM_NO_REPORTS: '当天还没有成员提交日报',
+ PROFILE_EMAIL_INVALID: '邮箱格式不正确',
+ AI_KEY_INVALID: 'API Key 无效或已过期',
+ AI_NETWORK_ERROR: '无法连接 AI 服务,请检查网络',
+ AI_EMPTY_MESSAGE: '请输入内容',
+ AI_STREAM_RUNNING: '当前会话正在回复中',
+ AI_CONVERSATION_NOT_FOUND: '会话不存在',
+ AI_CANCELLED: '已停止回复',
+ AI_STREAM_INTERRUPTED: 'AI 回复中断',
PROJECT_PATH_NOT_FOUND: '项目目录不存在',
PROJECT_PATH_UNREADABLE: '项目目录不可读取',
PROJECT_PATH_NOT_DIRECTORY: '选择的路径不是目录',
@@ -170,15 +860,253 @@ const zh = {
git: '正在读取 Git 历史',
completed: '分析完成',
failed: '分析失败',
- cancelled: '分析已取消'
+ cancelled: '分析已取消',
+ cloning: '正在克隆 {project}',
+ cloneDone: '克隆完成',
+ cloneFailed: '克隆失败'
}
}
const en = {
- app: 'Code Count',
+ app: '年糕崽崽 PMS',
dashboard: 'Dashboard',
logs: 'Activity Log',
settings: 'Settings',
+ navOverview: 'Overview',
+ navProjects: 'Projects',
+ navWork: 'Work',
+ navSystem: 'System',
+ launchpad: 'Launchpad',
+ launchpadSubtitle: 'Local service ports & app start/stop',
+ lpMyApps: 'My apps',
+ lpScanned: 'Detected services',
+ lpAddApp: 'Add app',
+ lpEditApp: 'Edit app',
+ lpRefresh: 'Refresh',
+ lpShowSys: 'Show system processes',
+ lpName: 'Name',
+ lpKind: 'Kind',
+ lpPort: 'Port',
+ lpDir: 'Working directory',
+ lpStartCmd: 'Start command',
+ lpStopCmd: 'Stop command',
+ lpStopBlank: 'Blank = kill the process tree',
+ lpSuggest: 'Suggested',
+ lpStart: 'Start',
+ lpStop: 'Stop',
+ lpPin: 'Save as app',
+ lpRunning: 'Running',
+ lpStopped: 'Stopped',
+ lpMem: 'Memory',
+ lpNeedCmd: 'Pick or enter a start command first',
+ lpStopConfirm: 'Stop {name}?',
+ lpDelConfirm: 'Delete app {name}? (running process is kept)',
+ lpEmptyApps: 'No saved apps yet — pin one from the detected list or add manually',
+ lpEmptyScan: 'No listening services detected',
+ workbench: 'Workbench',
+ workbenchSubtitle: 'Favorites, today\'s tasks and quick notes',
+ todos: 'Todos',
+ todosSubtitle: 'Manage todos and track progress',
+ tickets: 'Tickets',
+ ticketsSubtitle: 'Tickets are bound to a project and schedule',
+ calendar: 'Calendar',
+ calendarSubtitle: 'Todo deadlines and ticket schedules',
+ statsOverview: 'Overview',
+ statsCollapseBtn: 'Collapse',
+ statsExpandBtn: 'Show stats',
+ calJumpPh: 'Go to 0501 / 2026-05-01',
+ calJumpTitle: 'Type a date and press Enter; year defaults to the current view',
+ calBadDate: 'Unrecognized date format',
+ dpClear: 'Clear',
+ messages: 'Messages',
+ boardView: 'Board',
+ listView: 'List',
+ addTodo: 'New todo',
+ editTodo: 'Edit todo',
+ quickAddTodo: 'Quick add todo, press Enter...',
+ todoTitle: 'Title',
+ todoContent: 'Notes (optional)',
+ relatedProject: 'Related project',
+ noProject: 'No project',
+ selectProject: 'Select a project',
+ dueDate: 'Due',
+ startDate: 'Start date',
+ priorityLabel: 'Priority',
+ statusLabel: 'Status',
+ noTodos: 'No todos yet',
+ todoStatus: { open: 'Open', doing: 'Doing', done: 'Done' },
+ priority: { low: 'Low', medium: 'Medium', high: 'High' },
+ addTicket: 'New ticket',
+ editTicket: 'Edit ticket',
+ ticketTitle: 'Ticket title',
+ ticketDesc: 'Description',
+ ticketTypeLabel: 'Type',
+ ticketType: { feature: 'Feature', bug: 'Bug', task: 'Task', improvement: 'Improvement' },
+ ticketStatus: { open: 'Open', in_progress: 'In progress', resolved: 'Resolved', closed: 'Closed' },
+ ticketFlow: { start: 'Start', resolve: 'Resolve', close: 'Close', reopen: 'Reopen' },
+ noTickets: 'No tickets yet',
+ today: 'Today',
+ selectDate: 'Select a date',
+ noSchedule: 'Nothing scheduled',
+ weekdays: { sun: 'S', mon: 'M', tue: 'T', wed: 'W', thu: 'T', fri: 'F', sat: 'S' },
+ favoriteProjects: 'Favorite projects',
+ noFavorites: 'No favorites yet. Star a project card to pin it here.',
+ unfavorite: 'Unfavorite',
+ favorite: 'Favorite',
+ openTodos: 'Open todos',
+ openTickets: 'Active tickets',
+ todayTodos: 'Due today',
+ noTodayTodos: 'Nothing due today',
+ weekTickets: 'Due this week',
+ noWeekTickets: 'No tickets due this week',
+ notepad: 'Notepad',
+ notepadPlaceholder: 'Jot something down, autosaved...',
+ autoSaved: 'Autosaved',
+ noteCenter: 'Notes',
+ noteNew: 'New note',
+ noteEdit: 'Edit note',
+ noteEmpty: 'No notes yet — create one from the top right',
+ noteUntitled: '(Blank note)',
+ noteDeleteConfirm: 'Delete this note?',
+ notesPage: 'Notes',
+ notesSubtitle: '{n} notes in total, click a card to edit',
+ noteSearchPh: 'Search notes…',
+ viewAll: 'View all',
+ todayTasks: 'Today',
+ todaySubtitle: 'Everything due or in progress today',
+ secOverdue: 'Overdue',
+ secToday: 'Due today',
+ secDoing: 'In progress',
+ secUpcoming: 'Next 7 days',
+ todayEmpty: 'Nothing needs your attention today. Enjoy!',
+ messagesSubtitle: 'All notifications and alerts',
+ msgUnreadOnly: 'Unread only',
+ msgKindAll: 'All',
+ msgKind: { todo_due: 'Todo alerts', ticket_due: 'Ticket alerts', analysis: 'Analysis', sync: 'Sync' },
+ aiScopeBtn: 'AI Summary',
+ aiScopeEmpty: 'No summary yet — let AI analyze this page below.',
+ aiScopeGen: 'Generate',
+ aiScopeRegen: 'Regenerate',
+ cloudPendingTitle: 'Cloud projects to bind',
+ cloudPendingDesc: 'These projects were synced from your other computers. Pick a local folder to continue.',
+ cloudBindBtn: 'Bind folder',
+ cloudBindTitle: 'Bind cloud project locally',
+ cloudBindDone: 'Cloud project bound, ready to analyze',
+ navTeam: 'Team',
+ teamHome: 'Team overview',
+ teamHomeSubtitle: 'Members, roles and team settings',
+ teamTasks: 'Team tasks',
+ teamTasksSubtitle: 'Assign, track and nudge team tasks',
+ teamReports: 'Team reports',
+ teamReportsSubtitle: 'Daily reports and AI digest',
+ teamLoginHint: 'Teams require signing in (members share one MySQL server)',
+ teamNoneHint: 'You are not in any team yet — create one',
+ teamNamePh: 'Team name…',
+ teamCreateBtn: 'Create team',
+ teamCreateMore: 'Create another team',
+ teamCreatedToast: 'Team created',
+ teamGoHome: 'Open team page',
+ teamSwitcher: 'Switch team',
+ teamSwitchedToast: 'Switched to {name}',
+ teamName: 'Team name',
+ teamRenameBtn: 'Rename',
+ teamRole_owner: 'Owner',
+ teamRole_admin: 'Admin',
+ teamRole_member: 'Member',
+ teamMembersCount: '{n} members',
+ teamDigestTime: 'Digest time',
+ teamDigestTimeHint: 'Admin clients auto-generate the daily digest after this time',
+ teamInviteBtn: 'Invite member',
+ teamInviteUser: 'Username',
+ teamInviteUserPh: 'Their sign-in username',
+ teamInviteRole: 'Role',
+ teamInvitedToast: 'Member added',
+ teamMakeAdmin: 'Make admin',
+ teamMakeMember: 'Make member',
+ teamRemoveBtn: 'Remove from team',
+ teamRemoveConfirm: 'Remove {name} from the team?',
+ teamLeaveBtn: 'Leave team',
+ teamLeaveConfirm: 'Leave this team?',
+ teamDissolveBtn: 'Dissolve team',
+ teamDissolveConfirm: 'Dissolve "{name}"? All team tasks and reports will be deleted',
+ teamFilter_all: 'All',
+ teamFilter_mine: 'Assigned to me',
+ teamFilter_created: 'Created by me',
+ teamFilter_open: 'Open',
+ teamTaskNew: 'New task',
+ teamTaskEdit: 'Edit task',
+ teamTaskTitlePh: 'What needs doing…',
+ teamTaskDesc: 'Description (Markdown supported)',
+ teamKindTodo: 'Todo',
+ teamKindTicket: 'Ticket',
+ teamQuickCreate: 'Quick create task / ticket',
+ teamQuickCreateBtn: 'Create & assign',
+ teamQuickFor: 'Create for {name}',
+ teamQuickDoneToast: 'Created and assigned to {name}',
+ teamTasksEmpty: 'No team tasks yet',
+ teamStatus_open: 'Open',
+ teamStatus_doing: 'In progress',
+ teamStatus_done: 'Done',
+ teamStatus_closed: 'Closed',
+ teamReopen: 'Reopen',
+ teamAssignee: 'Assignee',
+ teamCreator: 'Creator',
+ teamUnassigned: 'Unassigned',
+ teamUrgeBtn: 'Nudge',
+ teamUrgedToast: 'Reminder sent',
+ teamUrgedAt: 'Nudged at',
+ teamTaskDeleteConfirm: 'Delete task "{title}"?',
+ teamSharedItems: 'Personal items shared by members',
+ teamSharedEmpty: 'No shared todos or tickets yet',
+ teamMyReport: 'My report',
+ teamReportPh: 'What you did today, blockers, plan for tomorrow… (Markdown supported)',
+ teamReportSubmitBtn: 'Submit report',
+ teamReportUpdateBtn: 'Update report',
+ teamReportSubmittedToast: 'Report submitted',
+ teamReportSubmittedAt: 'Submitted at {at}',
+ teamReportNotSubmitted: 'Not submitted yet',
+ teamReportMissing: 'Missing',
+ teamReportContentHidden: 'Content visible to admins only',
+ teamQuoteDayReport: 'Quote day summary',
+ teamQuoteDayReportHint: 'Insert the workbench end-of-day report',
+ teamNoDayReport: 'No end-of-day summary yet — generate it on the workbench first',
+ teamDigest: 'AI team digest',
+ teamDigestNone: 'Summarize all member reports for the day',
+ teamDigestAt: 'Generated {at} by {provider}',
+ teamDigestGen: 'Generate digest',
+ teamDigestRegen: 'Regenerate',
+ teamDigestStartedToast: 'Generating digest, it will refresh automatically',
+ teamDigestDoneToast: 'Team digest ready',
+ generating: 'Generating…',
+ prevDay: 'Previous day',
+ nextDay: 'Next day',
+ retry: 'Retry',
+ loading: 'Loading…',
+ noDescription: '(No description)',
+ savedToast: 'Saved',
+ profileTabInfo: 'Profile',
+ profileTabTeams: 'My teams',
+ profileInfoHint: 'Visible to teammates on the same server',
+ profileNickname: 'Nickname',
+ profileNicknamePh: 'What should we call you',
+ profileJobTitle: 'Title',
+ profileJobTitlePh: 'e.g. Frontend engineer',
+ profileEmail: 'Email',
+ profileBio: 'Bio',
+ profileBioPh: 'One line about yourself…',
+ profileTags: 'Tech stack tags',
+ profileTagsPh: 'Press Enter to add…',
+ profileTagsHint: 'Up to 20 tags; Enter or comma to add, × to remove',
+ profileSaveBtn: 'Save profile',
+ profileSavedToast: 'Profile saved',
+ profileTeamsHint: 'Switch current team or create a new one',
+ shareToTeam: 'Share with team',
+ sharePrivate: 'Private',
+ sharedToTeamToast: 'Sharing updated',
+ recentMessages: 'Recent messages',
+ noMessages: 'No messages',
+ markAllRead: 'Mark all read',
+ clearMessages: 'Clear messages',
projects: 'Projects',
addProject: 'Add project',
batch: 'Analyze all',
@@ -212,6 +1140,7 @@ const en = {
editProjectTitle: 'Edit project',
projectGroup: 'Project group',
allProjectGroups: 'All project groups',
+ allProjects: 'All projects',
myProjectGroup: 'My project group',
addProjectGroup: 'Add project group',
editProjectGroup: 'Edit project group',
@@ -300,7 +1229,447 @@ const en = {
warning: 'Warning',
error: 'Error',
noLogs: 'No logs yet',
+ searchLogs: 'Search logs...',
+ allCategories: 'All categories',
+ menuFile: 'File',
+ menuView: 'View',
+ menuTools: 'Tools',
+ menuHelp: 'Help',
+ menuDatabase: 'Data management',
+ menuQuit: 'Quit',
+ menuAnalyzeNow: 'Analyze all now',
+ menuAbout: 'About',
+ aiChat: 'AI Analysis',
+ aiSubtitle: 'Project-aware analysis and chat',
+ aiSparkLite: 'Spark Lite',
+ aiKeys: 'API keys',
+ aiNewChat: 'New chat',
+ aiHistory: 'History',
+ aiNoHistory: 'No conversations yet — ask below',
+ aiDeleteConfirm: 'Delete this conversation and all messages?',
+ aiNoProject: 'No project',
+ aiNeedProject: 'Select a project above first',
+ aiQuickProject: 'Project analysis',
+ aiQuickGit: 'Git contribution',
+ aiQuickTodo: 'Todo analysis',
+ aiQuickTicket: 'Ticket analysis',
+ aiPromptProject: 'Analyze this project: tech stack, code size and quality risks, with improvement suggestions.',
+ aiPromptGit: 'Analyze the Git contribution of this project: contributor structure, activity and hotspot risks.',
+ aiPromptTodo: 'Analyze the todos of this project: priority sanity, overdue risks and a suggested execution order.',
+ aiPromptTicket: 'Analyze the tickets of this project from a requirements perspective: scheduling, type distribution and handling order.',
+ aiWelcome: 'Pick a project for quick analysis, or just ask anything',
+ aiAskPlaceholder: 'Type a question. Enter to send, Shift+Enter for newline',
+ aiSend: 'Send',
+ aiStop: 'Stop',
+ aiNoKeyTitle: 'No AI key configured',
+ aiNoKeyHint: 'Add your Xunfei Spark or DeepSeek API key in Settings to use AI analysis.',
+ aiConfigureNow: 'Configure',
+ aiAnalysis: 'AI Analysis',
+ batchSummaryTitle: 'Batch analysis summary',
+ batchTotal: '{n} projects',
+ batchOk: 'completed',
+ batchFail: 'failed',
+ batchCancelled: 'cancelled',
+ batchDoneToast: 'Batch analysis done: {completed}/{total} succeeded',
+ fileHotspots: 'File hotspots',
+ fileHotspotsHint: 'Ranked by change frequency',
+ changesUnit: 'changes',
+ previewLoading: 'Loading file...',
+ clickPreview: 'Click to preview file',
+ previewBinary: 'Binary file, preview unavailable',
+ previewTruncated: 'Large file, first 1MB shown',
+ copyContent: 'Copy content',
+ copied: 'Copied to clipboard',
+ copyFailed: 'Copy failed',
+ settingsSubtitle: 'Manage exclusion rules, appearance and database',
+ tabRules: 'Exclusion rules',
+ tabAppearance: 'Appearance',
+ tabSync: 'Account & sync',
+ tabDatabase: 'Data',
+ aboutSlogan: 'A local-first, all-in-one project workbench',
+ aboutIntro: 'Niangao Zaizai PMS (NL PMS) is a local-first desktop workbench for your projects: count code size and language mix in one click, explore Git collaboration heat, manage todos, tickets and a schedule calendar, with built-in AI that explains your projects and plans your day. Data lives in local SQLite by default, and can be encrypted and synced to the cloud after signing in.',
+ aboutFeatCode: 'Code stats',
+ aboutFeatGit: 'Git insights',
+ aboutFeatTask: 'Todos & tickets',
+ aboutFeatCal: 'Schedule calendar',
+ aboutFeatAI: 'AI analysis',
+ aboutFeatSync: 'Encrypted sync',
+ aboutNameTitle: 'About the name',
+ aboutNameStory: '"NL" is short for nailao (cheese), and Niangao Zaizai is a round-faced, short-legged Minuet (Napoleon) ginger kitten who loves napping on a cheese wedge. Just like the kitten guards its cheese, NL PMS looks after every line of your code and every task on your list.',
+ aboutFoot: 'Crafted with care · by Niangao Zaizai',
+ addRule: 'Add exclusion rule',
+ rulePatternPh: 'e.g. *.log, cache, temp',
+ catGeneral: 'General',
+ catCustom: 'Custom',
+ ruleHint: 'Supports * wildcards; directory names match at any depth',
+ rulesCount: '{n} rules',
+ builtinTag: 'built-in',
+ langThemeTitle: 'Language & theme',
+ uiLanguage: 'Interface language',
+ gitScopeDefault: 'Default Git scope',
+ scopeCurrent: 'Current branch',
+ scopeAll: 'All branches',
+ loadingStyleOrbit: 'Energy orbit',
+ loadingStyleOrbitDesc: 'Orbiting particles, scan beams and a core',
+ loadingStyleGrid: 'Particle nebula',
+ loadingStyleGridDesc: 'Particle field converging into a glowing core',
+ loadingStyleWarp: 'Starfield rush',
+ loadingStyleWarpDesc: 'High-speed star streaks with light trails',
+ loadingStyleMatrix: 'The Matrix',
+ loadingStyleMatrixDesc: 'Green digital rain pouring down',
+ loadingStyleBar: 'Bottom progress bar',
+ loadingStyleBarDesc: 'Keep the page, show only a bottom bar',
+ sysIntegration: 'System integration',
+ onWindowClose: 'When closing the window',
+ closeToTray: 'Minimize to system tray',
+ closeQuit: 'Quit the app',
+ autostartLabel: 'Launch at startup',
+ autostartOnBtn: 'Enabled — click to disable',
+ autostartOffBtn: 'Disabled — click to enable',
+ autostartOnHint: '年糕崽崽 PMS starts after you sign in to the system',
+ autostartOffHint: 'Does not start with the system',
+ autostartOnToast: 'Launch at startup enabled',
+ autostartOffToast: 'Launch at startup disabled',
+ autostartFail: 'Failed to update autostart: {err}',
+ autoUpdateTitle: 'Auto-update projects',
+ autoUpdateEnable: 'Scheduled batch analysis',
+ optOn: 'On',
+ optOff: 'Off',
+ updateFreq: 'Frequency',
+ freqDaily: 'Daily',
+ freqNDays: 'Every N days',
+ freqNHours: 'Every N hours',
+ intervalN: 'Interval N',
+ triggerTime: 'Time of day',
+ autoUpdateHint: 'All projects are analyzed automatically at the scheduled time; results are written to the run log.',
+ previewModeTitle: 'Browser preview mode',
+ previewModeSync: 'The browser cannot reach MySQL. Run code-count.exe to use sync.',
+ previewModeDb: 'The browser cannot access the local database or file pickers. Run code-count.exe to use database features.',
+ accountSync: 'Account & sync',
+ menuAccount: 'Account',
+ loginOrRegister: 'Sign In / Register',
+ registerAndLogin: 'Register & Sign In',
+ loginModalHint: 'After signing in, todos, tickets, notes, API keys and your avatar sync across devices',
+ switchToRegister: 'No account yet? Register now',
+ loginWelcome: 'Welcome back',
+ registerWelcome: 'Create your account',
+ confirmPh: 'Repeat the password',
+ showPassword: 'Show password',
+ hidePassword: 'Hide password',
+ dailyCard: 'Daily Note',
+ dailyYi: 'Do',
+ dailyJi: 'Avoid',
+ dailyGotIt: 'Got it',
+ dailyTodayBtn: "Today's note",
+ dailyViewBtn: 'View daily note',
+ dailyHistory: 'Past',
+ dailyFuture: 'Notes for the future are not written yet',
+ dailyPrevDay: 'Previous day',
+ dailyNextDay: 'Next day',
+ festImgTitle: 'Festival cell style (admin)',
+ festImgSet: 'Set image',
+ festImgReplace: 'Replace',
+ festImgRemove: 'Remove',
+ festImgSaved: 'Festival image saved and will sync to all users',
+ festImgRemoved: 'Festival image removed',
+ festModeArtCard: 'Dynamic art',
+ festModePhotoCard: 'Custom photo',
+ fieldUser: 'Username',
+ fieldPass: 'Password',
+ fieldConfirm: 'Confirm password',
+ dqToday: 'Today',
+ dqTomorrow: 'Tomorrow',
+ dqDayAfter: '+2d',
+ dqDays: '+{n}d',
+ dqSuffix: 'd',
+ dqAddTip: 'Add custom days',
+ dqRemove: 'Remove label',
+ loginTagline: 'Local-first,\nsynced everywhere',
+ loginSecureBadge: 'API keys encrypted',
+ switchToLogin: 'Already have an account? Sign in',
+ usernamePh: 'Username (3-64 characters)',
+ passwordPh: 'Password (at least 6 characters)',
+ loginBtn: 'Sign in',
+ loggingIn: 'Signing in…',
+ registerBtn: 'Create account',
+ registering: 'Creating…',
+ syncLoginHint: 'After signing in, todos, tickets and notes sync to the server every 5 minutes; offline edits stay local and sync once you are back online.',
+ notLoggedIn: 'Not signed in',
+ loginNow: 'Click to sign in',
+ pendingSync: '{n} changes waiting to sync',
+ close: 'Close',
+ ctxNewTodo: 'New todo',
+ ctxNewTicket: 'New ticket',
+ ctxNewReminder: 'Quick reminder',
+ ctxNeedProject: 'Create a project first to add tickets',
+ quickTitlePh: 'Type a title, press Enter',
+ quickCreate: 'Create',
+ quickCreated: 'Created',
+ yesterday: 'Yesterday',
+ justNow: 'just now',
+ minAgo: '{n} min ago',
+ hourAgo: '{n} h ago',
+ dayAgo: '{n} d ago',
+ copyBtn: 'Copy',
+ copiedShort: 'Copied',
+ noLogsHint: 'Application events will appear here',
+ searchPlaceholder: 'Search projects, todos, tickets, AI chats…',
+ searchEmpty: 'No matches found',
+ searchHint: 'Type to search. ↑↓ to select, Enter to open',
+ kindProject: 'Projects',
+ kindTodo: 'Todos',
+ kindTicket: 'Tickets',
+ kindConversation: 'AI Chats',
+ changePasswordTitle: 'Change password',
+ oldPasswordLabel: 'Current password',
+ newPasswordLabel: 'New password',
+ confirmPasswordLabel: 'Confirm new password',
+ changePasswordBtn: 'Apply',
+ changingPassword: 'Changing…',
+ passwordChangedToast: 'Password changed',
+ passwordMismatch: 'The two new passwords do not match',
+ changePasswordHint: 'Changing the password requires being online. Cloud API keys are re-encrypted with the key derived from the new password; other signed-in devices must sign in again with the new password to decrypt them.',
+ profileTitle: 'Profile',
+ avatarSource: 'Avatar source',
+ avatarNone: 'No avatar',
+ avatarBase64: 'Local image (stored as Base64, syncs with account)',
+ avatarUrl: 'Image URL (OSS / CDN link)',
+ avatarPath: 'Local file path (this device only)',
+ avatarUrlLabel: 'Image URL',
+ avatarUrlPh: 'e.g. https://bucket.oss-cn-hangzhou.aliyuncs.com/avatar.png',
+ avatarPick: 'Choose image',
+ avatarHint: 'Images are scaled down to 256px. Base64 and URL avatars sync with your account across devices; a local path avatar only shows on this device and is never synced.',
+ contentImageStore: 'Content image storage',
+ imgModeBase64: 'Inline Base64 (syncs with content)',
+ imgModePath: 'Local file (this device only)',
+ imgModeServer: 'Upload to server (visible across devices)',
+ contentImageHint: 'Images pasted or inserted into todo / ticket content are stored this way: inline Base64 syncs to the cloud with the text; local files keep the database small but won\'t show on other devices; server uploads are referenced by URL and load anywhere. Images are compressed to 1100px max.',
+ avatarServer: 'Upload to server (admin-configured file service)',
+ fsTitle: 'File storage (global)',
+ fsHint: 'Admin only: the config syncs to every account. In server mode content images and avatars upload to the file service and stay accessible across devices',
+ fsMode: 'Storage mode',
+ fsModeLocal: 'Local (default)',
+ fsModeServer: 'Server (nl-pms-api)',
+ fsBaseUrl: 'Server URL',
+ fsApiKey: 'Upload key',
+ fsApiKeyPh: 'Same as api_key in the nl-pms-api config',
+ fsTestBtn: 'Test connection',
+ fsTesting: 'Testing…',
+ fsSaveBtn: 'Save config',
+ fsSavedToast: 'File storage config saved; effective for everyone immediately',
+ fsTestOkToast: 'File server reachable',
+ tabFileStorage: 'File storage',
+ fsPageHint: 'The config lives in the server database and takes effect immediately: every client reads it in real time when uploading images, no sync wait.',
+ fsAdminOnlyTitle: 'Admin only',
+ fsAdminOnlyDesc: 'Only the admin account (ID=1) can change the global file storage mode.',
+ storageFollowHint: 'Avatar and content image storage follows the admin-managed global config. Current: {mode}.',
+ avatarClear: 'Clear avatar',
+ quitApp: 'Quit app',
+ quitConfirm: 'Quit the app?',
+ assetsTab: 'Media library',
+ assetsHint: 'Manage images uploaded to the file server: yours, your teams\' (as owner/admin), or everything (admin id=1)',
+ assetsNeedServer: 'Server storage is off. Once the admin switches file storage to server mode on the Sync tab, uploads show up here.',
+ assetsScopeMine: 'My uploads',
+ assetsScopeTeam: 'Team media',
+ assetsScopeAll: 'Everything (admin)',
+ assetsCount: '{n} images',
+ assetsRefresh: 'Refresh',
+ assetsEmpty: 'No images yet. Pictures pasted into todos / tickets or uploaded avatars will appear here.',
+ assetsLoadMore: 'Load more',
+ assetsCopy: 'Copy link',
+ assetsCopiedToast: 'Link copied',
+ assetsCopyFailed: 'Copy failed',
+ assetsDeleteConfirm: 'Delete this image? Content referencing it will stop displaying.',
+ assetsDeletedToast: 'Image deleted',
+ assetsKindAvatar: 'Avatar',
+ assetsKindContent: 'Content',
+ mdEdit: 'Edit',
+ mdPreviewTab: 'Preview',
+ insertImage: 'Image',
+ mdInserting: 'Processing…',
+ mdPlaceholder: 'Markdown supported: **bold**, - lists, `code`, [links](url); paste images directly or use the Image button',
+ mdEmpty: '*(no content)*',
+ lifecycle: 'Lifecycle',
+ lifecycleCreated: 'Created',
+ lifecycleNow: 'In progress…',
+ lifecycleTotal: 'Created to completion in {dur}',
+ durMoment: 'moments',
+ durMin: '{n} min',
+ durHour: '{n} h',
+ durDay: '{n} d',
+ taskCenter: 'Task center',
+ taskDoingGroup: 'In progress',
+ taskPendingGroup: 'Not started',
+ taskStart: 'Start',
+ taskDone: 'Done',
+ taskResolve: 'Resolve',
+ taskEmpty: 'No pending tasks',
+ aiBriefProject: 'AI project brief',
+ aiBriefGit: 'AI Git brief',
+ aiBriefStructure: 'AI structure brief',
+ aiBriefInsights: 'AI insights brief',
+ aiBriefEmpty: 'Not generated yet; created automatically after analysis',
+ aiBriefGenerating: 'Generating…',
+ aiBriefRegen: 'Regenerate',
+ aiAskThisProject: 'Ask about this project…',
+ aiAskGo: 'Ask AI',
+ aiAskDrawer: 'AI Q&A',
+ aiOpenFull: 'Full page',
+ aiLoadEarlier: 'Load {n} earlier messages',
+ online: 'Online',
+ offline: 'Offline',
+ lastSync: 'Last sync: {time}',
+ profilePage: 'Profile',
+ profileSubtitle: 'Account, avatar and data sync',
+ profileSecurity: 'Account security',
+ profileGuest: 'Sign in to sync data, set an avatar and manage your cloud profile',
+ profileTabSync: 'Data sync',
+ avatarEdit: 'Edit avatar',
+ avatarModalTitle: 'Avatar & images',
+ accountLabel: 'Account',
+ connectionState: 'Connection',
+ lastSyncLabel: 'Last sync',
+ pendingLabel: 'Pending push',
+ profileSyncHint: 'Once signed in, todos, tickets, settings, favorites, project definitions and docs sync across devices; offline edits are pushed automatically when back online.',
+ greetMorning: 'Good morning',
+ greetNoon: 'Good noon',
+ greetAfternoon: 'Good afternoon',
+ greetEvening: 'Good evening',
+ greetNight: 'Late night',
+ phDoneTodos: 'Todos done',
+ phResolvedTickets: 'Tickets resolved',
+ pwdWeak: 'Weak',
+ pwdMedium: 'Medium',
+ pwdStrong: 'Strong',
+ syncScope: 'Sync scope',
+ scopeFavs: 'Favorites',
+ scopeDocs: 'Docs',
+ wbAiPlan: 'AI day plan',
+ wbPlanGen: 'Plan my day',
+ wbPlanRegen: 'Re-plan',
+ wbReport: 'EOD report',
+ wbReportGen: 'EOD summary',
+ wbGenerating: 'Generating…',
+ wbPlanEmpty: 'Click "Plan my day" and AI will prioritize today\'s todos and tickets',
+ wbReportEmpty: 'Before you leave, click "EOD summary" to turn today\'s work into a short report',
+ wbAiNoKey: 'Configure an AI key to get day plans and end-of-day reports',
+ pendingPush: '{n} pending push',
+ neverSynced: 'Not synced yet',
+ syncNowBtn: 'Sync now',
+ syncingBtn: 'Syncing…',
+ logoutBtn: 'Sign out',
+ registerOkToast: 'Account created',
+ loginOkToast: 'Signed in — syncing data',
+ logoutToast: 'Signed out',
+ syncDoneToast: 'Sync finished: pushed {pushed}, pulled {pulled}',
+ syncFailToast: 'Sync failed',
+ aiProviderTitle: 'AI provider',
+ aiCurrentProvider: 'Provider',
+ aiSparkOption: 'iFlytek Spark Lite (free)',
+ sparkKeyLabel: 'Spark APIPassword',
+ sparkKeyPh: 'iFlytek Open Platform → Spark Lite → HTTP service auth info',
+ deepseekKeyLabel: 'DeepSeek API Key',
+ deepseekKeyPh: 'platform.deepseek.com → API Keys',
+ aiKeyHint: 'Keys stay in the local database by default. Spark Lite is free (spark-api-open.xf-yun.com); DeepSeek is pay-as-you-go (api.deepseek.com). Then open the AI Analysis page to analyze projects, Git activity, todos and tickets.',
+ syncApiKeysLabel: 'Sync API keys to server',
+ syncApiKeysOn: 'On (encrypted)',
+ syncApiKeysOff: 'Off (local only)',
+ syncApiKeysHint: 'When enabled, API keys are encrypted with a key derived from your account password (AES-256-GCM) before upload; the server cannot decrypt them. Sign in with the same account on another device to retrieve them. Requires sign-in.',
+ dbLocation: 'Database location',
+ dbConnected: 'Connected',
+ dbCurrentLoc: 'Current location',
+ dbNoPath: 'Database path unavailable',
+ copyPathTitle: 'Copy path',
+ dbMigratedMsg: 'Database migrated to the new location',
+ dbMigrateFail: 'Database migration failed',
+ dbPathCopied: 'Database path copied',
+ dbCopyFail: 'Could not copy the path',
+ migrateBtn: 'Choose a new location and migrate',
+ dangerZone: 'Clear data',
+ dangerDesc: 'Choose what to clear. This cannot be undone.',
+ clearStats: 'Clear statistics',
+ clearStatsDesc: 'Delete analysis history, keep projects',
+ clearProject: 'Clear one project',
+ clearProjectDesc: 'Delete stats of a chosen project',
+ clearAllData: 'Clear all data',
+ clearAllDesc: 'Delete all projects and analysis history',
+ projectIdPrompt: 'Project ID',
+ confirmIrreversible: 'This cannot be undone. Continue?',
+ srcLocal: 'Local folder',
+ srcGit: 'Clone from Git',
+ cloneUrl: 'Git repository URL',
+ cloneDir: 'Clone into',
+ cloneDirHint: 'Parent folder for the repository',
+ cloneAndAdd: 'Clone & add',
+ cloning: 'Cloning...',
+ cloneDone: 'Cloned and added',
+ cloneUrlRequired: 'Git URL is required',
+ cloneDirRequired: 'Choose a folder to clone into',
errors: {
+ URL_REQUIRED: 'Git URL is required',
+ DIR_NOT_FOUND: 'Clone folder does not exist',
+ NAME_REQUIRED: 'Cannot infer project name; enter one manually',
+ TARGET_NOT_EMPTY: 'Target folder already exists and is not empty',
+ SYNC_NOT_CONFIGURED: 'Fill in the complete MySQL server configuration first',
+ SYNC_OFFLINE: 'Cannot reach the server; check network and configuration',
+ SYNC_SCHEMA_MISSING: 'Database or tables missing; run init.sql on the server first',
+ SYNC_SCHEMA_OUTDATED: 'Cloud schema is outdated; re-run the latest init.sql on the server',
+ SYNC_USER_EXISTS: 'Username already registered',
+ SYNC_BAD_CREDENTIALS: 'Wrong username or password',
+ SYNC_USERNAME_INVALID: 'Username must be 3-64 characters',
+ SYNC_PASSWORD_TOO_SHORT: 'Password must be at least 6 characters',
+ SYNC_NOT_LOGGED_IN: 'Sign in first',
+ SYNC_OLD_PASSWORD_WRONG: 'Current password is incorrect',
+ SYNC_IN_PROGRESS: 'Sync already in progress',
+ SYNC_DECRYPT_FAILED: 'Could not decrypt cloud API keys; sign in again and retry',
+ AVATAR_FILE_TOO_LARGE: 'Image exceeds 10MB, please pick a smaller one',
+ AVATAR_DECODE_FAILED: 'Unrecognized image format (PNG / JPG / GIF / WebP supported)',
+ FILE_STORAGE_ADMIN_ONLY: 'Only the admin (id=1) can configure file storage',
+ FILE_STORAGE_BAD_URL: 'Invalid server URL; it must start with http(s)://',
+ FILE_STORAGE_UNREACHABLE: 'Cannot reach the file server; check the URL and service',
+ FILE_API_UNCONFIGURED: 'Server storage is not enabled; ask the admin to configure file storage',
+ IMAGE_UPLOAD_FAILED: 'Image upload failed; check the file server and retry',
+ FILE_PERMISSION_DENIED: 'You do not have permission to manage this file',
+ FILE_API_REQUEST_FAILED: 'File server request failed; try again later',
+ FILE_NOT_FOUND: 'File does not exist',
+ FILE_IS_DIRECTORY: 'The path is a directory',
+ FILE_PATH_INVALID: 'Invalid file path',
+ FILE_READ_FAILED: 'Failed to read file',
+ FILE_PATH_REQUIRED: 'File path is required',
+ TODO_TITLE_REQUIRED: 'Todo title is required',
+ TODO_STATUS_INVALID: 'Invalid todo status',
+ TICKET_TITLE_REQUIRED: 'Ticket title is required',
+ TICKET_PROJECT_REQUIRED: 'A ticket must be bound to a project',
+ TICKET_SCHEDULE_REQUIRED: 'A ticket requires start and due dates',
+ TICKET_SCHEDULE_INVALID: 'Due date cannot be before start date',
+ TICKET_STATUS_INVALID: 'Invalid ticket status',
+ AI_PROVIDER_INVALID: 'Invalid AI provider',
+ AI_KEY_MISSING: 'No AI API key configured',
+ AI_EMPTY_RESPONSE: 'AI returned empty content, please retry',
+ TEAM_SCHEMA_MISSING: 'Server is missing team tables — run init.sql again',
+ TEAM_FORBIDDEN: 'You do not have permission for this team action',
+ TEAM_NAME_INVALID: 'Team name must be 1-64 characters',
+ TEAM_TIME_INVALID: 'Time must be HH:MM',
+ TEAM_OWNER_CANNOT_LEAVE: 'The owner cannot leave — dissolve the team instead',
+ TEAM_USER_NOT_FOUND: 'No account with that username',
+ TEAM_ALREADY_MEMBER: 'Already a team member',
+ TEAM_ROLE_INVALID: 'Invalid role',
+ TEAM_TASK_TITLE_REQUIRED: 'Task title is required',
+ TEAM_TASK_NOT_FOUND: 'Task not found or deleted',
+ TEAM_ASSIGNEE_INVALID: 'Assignee is not a team member',
+ TEAM_STATUS_INVALID: 'Invalid task status',
+ TEAM_NO_ASSIGNEE: 'Task has no assignee to nudge',
+ TEAM_DATE_INVALID: 'Date must be YYYY-MM-DD',
+ TEAM_REPORT_EMPTY: 'Report content is required',
+ TEAM_NO_REPORTS: 'No reports submitted for that day yet',
+ PROFILE_EMAIL_INVALID: 'Invalid email address',
+ AI_KEY_INVALID: 'API key invalid or expired',
+ AI_NETWORK_ERROR: 'Cannot reach the AI service, check your network',
+ AI_EMPTY_MESSAGE: 'Message is empty',
+ AI_STREAM_RUNNING: 'This conversation is already replying',
+ AI_CONVERSATION_NOT_FOUND: 'Conversation not found',
+ AI_CANCELLED: 'Reply stopped',
+ AI_STREAM_INTERRUPTED: 'AI reply interrupted',
PROJECT_PATH_NOT_FOUND: 'Project directory does not exist',
PROJECT_PATH_UNREADABLE: 'Project directory is not readable',
PROJECT_PATH_NOT_DIRECTORY: 'Selected path is not a directory',
@@ -330,7 +1699,10 @@ const en = {
git: 'Reading Git history',
completed: 'Analysis completed',
failed: 'Analysis failed',
- cancelled: 'Analysis cancelled'
+ cancelled: 'Analysis cancelled',
+ cloning: 'Cloning {project}',
+ cloneDone: 'Clone completed',
+ cloneFailed: 'Clone failed'
}
}
@@ -344,10 +1716,23 @@ const i18n = createI18n({ legacy: false, locale: saved.locale || 'zh-CN', fallba
const router = createRouter({
history: createWebHashHistory(),
routes: [
- { path: '/', component: Dashboard },
+ { path: '/', component: Workbench },
+ { path: '/projects', component: Dashboard },
{ path: '/project/:id', component: ProjectDetail },
+ { path: '/todos', component: Todos },
+ { path: '/tickets', component: Tickets },
+ { path: '/calendar', component: CalendarPage },
+ { path: '/ai', component: AIChat },
{ path: '/logs', component: Logs },
- { path: '/settings', component: Settings }
+ { path: '/settings', component: Settings },
+ { path: '/profile', component: Profile },
+ { path: '/launchpad', component: Launchpad },
+ { path: '/messages', component: Messages },
+ { path: '/today', component: Today },
+ { path: '/notes', component: Notes },
+ { path: '/team', component: TeamHome },
+ { path: '/team/tasks', component: TeamTasks },
+ { path: '/team/reports', component: TeamReports }
]
})
diff --git a/frontend/src/motion.css b/frontend/src/motion.css
index 3886933..cd2960f 100644
--- a/frontend/src/motion.css
+++ b/frontend/src/motion.css
@@ -1,7 +1,7 @@
html{--glass-user-opacity:.55;--glass:rgba(27,35,48,var(--glass-user-opacity));--glass-strong:rgba(32,41,57,calc(var(--glass-user-opacity) + .12));--glass-soft:rgba(40,50,69,calc(var(--glass-user-opacity) - .1));--glass-border:rgba(164,178,211,.18);--glass-highlight:rgba(255,255,255,.07);--glass-shadow:0 18px 42px rgba(0,0,0,.22);--glass-blur:24px}
html[data-theme=light]{--glass:rgba(255,255,255,calc(.72 + var(--glass-user-opacity) * .28));--glass-strong:rgba(255,255,255,calc(.8 + var(--glass-user-opacity) * .2));--glass-soft:rgba(244,247,252,calc(.64 + var(--glass-user-opacity) * .3));--glass-border:rgba(56,70,98,.16);--glass-highlight:rgba(255,255,255,.85);--glass-shadow:0 18px 42px rgba(40,53,78,.12)}
main,.setup-screen{background-color:var(--bg);background-image:linear-gradient(rgba(123,115,255,.055) 1px,transparent 1px),linear-gradient(90deg,rgba(123,115,255,.045) 1px,transparent 1px),linear-gradient(135deg,rgba(67,201,150,.08),transparent 36%,rgba(91,86,192,.11) 72%,transparent);background-size:40px 40px,40px 40px,100% 100%;background-attachment:fixed}main{position:relative;isolation:isolate}.sidebar,.stat-card,.project-card,.panel,.modal,.taskbar,.toast,.tabs,.search,.setup-card{background:linear-gradient(135deg,var(--glass-highlight),transparent 42%),var(--glass);border-color:var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow);backdrop-filter:blur(var(--glass-blur)) saturate(145%);-webkit-backdrop-filter:blur(var(--glass-blur)) saturate(145%)}.sidebar{background:linear-gradient(145deg,var(--glass-highlight),transparent 38%),var(--glass-strong)}.modal,.setup-card{background:linear-gradient(135deg,var(--glass-highlight),transparent 45%),var(--glass-strong)}
-.page{animation:page-enter .48s cubic-bezier(.2,.8,.2,1) both}.page-head,.project-head{animation:fade-rise .42s .04s both}.stats-grid>.stat-card,.project-grid>*{animation:card-enter .5s cubic-bezier(.16,1,.3,1) both}.stats-grid>:nth-child(1),.project-grid>:nth-child(1){animation-delay:.08s}.stats-grid>:nth-child(2),.project-grid>:nth-child(2){animation-delay:.13s}.stats-grid>:nth-child(3),.project-grid>:nth-child(3){animation-delay:.18s}.stats-grid>:nth-child(4),.project-grid>:nth-child(4){animation-delay:.23s}.stats-grid>:nth-child(5),.project-grid>:nth-child(5){animation-delay:.28s}.project-grid>:nth-child(n+6){animation-delay:.32s}
+.page{animation:page-enter .48s cubic-bezier(.2,.8,.2,1) backwards}.page-head,.project-head{animation:fade-rise .42s .04s both}.stats-grid>.stat-card,.project-grid>*{animation:card-enter .5s cubic-bezier(.16,1,.3,1) both}.stats-grid>:nth-child(1),.project-grid>:nth-child(1){animation-delay:.08s}.stats-grid>:nth-child(2),.project-grid>:nth-child(2){animation-delay:.13s}.stats-grid>:nth-child(3),.project-grid>:nth-child(3){animation-delay:.18s}.stats-grid>:nth-child(4),.project-grid>:nth-child(4){animation-delay:.23s}.stats-grid>:nth-child(5),.project-grid>:nth-child(5){animation-delay:.28s}.project-grid>:nth-child(n+6){animation-delay:.32s}
.stat-card,.panel,.project-card,.add-card{transition:transform .26s cubic-bezier(.2,.8,.2,1),border-color .26s,box-shadow .26s,background-color .26s}.stat-card:hover,.panel:hover{border-color:rgba(130,120,255,.38);box-shadow:inset 0 1px 0 var(--glass-highlight),0 22px 46px rgba(0,0,0,.25);transform:translateY(-2px)}.project-card:hover,.add-card:hover{transform:translateY(-5px);border-color:rgba(130,120,255,.6);box-shadow:inset 0 1px 0 var(--glass-highlight),0 25px 52px rgba(0,0,0,.3);background:var(--glass-strong)}
.stat-card strong{animation:number-arrive .55s .2s both}.overlay{animation:overlay-in .24s both}.modal{animation:modal-in .32s cubic-bezier(.16,1,.3,1) both}.toast{animation:toast-in .36s cubic-bezier(.16,1,.3,1) both}.taskbar{animation:task-in .38s cubic-bezier(.16,1,.3,1) both}.taskbar .progress i{transition:width .4s cubic-bezier(.2,.8,.2,1);position:relative;overflow:hidden}.taskbar .progress i:after{content:"";position:absolute;inset:0;background:rgba(255,255,255,.35);animation:progress-sweep 1.2s linear infinite}.heatmap i[class]:not(.l0){animation:heat-in .38s both}.heatmap i:nth-child(5n){animation-delay:.08s}.heatmap i:nth-child(7n){animation-delay:.14s}.spin{animation:spin .9s linear infinite}.btn,.tabs button,.icon-actions button{transition:background-color .2s,border-color .2s,color .2s,box-shadow .2s,transform .2s}.btn:active,.tabs button:active{transform:translateY(1px)}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,a:focus-visible{outline:2px solid #9188ff;outline-offset:2px}
.boot-loading,.setup-screen{min-height:100vh;display:grid;place-items:center;background:var(--bg)}.boot-loading{gap:12px;color:var(--muted)}.boot-loading svg{width:28px}.setup-screen{padding:32px}.setup-card{width:min(620px,100%);padding:42px;border:1px solid var(--glass-border);border-radius:10px;animation:setup-in .55s cubic-bezier(.16,1,.3,1) both}.setup-icon{width:62px;height:62px;display:grid;place-items:center;background:rgba(115,103,245,.18);color:#9188ff;border:1px solid rgba(145,136,255,.28);border-radius:10px;margin-bottom:24px}.setup-icon svg{width:30px}.setup-kicker{display:flex;align-items:center;gap:7px;color:#9b94ff;font-size:13px;font-weight:bold}.setup-kicker svg{width:16px}.setup-card h1{font-size:30px;margin:10px 0}.setup-card>p{color:var(--muted);line-height:1.65}.setup-card label{display:block;margin-top:26px;font-size:13px;font-weight:bold}.setup-path{display:grid;grid-template-columns:1fr 44px;margin-top:8px}.setup-path input{height:44px;border:1px solid var(--border);border-radius:7px 0 0 7px;background:var(--glass-soft);color:var(--text);padding:0 13px;min-width:0}.setup-path button{border:1px solid var(--border);border-left:0;border-radius:0 7px 7px 0;background:var(--glass-soft);color:var(--text);cursor:pointer}.setup-path svg{width:19px}.setup-default{display:block;color:var(--muted);margin-top:8px;overflow-wrap:anywhere}.setup-warning{display:flex;gap:12px;background:rgba(240,94,104,.1);border:1px solid rgba(240,94,104,.28);padding:14px;border-radius:8px;margin-top:20px;color:var(--red)}.setup-warning svg{width:20px;flex:none}.setup-warning b,.setup-warning small{display:block}.setup-warning small{margin-top:5px;opacity:.8;overflow-wrap:anywhere}.setup-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:28px}
diff --git a/frontend/src/polish.css b/frontend/src/polish.css
index 0b325a7..849100f 100644
--- a/frontend/src/polish.css
+++ b/frontend/src/polish.css
@@ -2,14 +2,147 @@ main{position:relative;overflow:visible}
main::before{content:"";position:fixed;inset:0 0 0 232px;pointer-events:none;background:linear-gradient(90deg,rgba(255,255,255,.035) 1px,transparent 1px),linear-gradient(180deg,rgba(255,255,255,.03) 1px,transparent 1px);background-size:80px 80px;mask-image:linear-gradient(90deg,transparent,black 14%,black 86%,transparent);animation:gridDrift 18s linear infinite;opacity:.55}
main::after{content:"";position:fixed;inset:0 0 0 232px;pointer-events:none;background:linear-gradient(115deg,transparent 0 35%,rgba(124,115,255,.1) 45%,transparent 56%);transform:translateX(-35%);animation:ambientSweep 12s ease-in-out infinite}
.page{position:relative;z-index:1}
-.sticky-head{position:sticky;top:0;z-index:8;margin:-12px -10px 24px;padding:18px 20px;border:1px solid rgba(255,255,255,.08);border-radius:8px;background:color-mix(in srgb,var(--surface) calc(var(--glass-user-opacity, .55) * 100%),transparent);backdrop-filter:blur(24px) saturate(140%);box-shadow:0 16px 38px rgba(0,0,0,.22)}
+html{--topbar-h:0px}
+.sticky-head{position:sticky;top:calc(var(--topbar-h) + 8px);z-index:20;margin:-12px -10px 24px;padding:18px 20px;border:1px solid rgba(255,255,255,.08);border-radius:8px;background:color-mix(in srgb,var(--surface) calc(clamp(.72, var(--glass-user-opacity, .55) + .18, .94) * 100%),transparent);backdrop-filter:blur(24px) saturate(140%);box-shadow:0 16px 38px rgba(0,0,0,.22)}
+@supports not (backdrop-filter:blur(1px)){.sticky-head{background:color-mix(in srgb,var(--surface) 97%,transparent)}}
.project-head.sticky-head{margin:-12px -10px 16px}
+/* 页内顶栏已由原生窗口菜单替代;消息/笔记/任务图标收进左侧 rail 底部,一行两个 */
+.rail-tools{position:relative;margin-top:auto;padding-top:8px;display:grid;grid-template-columns:repeat(2,1fr);gap:4px 2px;justify-items:center;align-items:center;width:100%}
+.rail-tools .bell-wrap{position:static;display:grid;place-items:center}
+.rail-tools .bell-btn{width:28px;height:28px;border-radius:9px}
+.rail-tools .bell-btn svg{width:15px}
+.rail-tools .bell-badge{top:-2px;right:-3px;z-index:1}
+/* 气泡贴着工具区弹出:图标右侧 + 底部对齐,跟随 rail 宽度变化 */
+.rail-tools .bell-dropdown{position:absolute;left:calc(100% + 16px);right:auto;top:auto;bottom:0}
+.side-rail .rail-user{margin-top:6px}
+.autostart-row{display:flex;align-items:center;gap:14px}
+.autostart-row small{color:var(--muted)}
+.interval-input{max-width:200px}
+.auto-update-hint{margin:16px 0 0;color:var(--muted);font-size:13px}
+.sync-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 18px}
+.sync-grid input,.interval-input,.form-panel label>input:not([type=range]){width:100%;background:var(--surface-2);border:1px solid var(--border);border-radius:7px;color:var(--text);padding:10px 12px;margin-top:7px;outline:none}
+.sync-grid input:focus,.form-panel label>input:focus{border-color:var(--primary)}
+.sync-actions{display:flex;gap:10px;margin-top:14px}
+.sync-status-line{display:flex;align-items:center;gap:12px;padding:12px 14px;border:1px solid var(--border);border-radius:8px;background:var(--surface-2)}
+.sync-status-line small{color:var(--muted)}
+.sync-badge{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;font-size:12px}
+.sync-badge svg{width:13px}
+.sync-badge.on{background:rgba(67,201,150,.13);color:#5fe0af}
+.sync-badge.off{background:rgba(240,94,104,.13);color:#ff8a95}
.animated-logo{background:linear-gradient(135deg,rgba(115,103,245,.95),rgba(52,211,153,.82));box-shadow:0 10px 28px rgba(115,103,245,.34)}
.animated-logo svg{width:40px;height:40px;overflow:visible}
.logo-frame{fill:rgba(255,255,255,.08);stroke:url(#logoGlow);stroke-width:1.5}
.logo-track{fill:none;stroke:#fff;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:68;animation:logoTrace 3.2s ease-in-out infinite}
.logo-spark{fill:none;stroke:url(#logoGlow);stroke-width:2.5;stroke-linecap:round;stroke-dasharray:8 12;animation:logoSpark 2.2s linear infinite}
-.sidebar-bottom{position:relative;margin-top:auto;border-top:1px solid var(--border);padding:18px 8px 0;display:flex;align-items:center;gap:8px}
+.sidebar-bottom{position:relative;margin-top:auto;border-top:1px solid var(--border);padding:18px 8px 0;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+.user-chip{flex:0 0 100%;display:flex;align-items:center;gap:10px;border:1px solid var(--border);border-radius:8px;background:var(--surface-2);color:var(--text);padding:7px 10px;margin-bottom:4px;cursor:pointer;text-align:left;min-width:0;transition:border-color .2s,background .2s}
+.user-chip:hover{border-color:var(--primary)}
+.user-chip.active{border-color:var(--primary);background:color-mix(in srgb,var(--primary) 14%,var(--surface-2))}
+.user-chip.active .user-name{color:var(--text)}
+.user-avatar{position:relative;width:32px;height:32px;border-radius:50%;background:var(--surface-3);display:grid;place-items:center;flex:none}
+.user-avatar img{width:32px;height:32px;border-radius:50%;object-fit:cover;display:block}
+.user-avatar b{font-size:14px;color:#a9a2ff}
+.user-avatar svg{width:16px;color:var(--muted)}
+.user-dot{position:absolute;right:-2px;bottom:-2px;width:9px;height:9px;border-radius:50%;border:2px solid var(--side);background:#6b7482}
+.user-dot.on{background:var(--green)}
+.user-dot.err{background:var(--red)}
+.user-pending{flex:none;min-width:20px;height:20px;padding:0 6px;border-radius:10px;background:rgba(115,103,245,.22);color:#a9a2ff;font-size:11px;font-style:normal;font-weight:700;display:grid;place-items:center}
+@media(max-width:1150px){.user-pending{display:none}}
+.user-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;color:var(--muted)}
+.user-chip:hover .user-name{color:var(--text)}
+@media(max-width:1150px){.user-chip{flex:0 0 auto;border:0;background:transparent;padding:4px;margin-bottom:2px}.user-name{display:none}}
+.avatar-row{display:flex;gap:18px;align-items:flex-start;margin-top:18px}
+.avatar-preview{width:72px;height:72px;border-radius:50%;background:var(--surface-2);border:1px solid var(--border);display:grid;place-items:center;flex:none;overflow:hidden}
+.avatar-preview img{width:100%;height:100%;object-fit:cover;display:block}
+.avatar-preview svg{width:28px;color:var(--muted)}
+.avatar-controls{flex:1;min-width:0}
+.avatar-controls .sync-actions{margin-top:14px}
+.avatar-path{display:block;margin-top:10px;background:var(--surface-2);border-radius:6px;padding:8px 10px;font-size:12px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.login-overlay{position:fixed;inset:0;z-index:60;background:rgba(4,7,12,.68);backdrop-filter:blur(14px) saturate(118%);-webkit-backdrop-filter:blur(14px) saturate(118%);display:grid;place-items:center;animation:login-fade .22s ease-out}
+@keyframes login-fade{from{opacity:0}to{opacity:1}}
+.login-card{position:relative;display:grid;grid-template-columns:280px 1fr;width:744px;max-width:calc(100vw - 48px);min-height:466px;border-radius:20px;overflow:hidden;background:var(--surface);border:1px solid color-mix(in srgb,#fff 8%,var(--border));box-shadow:0 60px 140px rgba(0,0,0,.58),inset 0 1px 0 rgba(255,255,255,.05);animation:login-pop .4s cubic-bezier(.22,1.12,.34,1)}
+@keyframes login-pop{from{opacity:0;transform:translateY(22px) scale(.96)}to{opacity:1;transform:none}}
+html[data-theme=light] .login-card{border-color:var(--border)}
+.login-close{position:absolute;top:14px;right:14px;z-index:3;border:0;background:transparent;color:var(--muted);cursor:pointer;width:32px;height:32px;display:grid;place-items:center;border-radius:10px;transition:color .2s,background .2s}
+.login-close:hover{color:var(--text);background:var(--surface-3)}
+.login-close svg{width:17px}
+
+/* 左侧品牌面板:墨色底 + 噪点纹理 + 细线圆环 + 单侧柔光 */
+.lg-art{position:relative;display:flex;flex-direction:column;justify-content:space-between;gap:26px;padding:26px 26px 22px;background:#0c1019;color:#edf0f7;overflow:hidden}
+html[data-theme=light] .lg-art{background:#141a2e}
+.lg-art::before{content:"";position:absolute;inset:0;opacity:.6;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.06'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}
+.lg-art::after{content:"";position:absolute;inset:0;background:radial-gradient(430px 320px at -14% 116%,rgba(115,103,245,.32),transparent 62%),radial-gradient(260px 200px at 112% -10%,rgba(67,201,150,.11),transparent 64%);pointer-events:none}
+.lg-rings{position:absolute;right:-86px;top:-86px;width:252px;height:252px;border-radius:50%;border:1px solid rgba(255,255,255,.08);pointer-events:none;z-index:1}
+.lg-rings::before,.lg-rings::after{content:"";position:absolute;inset:36px;border-radius:50%;border:1px solid rgba(255,255,255,.06)}
+.lg-rings::after{inset:76px;border-style:dashed;border-color:rgba(255,255,255,.1);animation:lg-spin 80s linear infinite}
+@keyframes lg-spin{to{transform:rotate(360deg)}}
+.lg-logo{position:relative;z-index:2;display:flex;align-items:center;gap:11px;font-size:13px;font-weight:700;letter-spacing:.5px}
+.lg-logo svg{width:19px;height:19px;padding:7px;box-sizing:content-box;border-radius:11px;background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.15);color:#cfc9ff;box-shadow:inset 0 1px 0 rgba(255,255,255,.12)}
+.lg-copy{position:relative;z-index:2}
+.lg-copy h3{margin:0;font-size:22px;line-height:1.45;font-weight:800;letter-spacing:1px;white-space:pre-line}
+.lg-copy h3::after{content:"";display:block;width:36px;height:3px;border-radius:2px;margin-top:15px;background:linear-gradient(90deg,#8b7cf8,rgba(139,124,248,0))}
+.lg-copy p{margin:14px 0 0;font-size:12px;line-height:1.8;color:rgba(237,240,247,.55)}
+.lg-badge{position:relative;z-index:2;align-self:flex-start;display:inline-flex;align-items:center;gap:6px;font-size:11px;color:rgba(237,240,247,.72);border:1px solid rgba(255,255,255,.13);background:rgba(255,255,255,.05);border-radius:999px;padding:5px 11px}
+.lg-badge svg{width:12px;color:#5fd8a5}
+
+/* 右侧表单面板 */
+.lg-main{position:relative;display:flex;flex-direction:column;padding:24px 32px 20px}
+.lg-tabs{display:flex;gap:22px;border-bottom:1px solid var(--border);margin-bottom:20px}
+.lg-tabs button{position:relative;border:0;background:none;color:var(--muted);font-weight:700;font-size:13px;letter-spacing:.4px;padding:6px 2px 12px;cursor:pointer;transition:color .2s}
+.lg-tabs button:hover,.lg-tabs button.active{color:var(--text)}
+.lg-tabs button::after{content:"";position:absolute;left:0;right:0;bottom:-1px;height:2px;border-radius:2px;background:var(--primary);transform:scaleX(0);transform-origin:left;transition:transform .28s cubic-bezier(.3,1,.4,1)}
+.lg-tabs button.active::after{transform:scaleX(1)}
+.lg-title{margin:0 0 18px;font-size:19px;letter-spacing:.3px}
+.lg-fields{display:grid;gap:11px;margin-bottom:16px}
+.lg-field{position:relative;display:flex;flex-direction:column;gap:2px;border:1px solid var(--border);border-radius:12px;background:color-mix(in srgb,var(--surface-2) 55%,transparent);padding:8px 14px 7px;cursor:text;transition:border-color .18s,box-shadow .18s,background .18s}
+.lg-field:hover{border-color:color-mix(in srgb,var(--primary) 32%,var(--border))}
+.lg-field:focus-within{border-color:color-mix(in srgb,var(--primary) 72%,var(--border));background:var(--surface-2);box-shadow:0 0 0 3px rgba(115,103,245,.12)}
+.lg-field>span{font-size:10.5px;font-weight:700;letter-spacing:1px;color:var(--muted);transition:color .18s}
+.lg-field:focus-within>span{color:var(--primary)}
+.lg-field input{border:0;outline:0;background:transparent;color:var(--text);height:23px;min-height:0;padding:0;margin:0;font-size:13.5px;box-shadow:none;border-radius:0;width:100%}
+.lg-field input::placeholder{color:color-mix(in srgb,var(--muted) 52%,transparent)}
+.lg-pass{padding-right:46px}
+.login-eye{position:absolute;right:8px;top:50%;transform:translateY(-50%);border:0;background:transparent;color:var(--muted);cursor:pointer;width:30px;height:30px;display:grid;place-items:center;border-radius:9px;transition:color .15s,background .15s}
+.login-eye:hover{color:var(--text);background:var(--surface-3)}
+.login-eye svg{width:15px}
+.lg-error{display:flex;align-items:center;gap:8px;margin:0;font-size:12px;color:#ff8f99;animation:login-shake .38s cubic-bezier(.36,.07,.19,.97)}
+.lg-error::before{content:"";width:5px;height:5px;border-radius:50%;background:#ff8f99;flex:none}
+@keyframes login-shake{10%,90%{transform:translateX(-1px)}20%,80%{transform:translateX(2px)}30%,50%,70%{transform:translateX(-3px)}40%,60%{transform:translateX(3px)}}
+.lg-submit{height:44px;margin-top:3px;border:0;border-radius:12px;background:linear-gradient(180deg,#7e72f6,#6155e6);color:#fff;font-weight:700;font-size:13.5px;letter-spacing:.4px;display:flex;align-items:center;justify-content:center;gap:8px;cursor:pointer;box-shadow:0 12px 26px rgba(97,85,230,.3),inset 0 1px 0 rgba(255,255,255,.24),inset 0 -1px 0 rgba(0,0,0,.16);transition:filter .18s,transform .18s,box-shadow .18s}
+.lg-submit:hover:not(:disabled){filter:brightness(1.07);transform:translateY(-1px);box-shadow:0 16px 30px rgba(97,85,230,.38),inset 0 1px 0 rgba(255,255,255,.26)}
+.lg-submit:active:not(:disabled){transform:translateY(0) scale(.99)}
+.lg-submit:disabled{opacity:.45;cursor:not-allowed}
+.lg-submit svg{width:15px;transition:transform .2s}
+.lg-submit:hover:not(:disabled) svg{transform:translateX(3px)}
+.login-switch{border:0;background:transparent;color:var(--primary);cursor:pointer;font-size:12.5px;padding:4px;justify-self:center;border-radius:6px}
+.login-switch:hover{text-decoration:underline}
+.lg-foot{display:flex;align-items:center;justify-content:center;gap:7px;color:var(--muted);font-size:11px;line-height:1.6;margin:auto 0 0;padding-top:14px;border-top:1px dashed color-mix(in srgb,var(--border) 85%,transparent);text-align:center}
+.lg-foot svg{width:12px;flex:none;color:var(--green)}
+.login-field-enter-active,.login-field-leave-active{transition:opacity .2s,transform .2s}
+.login-field-enter-from,.login-field-leave-to{opacity:0;transform:translateY(-6px)}
+@media(prefers-reduced-motion:reduce){.lg-error{animation:none}.lg-rings::after{animation:none}}
+.cp-overlay{position:fixed;inset:0;z-index:58;background:rgba(5,8,14,.55);backdrop-filter:blur(6px);display:flex;justify-content:center;padding-top:14vh;animation:login-fade .15s ease-out}
+.cp-panel{width:560px;max-width:calc(100vw - 48px);height:fit-content;max-height:60vh;display:flex;flex-direction:column;border:1px solid var(--border);border-radius:12px;background:var(--surface);box-shadow:0 24px 70px rgba(0,0,0,.5);overflow:hidden;animation:login-pop .18s ease-out}
+.cp-input{display:flex;align-items:center;gap:10px;padding:0 14px;border-bottom:1px solid var(--border)}
+.cp-input svg{width:17px;color:var(--muted);flex:none}
+.cp-input input{flex:1;height:48px;border:0;outline:0;background:transparent;color:var(--text);font-size:14.5px}
+.cp-input kbd{flex:none;font-size:10.5px;color:var(--muted);border:1px solid var(--border);border-radius:5px;padding:2px 6px;background:var(--surface-2)}
+.cp-list{overflow-y:auto;padding:6px}
+.cp-group small{display:block;padding:8px 10px 4px;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.06em}
+.cp-item{display:flex;align-items:center;gap:10px;width:100%;text-align:left;border:0;background:transparent;color:var(--text);padding:9px 10px;border-radius:8px;cursor:pointer;font-size:13.5px}
+.cp-item.active{background:var(--surface-3)}
+.cp-item svg{width:15px;color:var(--muted);flex:none}
+.cp-item .cp-title{flex:none;max-width:55%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.cp-item .cp-sub{flex:1;color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.cp-item .cp-enter{margin-left:auto;color:var(--primary)}
+.cp-empty{padding:26px 16px;text-align:center;color:var(--text);font-size:13px;margin:0}
+.cp-empty.muted{color:var(--muted)}
+.pwd-change{margin-top:20px;border-top:1px solid var(--border);padding-top:16px}
+.pwd-change summary{display:flex;align-items:center;gap:9px;cursor:pointer;color:var(--muted);font-weight:600;list-style:none;user-select:none;transition:color .2s}
+.pwd-change summary::-webkit-details-marker{display:none}
+.pwd-change summary:hover,.pwd-change[open] summary{color:var(--text)}
+.pwd-change summary svg{width:17px}
+.pwd-change[open] summary{margin-bottom:4px}
.sidebar-bottom .version{margin:0;border:0;padding:0;flex:1}
.quick-settings-btn{width:34px;height:34px;border:1px solid var(--border);border-radius:8px;background:var(--surface-2);color:var(--muted);display:grid;place-items:center;cursor:pointer;transition:color .2s,border-color .2s,background .2s}
.quick-settings-btn:hover{color:var(--text);border-color:var(--primary)}
@@ -31,11 +164,11 @@ main::after{content:"";position:fixed;inset:0 0 0 232px;pointer-events:none;back
.stat-copy{min-width:0;display:grid;gap:4px}
.stat-card strong,.stat-value{font-size:clamp(22px,2.2vw,29px);line-height:1.05;word-break:keep-all;white-space:normal}
.stat-card small{line-height:1.25}
-.metric-row{gap:12px}
-.metric-row>div{min-width:0}
-.metric-row b{font-size:clamp(19px,2vw,25px);line-height:1.05;white-space:nowrap}
-.metric-row span{display:block;white-space:nowrap}
-.project-metrics b{font-variant-numeric:tabular-nums;font-weight:900;letter-spacing:.2px;text-shadow:0 0 18px currentColor}
+.metric-row{gap:10px}
+.metric-row>div{min-width:0;overflow:hidden}
+.metric-row b{font-size:clamp(13.5px,1.5vw,23px);line-height:1.05;white-space:nowrap}
+.metric-row span{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.project-metrics b{font-variant-numeric:tabular-nums;font-weight:800;letter-spacing:0}
.project-metrics .metric-total b{color:#72b7ff}
.project-metrics .metric-code b{color:#55d6a2}
.project-metrics .metric-files b{color:#b39cff}
@@ -100,12 +233,13 @@ main::after{content:"";position:fixed;inset:0 0 0 232px;pointer-events:none;back
.loading-style-card.fullscreen-orbit .loading-style-preview i{border:2px solid rgba(110,231,255,.72);box-shadow:0 0 0 7px rgba(124,108,255,.14),0 0 18px rgba(110,231,255,.48)}
.loading-style-card.fullscreen-orbit .loading-style-preview i::before{content:"";position:absolute;inset:8px;border-radius:50%;background:#53d6a2;box-shadow:0 0 16px rgba(83,214,162,.78)}
.loading-style-card.fullscreen-orbit .loading-style-preview i::after{content:"";position:absolute;width:7px;height:7px;border-radius:50%;background:#fff;right:-2px;top:9px;box-shadow:0 0 12px rgba(255,255,255,.9)}
-.loading-style-card.fullscreen-grid .loading-style-preview i{width:34px;height:34px;border-radius:4px;background:linear-gradient(90deg,rgba(110,231,255,.34) 1px,transparent 1px),linear-gradient(180deg,rgba(83,214,162,.3) 1px,transparent 1px);background-size:9px 9px}
-.loading-style-card.fullscreen-grid .loading-style-preview i::before{content:"";position:absolute;left:4px;right:4px;top:15px;height:4px;background:rgba(110,231,255,.75);box-shadow:0 0 12px rgba(110,231,255,.75)}
-.loading-style-card.fullscreen-grid .loading-style-preview i::after{content:"";position:absolute;width:7px;height:7px;right:6px;bottom:5px;border-radius:50%;background:#53d6a2;box-shadow:-17px -14px 0 rgba(124,108,255,.82),0 0 12px rgba(83,214,162,.8)}
+.loading-style-card.fullscreen-grid .loading-style-preview i{width:34px;height:34px;border-radius:50%;background:radial-gradient(circle at 50% 50%,rgba(255,255,255,.95) 0 7%,rgba(110,231,255,.42) 13%,transparent 32%),radial-gradient(circle at 27% 36%,#6ee7ff 0 6%,transparent 13%),radial-gradient(circle at 72% 28%,#9188ff 0 5%,transparent 12%),radial-gradient(circle at 68% 72%,#53d6a2 0 6%,transparent 13%),radial-gradient(circle at 28% 72%,#8ea2ff 0 4%,transparent 11%),radial-gradient(circle at 86% 50%,#6ee7ff 0 4%,transparent 10%);box-shadow:0 0 18px rgba(124,108,255,.5)}
+.loading-style-card.fullscreen-grid .loading-style-preview i::before{content:"";position:absolute;inset:3px;border-radius:50%;border:1px dashed rgba(148,136,255,.55);transform:rotate(24deg)}
.loading-style-card.fullscreen-warp .loading-style-preview i{width:36px;height:36px;background:radial-gradient(circle,#fff 0 8%,#6ee7ff 11%,rgba(124,108,255,.32) 34%,transparent 62%);box-shadow:0 0 20px rgba(124,108,255,.62)}
.loading-style-card.fullscreen-warp .loading-style-preview i::before,.loading-style-card.fullscreen-warp .loading-style-preview i::after{content:"";position:absolute;left:50%;top:50%;width:36px;height:2px;border-radius:999px;background:linear-gradient(90deg,transparent,#6ee7ff,transparent);transform:translate(-50%,-50%) rotate(32deg);box-shadow:0 0 10px rgba(110,231,255,.7)}
.loading-style-card.fullscreen-warp .loading-style-preview i::after{transform:translate(-50%,-50%) rotate(-28deg);background:linear-gradient(90deg,transparent,#53d6a2,transparent)}
+.loading-style-card.fullscreen-matrix .loading-style-preview{background:#041008}
+.loading-style-card.fullscreen-matrix .loading-style-preview i{width:34px;height:34px;border-radius:4px;background:linear-gradient(180deg,transparent 0 10%,#a5ffcb 10% 19%,rgba(60,200,110,.5) 19% 72%,transparent 72%) 2px 0/3px 100% no-repeat,linear-gradient(180deg,transparent 0 36%,#a5ffcb 36% 45%,rgba(60,200,110,.45) 45% 96%,transparent 96%) 9px 0/3px 100% no-repeat,linear-gradient(180deg,#a5ffcb 0 8%,rgba(60,200,110,.5) 8% 52%,transparent 52%) 16px 0/3px 100% no-repeat,linear-gradient(180deg,transparent 0 22%,#a5ffcb 22% 31%,rgba(60,200,110,.45) 31% 84%,transparent 84%) 23px 0/3px 100% no-repeat,linear-gradient(180deg,transparent 0 52%,#a5ffcb 52% 60%,rgba(60,200,110,.4) 60% 100%) 30px 0/3px 100% no-repeat;box-shadow:0 0 14px rgba(90,230,140,.35)}
.loading-style-card.bar .loading-style-preview i{width:34px;height:8px;border-radius:999px;background:rgba(255,255,255,.1);overflow:hidden}
.loading-style-card.bar .loading-style-preview i::before{content:"";position:absolute;inset:0 38% 0 0;border-radius:inherit;background:linear-gradient(90deg,#6ee7ff,#53d6a2);box-shadow:0 0 12px rgba(110,231,255,.65)}
.toast{transition:opacity .32s ease,transform .36s ease,filter .32s ease}
@@ -113,7 +247,7 @@ main::after{content:"";position:fixed;inset:0 0 0 232px;pointer-events:none;back
.toast.leaving{opacity:0;transform:translate(18px,-12px);pointer-events:none}
.detail-sticky-head{display:grid;gap:16px;align-items:stretch}
.detail-page{--detail-sticky-offset:16px;max-width:none;margin:0;padding:var(--detail-sticky-offset) 30px 60px}
-.detail-page .detail-sticky-head{position:sticky;top:var(--detail-sticky-offset);width:100%;z-index:24;margin:0 0 24px;border-radius:8px;border-top:1px solid rgba(255,255,255,.08);background:color-mix(in srgb,var(--surface) 88%,transparent);box-shadow:0 18px 42px rgba(0,0,0,.34),0 1px 0 rgba(255,255,255,.06)}
+.detail-page .detail-sticky-head{position:sticky;top:calc(var(--topbar-h) + var(--detail-sticky-offset));width:100%;z-index:24;margin:0 0 24px;border-radius:8px;border-top:1px solid rgba(255,255,255,.08);background:color-mix(in srgb,var(--surface) 88%,transparent);box-shadow:0 18px 42px rgba(0,0,0,.34),0 1px 0 rgba(255,255,255,.06)}
.detail-head-row{display:flex;align-items:center;gap:28px;min-width:0}
.detail-head-row>div{min-width:0}
.detail-head-row p{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -153,6 +287,8 @@ html[data-theme=light] .heat-cell.l1{background:#b9efd4}html[data-theme=light] .
.issue-card p{margin:0;color:var(--muted)}
.issue-card small,.issue-card b,.issue-card code{display:block;margin-top:7px}
.issue-card small{color:#8fb2ff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
+.issue-card small.issue-file{cursor:pointer;text-decoration:underline dotted rgba(143,178,255,.55);text-underline-offset:3px;transition:color .16s}
+.issue-card small.issue-file:hover{color:#c3d6ff;text-decoration-style:solid}
.issue-card code{white-space:pre-wrap;word-break:break-word;background:rgba(0,0,0,.2);padding:8px;border-radius:6px;color:var(--muted);max-width:100%;overflow:hidden}
.issue-card b{color:var(--text);font-weight:700}
.git-error-panel{display:grid;grid-template-columns:42px 1fr auto;align-items:center;gap:14px;border-color:rgba(240,94,104,.42)}
@@ -167,6 +303,493 @@ html[data-theme=light] .heat-cell.l1{background:#b9efd4}html[data-theme=light] .
.large-file-panel{max-height:360px;overflow:auto}
.large-file{border-radius:7px;gap:16px}
.large-file>div{min-width:0}
+.log-toolbar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:18px}
+.log-toolbar .tabs{margin:0}
+.log-search{flex:1;min-width:220px;width:auto}
+.log-category{height:38px;min-width:150px;border:1px solid var(--border);border-radius:7px;background:var(--surface-2);color:var(--text);padding:0 11px;outline:none}
+.log-rows{display:grid;gap:7px}
+.log-day{display:flex;align-items:center;gap:12px;padding:16px 2px 7px}
+.log-day:first-child{padding-top:2px}
+.log-day span{color:var(--muted);font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;background:var(--surface-2);border:1px solid var(--border);border-radius:999px;padding:3px 12px}
+.log-day i{flex:1;height:1px;background:linear-gradient(90deg,var(--border),transparent)}
+.log-chips{display:flex;gap:8px}
+.log-chip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);border-radius:999px;background:var(--surface-2);color:var(--muted);padding:6px 12px;font-size:12.5px;cursor:pointer;transition:border-color .15s,color .15s,background .15s}
+.log-chip svg{width:13px}
+.log-chip b{font-size:11px;font-weight:700;padding:1px 7px;border-radius:999px;background:var(--surface-3);color:inherit}
+.log-chip.info.active,.log-chip.info:hover{color:var(--blue);border-color:rgba(79,157,245,.5)}
+.log-chip.warning.active,.log-chip.warning:hover{color:var(--yellow);border-color:rgba(231,189,53,.5)}
+.log-chip.error.active,.log-chip.error:hover{color:var(--red);border-color:rgba(240,94,104,.5)}
+.log-chip.info.active{background:rgba(79,157,245,.12)}
+.log-chip.warning.active{background:rgba(231,189,53,.12)}
+.log-chip.error.active{background:rgba(240,94,104,.12)}
+.log-row{border:1px solid color-mix(in srgb,var(--border) 82%,transparent);border-radius:11px;background:color-mix(in srgb,var(--surface-2) 72%,transparent);overflow:hidden;transition:border-color .16s,box-shadow .16s,background .16s}
+.log-row:hover{border-color:color-mix(in srgb,var(--primary) 26%,var(--border));box-shadow:0 8px 20px rgba(0,0,0,.16)}
+.log-row.open{border-color:color-mix(in srgb,var(--primary) 34%,var(--border));background:var(--surface-2)}
+.log-row.warning{background:linear-gradient(90deg,rgba(231,189,53,.055),transparent 46%),color-mix(in srgb,var(--surface-2) 72%,transparent)}
+.log-row.error{background:linear-gradient(90deg,rgba(240,94,104,.075),transparent 46%),color-mix(in srgb,var(--surface-2) 72%,transparent)}
+.log-row-main{width:100%;display:grid;grid-template-columns:30px 1fr auto auto 14px;align-items:center;gap:12px;border:0;background:transparent;color:var(--text);padding:10px 14px;min-height:50px;cursor:default;text-align:left;font:inherit}
+.log-row.clickable .log-row-main{cursor:pointer}
+.log-detail-wrap{position:relative;margin:2px 14px 12px 56px}
+.log-detail-wrap .log-detail{margin:0;padding-right:76px}
+.log-copy{position:absolute;top:7px;right:7px;display:inline-flex;align-items:center;gap:5px;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--muted);font-size:11.5px;padding:4px 9px;cursor:pointer;transition:color .15s,border-color .15s}
+.log-copy:hover{color:var(--text)}
+.log-copy.ok{color:var(--green);border-color:rgba(61,196,126,.5)}
+.log-copy svg{width:12px}
+.log-empty{display:grid;justify-items:center;gap:6px;padding:44px 0}
+.log-empty b{font-size:14px}
+.log-empty small{color:var(--muted)}
+.log-row .log-badge{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;background:rgba(79,157,245,.12);color:var(--blue);box-shadow:inset 0 0 0 1px rgba(79,157,245,.18)}
+.log-row.warning .log-badge{background:rgba(231,189,53,.11);color:var(--yellow);box-shadow:inset 0 0 0 1px rgba(231,189,53,.18)}
+.log-row.error .log-badge{background:rgba(240,94,104,.11);color:var(--red);box-shadow:inset 0 0 0 1px rgba(240,94,104,.2)}
+.log-row svg{width:14px}
+.log-cat{font-size:11px;font-weight:600;color:var(--muted);background:var(--surface-3);border:1px solid color-mix(in srgb,var(--border) 70%,transparent);border-radius:999px;padding:3px 10px;white-space:nowrap}
+.log-msg{font-weight:600;font-size:13px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.log-row time{color:var(--muted);font-size:11.5px;white-space:nowrap;font-variant-numeric:tabular-nums}
+.log-caret{color:var(--muted);opacity:.55;transition:transform .2s,opacity .15s}
+.log-row:hover .log-caret{opacity:1}
+.log-row.open .log-caret{transform:rotate(180deg)}
+.log-detail{display:block;background:rgba(0,0,0,.26);border:1px solid color-mix(in srgb,var(--border) 60%,transparent);border-radius:9px;padding:11px 13px;white-space:pre-wrap;word-break:break-word;color:var(--muted);font-size:12px;line-height:1.6}
+.log-pager{display:flex;align-items:center;justify-content:center;gap:14px;margin-top:16px}
+.log-pager .btn{width:38px;padding:0;justify-content:center}
+.log-pager span{color:var(--muted);font-variant-numeric:tabular-nums}
+.batch-summary{border-color:rgba(67,201,150,.4)}
+.batch-summary.has-failures{border-color:rgba(240,94,104,.42)}
+.batch-summary header{display:flex;justify-content:space-between;align-items:center}
+.batch-summary h2{display:flex;align-items:center;gap:9px}
+.batch-summary h2 svg{width:19px}
+.batch-summary .ok{color:var(--green)}
+.batch-summary .bad{color:#ff8a95}
+.batch-summary .skip{color:var(--muted)}
+.batch-close{border:0;background:transparent;color:var(--muted);cursor:pointer;padding:6px;border-radius:6px}
+.batch-close:hover{background:var(--surface-3);color:var(--text)}
+.batch-close svg{width:16px}
+.batch-counts{display:flex;gap:16px;flex-wrap:wrap;margin-top:12px;color:var(--muted)}
+.batch-counts span{display:inline-flex;align-items:center;gap:6px}
+.batch-counts svg{width:15px}
+.batch-failures{margin:12px 0 0;padding:10px 12px;list-style:none;background:rgba(240,94,104,.07);border-radius:7px;display:grid;gap:6px;color:#ffb4bc;font-size:13px}
+.panel h2 small{margin-left:10px;color:var(--muted);font-weight:normal;font-size:12px}
+.panel-icon{width:18px;color:var(--primary);vertical-align:-3px;margin-right:8px}
+.lang-overview .chart{height:300px}
+.lang-rank{display:grid;gap:8px;margin-top:16px;max-height:300px;overflow:auto;padding-right:4px}
+.lang-rank-row{display:grid;grid-template-columns:12px minmax(90px,auto) auto 1fr auto 42px;align-items:center;gap:12px;background:var(--surface-2);border-radius:7px;padding:11px 14px}
+.lang-rank-row i{width:10px;height:10px;border-radius:50%}
+.lang-rank-row span{color:var(--muted);font-size:12px;white-space:nowrap}
+.lang-rank-row em{display:block;height:6px;border-radius:4px;background:var(--surface-3);overflow:hidden}
+.lang-rank-row em s{display:block;height:100%;border-radius:4px;text-decoration:none}
+.lang-rank-row strong{font-variant-numeric:tabular-nums}
+.lang-rank-row small{color:var(--muted);text-align:right}
+.hotspot-panel h2{display:flex;align-items:center;gap:4px}
+.hotspot-icon{width:18px;color:#fb923c;margin-right:6px}
+.hotspot-list{display:grid;gap:7px;margin-top:15px;max-height:420px;overflow:auto;padding-right:4px}
+.hotspot-row{display:grid;grid-template-columns:30px minmax(0,1.4fr) 1fr auto 56px 56px;align-items:center;gap:12px;border:0;border-radius:7px;background:var(--surface-2);color:var(--text);padding:10px 14px;cursor:pointer;text-align:left;font:inherit;transition:background .2s}
+.hotspot-row:hover{background:var(--surface-3)}
+.hotspot-row>b{color:var(--muted)}
+.hotspot-path{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.hotspot-bar{display:block;height:6px;border-radius:4px;background:var(--surface-3);overflow:hidden}
+.hotspot-bar em{display:block;height:100%;background:linear-gradient(90deg,#fb923c,#ef6683);border-radius:4px}
+.hotspot-row strong{font-size:12px;color:var(--muted);white-space:nowrap}
+.hotspot-row .positive,.hotspot-row .negative{font-size:12px;text-align:right}
+.file-clickable{cursor:pointer;border-radius:6px;transition:background .15s}
+.file-tree .file-clickable:hover{background:var(--surface-2)}
+.large-file.file-clickable:hover{background:var(--surface-3)}
+.preview-overlay{z-index:60}
+.preview-modal{width:min(1060px,calc(100vw - 64px));height:min(780px,calc(100vh - 64px));max-width:none;display:flex;flex-direction:column;padding:0;overflow:hidden}
+.preview-modal header{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:18px;align-items:center;padding:16px 20px;border-bottom:1px solid var(--border)}
+.preview-title{display:flex;align-items:center;gap:12px;min-width:0}
+.preview-title>svg{width:22px;color:var(--primary);flex:none}
+.preview-title>div{min-width:0}
+.preview-title h2{margin:0;font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.preview-title p{margin:2px 0 0;color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.preview-meta{display:flex;gap:12px;color:var(--muted);font-size:12px;white-space:nowrap}
+.preview-truncated{color:var(--yellow)}
+.preview-actions{display:flex;gap:6px}
+.preview-actions button{width:34px;height:34px;border:1px solid var(--border);border-radius:7px;background:var(--surface-2);color:var(--muted);display:grid;place-items:center;cursor:pointer}
+.preview-actions button:hover{color:var(--text);border-color:var(--primary)}
+.preview-actions svg{width:16px}
+.preview-state{flex:1;display:grid;place-content:center;justify-items:center;gap:12px;color:var(--muted)}
+.preview-state.error{color:#ff8a95}
+.preview-state svg{width:30px}
+.preview-body{position:relative;flex:1;display:flex;align-items:flex-start;overflow:auto;background:#0d1117}
+.preview-hl-line{position:absolute;left:0;right:0;background:rgba(231,189,53,.13);border-left:2px solid var(--yellow);pointer-events:none}
+.preview-gutter span.hit{color:#ffd968;font-weight:700}
+.preview-gutter{position:sticky;left:0;z-index:1;flex:none;display:flex;flex-direction:column;padding:14px 0;background:#0b0f14;border-right:1px solid rgba(255,255,255,.07);color:#5a657a;font:12.5px/1.6 Consolas,'Courier New',monospace;text-align:right;min-width:52px;user-select:none}
+.preview-gutter span{padding:0 12px}
+.preview-src{flex:1;margin:0;padding:14px 18px;font:12.5px/1.6 Consolas,'Courier New',monospace;color:#c9d1d9}
+.preview-src code{display:block;background:transparent!important;padding:0;font:inherit;white-space:pre}
+.view-toggle{margin:0}
+.tab-count{display:inline-block;margin-left:7px;padding:1px 7px;border-radius:999px;background:rgba(255,255,255,.08);font-style:normal;font-size:11px}
+.todo-toolbar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:20px}
+.todo-toolbar .tabs{margin:0}
+.quick-add{flex:1;min-width:240px;width:auto}
+.todo-board{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;align-items:start}
+.todo-column{border:1px solid var(--border);border-radius:8px;background:color-mix(in srgb,var(--surface) 72%,transparent);padding:14px;display:grid;gap:10px;min-height:280px;align-content:start}
+.todo-column>header{display:flex;align-items:center;gap:9px;padding:2px 4px 8px;border-bottom:1px solid var(--border)}
+.todo-column>header svg{width:16px}
+.todo-column.open>header{color:#72b7ff}
+.todo-column.doing>header{color:#f7cb4d}
+.todo-column.done>header{color:var(--green)}
+.todo-column>header span{margin-left:auto;color:var(--muted);font-size:12px}
+.todo-card{position:relative;display:grid;grid-template-columns:24px 1fr 24px;gap:9px;align-items:start;border:1px solid var(--glass-border);border-radius:12px;background:linear-gradient(180deg,rgba(255,255,255,.018),transparent 60%),var(--surface-2);padding:12px 12px 11px;overflow:hidden;transition:border-color .18s,transform .18s,box-shadow .18s}
+.todo-card:hover{transform:translateY(-1px);border-color:rgba(122,162,247,.38);box-shadow:0 10px 26px -18px rgba(0,0,0,.85)}
+.todo-card:has(b.done){opacity:.6}
+.todo-card.overdue{border-color:rgba(240,94,104,.3);background:linear-gradient(180deg,rgba(240,94,104,.06),transparent 55%),var(--surface-2)}
+.todo-check{border:0;background:transparent;color:var(--muted);cursor:pointer;width:24px;height:24px;margin-top:-1px;border-radius:8px;display:grid;place-items:center;transition:background .16s,color .16s,transform .16s}
+.todo-check svg{width:17px}
+.todo-check:hover{color:var(--green);background:rgba(88,199,110,.13);transform:scale(1.08)}
+.todo-main{min-width:0;cursor:pointer}
+.todo-main b{font-size:13px;font-weight:650;line-height:1.45;letter-spacing:.2px}
+.todo-main b.done{text-decoration:line-through;color:var(--muted)}
+.todo-main p{margin:4px 0 0;color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
+.todo-meta{display:flex;flex-wrap:wrap;gap:6px;margin-top:9px;align-items:center}
+.todo-chip{display:inline-flex;max-width:150px;padding:2.5px 9px;border-radius:999px;background:rgba(115,103,245,.13);border:1px solid rgba(115,103,245,.22);color:#a9a2ff;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.todo-due{display:inline-flex;align-items:center;gap:4.5px;color:var(--text-dim);font-size:11px;padding:2.5px 9px;border-radius:999px;background:var(--surface-3);border:1px solid var(--glass-border)}
+.todo-due svg{width:11.5px}
+.todo-due.overdue,.overdue-text{color:#ff8a95!important}
+.todo-due.overdue{background:rgba(240,94,104,.1);border-color:rgba(240,94,104,.28)}
+.todo-priority{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:2.5px 9px;border-radius:999px}
+.todo-priority svg{width:11px}
+.todo-priority.high{background:rgba(240,94,104,.12);border:1px solid rgba(240,94,104,.25);color:#ff8a95}
+.todo-priority.medium{background:rgba(231,189,53,.11);border:1px solid rgba(231,189,53,.24);color:#ffd166}
+.todo-priority.low{background:rgba(79,157,245,.11);border:1px solid rgba(79,157,245,.24);color:#72b7ff}
+.todo-remove{border:0;background:transparent;color:var(--muted);cursor:pointer;padding:2px;opacity:0;transition:opacity .15s}
+.todo-card:hover .todo-remove{opacity:1}
+.todo-remove svg{width:14px}
+.todo-remove:hover{color:var(--red)}
+.todo-empty{color:var(--muted);text-align:center;padding:22px 0;font-size:13px}
+.todo-list-panel{display:grid;gap:9px}
+.todo-row{position:relative;display:grid;grid-template-columns:26px minmax(0,1fr) auto auto 150px 70px;gap:12px;align-items:center;border:1px solid var(--glass-border);border-radius:11px;background:var(--surface-2);padding:10px 13px;overflow:hidden;transition:border-color .18s,background .18s}
+.todo-row:hover{border-color:rgba(122,162,247,.35);background:linear-gradient(180deg,rgba(255,255,255,.02),transparent),var(--surface-2)}
+.todo-row:has(b.done){opacity:.6}
+.todo-row.overdue{border-color:rgba(240,94,104,.3)}
+.todo-row b.done{text-decoration:line-through;color:var(--muted)}
+.todo-row b{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:650;letter-spacing:.2px}
+.todo-row .todo-due{font-size:12px}
+.modal-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 18px}
+.modal-grid>label{padding-right:0}
+.ticket-panel{display:grid;gap:10px}
+.ticket-row{position:relative;display:grid;grid-template-columns:40px minmax(0,1fr) auto;gap:14px;align-items:center;border:1px solid var(--glass-border);border-radius:12px;background:linear-gradient(180deg,rgba(255,255,255,.015),transparent 60%),var(--surface-2);padding:14px;overflow:hidden;transition:border-color .18s,transform .18s,box-shadow .18s}
+.ticket-row:hover{transform:translateY(-1px);border-color:rgba(122,162,247,.38);box-shadow:0 10px 26px -18px rgba(0,0,0,.85)}
+.ticket-row.closed{opacity:.62}
+.ticket-row.overdue{border-color:rgba(240,94,104,.38);background:linear-gradient(180deg,rgba(240,94,104,.05),transparent 55%),var(--surface-2)}
+.ticket-type{width:38px;height:38px;border-radius:11px;display:grid;place-items:center;background:rgba(115,103,245,.15);color:#a9a2ff;border:1px solid rgba(115,103,245,.2)}
+.ticket-type.bug{background:rgba(240,94,104,.13);color:#ff8a95;border-color:rgba(240,94,104,.22)}
+.ticket-type.feature{background:rgba(67,201,150,.13);color:#5fe0af;border-color:rgba(67,201,150,.22)}
+.ticket-type.improvement{background:rgba(231,189,53,.13);color:#ffd166;border-color:rgba(231,189,53,.22)}
+.ticket-type svg{width:17px}
+.ticket-main{min-width:0;cursor:pointer}
+.ticket-title{display:flex;align-items:center;gap:10px;min-width:0}
+.ticket-title b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13.5px;font-weight:650;letter-spacing:.2px}
+.ticket-main p{margin:4px 0 0;color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.ticket-status{flex:none;padding:2px 9px;border-radius:999px;font-size:11px;background:rgba(79,157,245,.13);color:#72b7ff}
+.ticket-status.in_progress{background:rgba(231,189,53,.13);color:#ffd166}
+.ticket-status.resolved{background:rgba(67,201,150,.13);color:#5fe0af}
+.ticket-status.closed{background:rgba(255,255,255,.07);color:var(--muted)}
+.ticket-status.todo-doing{background:rgba(231,189,53,.13);color:#ffd166}
+.ticket-status.todo-done{background:rgba(67,201,150,.13);color:#5fe0af}
+.ticket-actions{display:flex;align-items:center;gap:8px}
+.flow-btn{height:32px;padding:0 11px;font-size:12px}
+.flow-btn svg{width:13px}
+.calendar-nav{gap:10px}
+.calendar-nav .btn{width:38px;padding:0;justify-content:center;flex:0 0 auto}
+/* 文字类按钮按内容自适应宽度(不能依赖 last-child:末尾可能是管理员 icon 按钮) */
+.calendar-nav .daily-open-btn{width:auto;padding:0 14px;white-space:nowrap}
+.calendar-month{min-width:120px;text-align:center}
+/* 固定左右两栏:右栏按比例随窗口缩放(约 1/4),不再在窄屏折叠为单列 */
+.calendar-layout{display:grid;grid-template-columns:minmax(0,3fr) minmax(230px,1fr);gap:20px;align-items:start}
+.calendar-weekdays{display:grid;grid-template-columns:repeat(7,1fr);gap:6px;margin-bottom:8px;color:var(--muted);font-size:12px;text-align:center}
+/* 面板撑满可用视口高度,格子行高 1fr 平分剩余空间,窗口越大格子越高 */
+.calendar-grid-panel{display:flex;flex-direction:column;min-height:calc(100vh - var(--topbar-h) - 196px)}
+.calendar-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:6px;grid-auto-rows:minmax(86px,1fr);flex:1}
+.calendar-cell{position:relative;min-height:86px;border:1px solid var(--border);border-radius:7px;background:var(--surface-2);padding:6px;display:flex;flex-direction:column;gap:4px;cursor:pointer;text-align:left;color:var(--text);transition:border-color .15s,background .15s}
+.calendar-cell:hover{border-color:var(--primary)}
+.calendar-cell.out{opacity:.42}
+.calendar-cell.today .calendar-day{background:var(--primary);color:#fff}
+.calendar-cell.selected{border-color:var(--primary);box-shadow:0 0 0 2px rgba(115,103,245,.22)}
+.calendar-day{width:22px;height:22px;border-radius:6px;display:grid;place-items:center;font-size:12px;font-weight:700}
+.calendar-head-row{display:flex;align-items:center;justify-content:space-between;gap:4px}
+.calendar-lunar{color:var(--muted);font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.calendar-fest{align-self:flex-start;max-width:100%;font-size:10px;line-height:1;font-weight:600;color:var(--red);background:rgba(240,94,104,.13);border-radius:999px;padding:3px 7px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.calendar-fest.jieqi{color:var(--green);background:rgba(67,201,150,.13)}
+.calendar-lunar-card{border:1px solid var(--border);border-radius:8px;background:var(--surface-2);padding:10px 12px;margin-bottom:14px}
+.calendar-lunar-card b{font-size:13px;display:block}
+.calendar-fest-tags{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
+.calendar-fest-tags span{font-size:11px;color:var(--red);background:rgba(240,94,104,.12);border-radius:999px;padding:2px 9px}
+.calendar-ctx{position:fixed;z-index:70;min-width:170px;display:flex;flex-direction:column;padding:6px;border:1px solid var(--border);border-radius:10px;background:var(--surface);box-shadow:0 16px 44px rgba(0,0,0,.42);animation:login-fade .12s ease-out}
+.calendar-ctx small{color:var(--muted);font-size:11px;padding:4px 10px 6px}
+.calendar-ctx button{display:flex;align-items:center;gap:9px;border:0;background:transparent;color:var(--text);padding:8px 10px;border-radius:7px;cursor:pointer;font-size:13px;text-align:left}
+.calendar-ctx button:hover:not(:disabled){background:var(--surface-3)}
+.calendar-ctx button:disabled{opacity:.45;cursor:not-allowed}
+.calendar-ctx button svg{width:15px;color:var(--muted)}
+.quick-modal{position:relative;width:380px;padding:22px 24px}
+.quick-modal h2{display:flex;align-items:center;gap:8px;margin:0 0 16px;font-size:15px;padding-right:30px}
+.quick-modal .quick-date{margin-left:auto;color:var(--muted);font-size:12px;font-weight:400}
+.quick-form{display:grid;gap:10px}
+.quick-form input,.quick-form select{width:100%;height:38px;border:1px solid var(--border);border-radius:8px;background:var(--surface-2);color:var(--text);padding:0 12px;outline:0}
+.quick-form input:focus,.quick-form select:focus{border-color:var(--primary)}
+.quick-form .btn{justify-content:center;height:38px}
+.calendar-marks{display:flex;flex-direction:column;gap:3px;min-width:0}
+.calendar-marks small{color:var(--muted);font-size:10px}
+.mark-todo{width:100%;height:4px;border-radius:2px;background:#72b7ff}
+.mark-todo.high{background:#ff8a95}
+.mark-todo.medium{background:#ffd166}
+.mark-ticket{display:block;height:16px;border-radius:4px;background:rgba(115,103,245,.32);color:#cfcaff;font-size:10px;font-style:normal;line-height:16px;padding:0 5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.mark-ticket.in_progress{background:rgba(231,189,53,.28);color:#ffe3a0}
+.mark-ticket.start{border-top-left-radius:8px;border-bottom-left-radius:8px}
+.mark-ticket.end{border-top-right-radius:8px;border-bottom-right-radius:8px}
+.calendar-detail-panel{position:sticky;top:calc(var(--topbar-h) + 8px)}
+.calendar-group{margin-top:16px}
+.calendar-group h3{display:flex;align-items:center;gap:8px;margin:0 0 10px;font-size:13px;color:var(--muted)}
+.calendar-group h3 svg{width:15px}
+.calendar-item{position:relative;width:100%;display:grid;grid-template-columns:minmax(0,1fr) auto 16px;gap:10px;align-items:center;border:1px solid var(--glass-border);border-radius:10px;background:var(--surface-2);color:var(--text);padding:10px 12px;margin-bottom:6px;cursor:pointer;text-align:left;font:inherit;overflow:hidden;transition:border-color .18s,background .18s}
+.calendar-item:hover{background:var(--surface-3);border-color:rgba(122,162,247,.32)}
+.calendar-item b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.calendar-item svg{width:14px;color:var(--muted)}
+.bell-wrap{position:relative}
+.bell-btn{position:relative;width:32px;height:32px;border:0;border-radius:7px;background:transparent;color:var(--muted);display:grid;place-items:center;cursor:pointer;transition:background .15s,color .15s}
+.bell-btn:hover{background:var(--surface-3);color:var(--text)}
+.bell-btn svg{width:17px}
+.bell-badge{position:absolute;top:-3px;right:-5px;min-width:16px;height:16px;border-radius:999px;background:var(--red);color:#fff;font-size:10px;font-style:normal;display:grid;place-items:center;padding:0 4px;box-shadow:0 0 0 2px var(--side)}
+.bell-dropdown{position:absolute;right:0;top:40px;width:360px;border:1px solid var(--border);border-radius:8px;background:color-mix(in srgb,var(--surface-2) 90%,transparent);box-shadow:0 18px 42px rgba(0,0,0,.4);backdrop-filter:blur(24px) saturate(150%);animation:popoverIn .16s ease-out;z-index:50;overflow:hidden}
+.bell-dropdown header{display:flex;justify-content:space-between;align-items:center;padding:12px 14px;border-bottom:1px solid var(--border)}
+.bell-tools{display:flex;gap:4px}
+.bell-tools button{width:28px;height:28px;border:0;border-radius:6px;background:transparent;color:var(--muted);display:grid;place-items:center;cursor:pointer}
+.bell-tools button:hover{background:var(--surface-3);color:var(--text)}
+.bell-tools svg{width:15px}
+.bell-list{max-height:420px;overflow:auto}
+.bell-item{width:100%;display:grid;grid-template-columns:32px minmax(0,1fr) 10px;gap:10px;align-items:start;border:0;background:transparent;color:var(--text);padding:11px 14px;cursor:pointer;text-align:left;font:inherit;border-bottom:1px solid rgba(255,255,255,.04)}
+.bell-item:hover{background:var(--surface-3)}
+.bell-item.unread{background:rgba(115,103,245,.06)}
+.bell-icon{width:28px;height:28px;border-radius:7px;display:grid;place-items:center;background:rgba(79,157,245,.13);color:#72b7ff}
+.bell-icon.todo_due{background:rgba(231,189,53,.13);color:#ffd166}
+.bell-icon.ticket_due{background:rgba(240,94,104,.13);color:#ff8a95}
+.bell-icon.analysis{background:rgba(67,201,150,.13);color:#5fe0af}
+.bell-icon svg{width:14px}
+.bell-item b{display:block;font-size:13px}
+.bell-item p{margin:3px 0 0;color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
+.bell-item time{display:block;margin-top:4px;color:var(--muted);font-size:11px}
+.bell-dot{width:8px;height:8px;border-radius:50%;background:var(--primary);margin-top:6px}
+.bell-empty{padding:34px 0;text-align:center;color:var(--muted)}
+.wb-favorites{margin-top:4px}
+.wb-fav-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px;margin-top:16px}
+.wb-fav-card{border:1px solid var(--border);border-radius:8px;background:var(--surface-2);padding:15px;cursor:pointer;transition:border-color .15s,transform .15s}
+.wb-fav-card:hover{border-color:var(--primary);transform:translateY(-2px)}
+.wb-fav-head{display:flex;justify-content:space-between;align-items:center;gap:8px}
+.wb-fav-head b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.wb-fav-card p{margin:6px 0 12px;color:var(--muted);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.wb-fav-card footer{display:flex;gap:16px;color:var(--muted);font-size:12px;border-top:1px solid var(--border);padding-top:10px}
+.wb-fav-card footer span{display:inline-flex;align-items:center;gap:5px}
+.wb-fav-card footer svg{width:13px}
+.wb-star{border:0;background:transparent;color:var(--muted);cursor:pointer;padding:4px;display:grid;place-items:center;border-radius:6px}
+.wb-star svg{width:15px}
+.wb-star:hover{color:#ffd166}
+.wb-star.active{color:#ffd166}
+.wb-star.active svg{fill:#ffd166}
+.star-icon{color:#ffd166}
+/* 工作台四面板:默认 2×2,超宽一行四列,窄屏单列 */
+.wb-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px;margin-top:20px;align-items:start}
+@media(min-width:1700px){.wb-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}
+.wb-col{margin-bottom:0}
+.wb-col .section-head h2 small{margin-left:8px;color:var(--muted);font-weight:normal;font-size:12px}
+.wb-col .section-head .btn{height:32px;padding:0 11px;font-size:12px}
+.wb-col .section-head .btn svg{width:13px}
+.wb-list{display:grid;gap:8px;margin-top:14px}
+.wb-item{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:10px;align-items:center;background:var(--surface-2);border:1px solid var(--glass-border);border-radius:11px;padding:10px 12px;overflow:hidden;transition:border-color .18s}
+.wb-item:hover{border-color:rgba(122,162,247,.32)}
+/* 优先级标记:彩色边框 + 微光(替代旧的左侧色带)。规则置于各卡片 hover 之后,保证优先级边框色不被悬浮态覆盖 */
+.todo-card.high,.todo-row.high,.ticket-row.high,.wb-item.high,.calendar-item.high{border-color:rgba(240,94,104,.46);box-shadow:0 0 15px -4px rgba(240,94,104,.38),inset 0 0 12px -9px rgba(240,94,104,.5)}
+.todo-card.medium,.todo-row.medium,.ticket-row.medium,.wb-item.medium,.calendar-item.medium{border-color:rgba(231,189,53,.4);box-shadow:0 0 14px -4px rgba(231,189,53,.3),inset 0 0 12px -9px rgba(231,189,53,.42)}
+.todo-card.low,.todo-row.low,.ticket-row.low,.wb-item.low,.calendar-item.low{border-color:rgba(79,157,245,.34);box-shadow:0 0 13px -5px rgba(79,157,245,.26),inset 0 0 12px -10px rgba(79,157,245,.36)}
+.todo-card.high:hover,.ticket-row.high:hover{box-shadow:0 0 19px -3px rgba(240,94,104,.5),0 10px 26px -18px rgba(0,0,0,.85)}
+.todo-card.medium:hover,.ticket-row.medium:hover{box-shadow:0 0 18px -3px rgba(231,189,53,.4),0 10px 26px -18px rgba(0,0,0,.85)}
+.todo-card.low:hover,.ticket-row.low:hover{box-shadow:0 0 17px -4px rgba(79,157,245,.36),0 10px 26px -18px rgba(0,0,0,.85)}
+/* 完成/关闭态整体压暗,撤掉微光避免与低饱和内容冲突 */
+.todo-card:has(b.done),.todo-row:has(b.done),.ticket-row.closed{border-color:var(--glass-border);box-shadow:none}
+.wb-item>div{min-width:0}
+.wb-item b{display:block;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.wb-item small{display:inline-flex;align-items:center;gap:4px;color:var(--muted);font-size:11px;margin-top:3px}
+.wb-item small svg{width:11px}
+.wb-ticket-status{align-self:center}
+.wb-empty{min-height:80px;padding:14px 0}
+.wb-note{width:100%;min-height:150px;margin-top:14px;border:1px solid var(--border);border-radius:7px;background:rgba(0,0,0,.14);color:var(--text);padding:12px;resize:vertical;outline:none;font:inherit;font-size:13px;line-height:1.6}
+.wb-note:focus{border-color:var(--primary)}
+.wb-note-saved{color:var(--green);font-size:11px}
+.wb-msg-head{margin-top:18px}
+.wb-msg-list{display:grid;gap:6px;margin-top:12px}
+.wb-msg{display:flex;justify-content:space-between;gap:10px;background:var(--surface-2);border-radius:7px;padding:9px 12px;font-size:12px}
+.wb-msg.unread{background:rgba(115,103,245,.09)}
+.wb-msg b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}
+.wb-msg time{color:var(--muted);flex:none}
+.ai-entry{margin-left:auto;flex:none}
+.ai-provider-badge{display:inline-flex;align-items:center;gap:7px;padding:7px 13px;border-radius:999px;background:rgba(115,103,245,.14);color:#a9a2ff;font-size:13px}
+.ai-provider-badge svg{width:14px}
+.ai-key-notice{display:flex;align-items:center;gap:14px;margin-bottom:20px}
+.ai-key-notice>svg{width:22px;color:var(--primary);flex:none}
+.ai-key-notice>div{flex:1;display:grid;gap:3px}
+.ai-key-notice small{color:var(--muted)}
+/* AI 页固定视口高度:页面自身不滚动,左历史/右会话各自内部滚动 */
+.ai-page{height:calc(100vh - var(--topbar-h));display:flex;flex-direction:column;overflow:hidden;padding-bottom:22px}
+.ai-page .sticky-head{position:static;flex:none;margin:-12px -10px 18px}
+.ai-page .ai-key-notice{flex:none}
+.ai-layout{flex:1;min-height:0;display:grid;grid-template-columns:290px minmax(0,1fr);gap:20px;align-items:stretch}
+.ai-history{min-height:0;display:flex;flex-direction:column;overflow:hidden}
+.ai-history h2{flex:none;display:flex;justify-content:space-between;align-items:center;margin:0 0 12px;font-size:15px}
+.ai-history h2 small{color:var(--muted);font-weight:normal}
+.ai-conv-list{flex:1;min-height:0;display:grid;gap:6px;align-content:start;overflow:auto}
+.ai-conv{position:relative;display:grid;gap:5px;border:1px solid transparent;border-radius:7px;background:var(--surface-2);color:var(--text);padding:10px 12px;cursor:pointer;text-align:left;font:inherit}
+.ai-conv:hover{background:var(--surface-3)}
+.ai-conv.active{border-color:var(--primary);background:rgba(115,103,245,.1)}
+.ai-conv b{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:22px}
+.ai-conv small{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:11px}
+.ai-conv-del{position:absolute;top:8px;right:8px;color:var(--muted);opacity:0;transition:opacity .15s;cursor:pointer}
+.ai-conv:hover .ai-conv-del{opacity:1}
+.ai-conv-del:hover{color:var(--red)}
+.ai-conv-del svg{width:14px}
+.ai-chat{display:flex;flex-direction:column;height:100%;min-height:0;overflow:hidden}
+.ai-toolbar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding-bottom:14px;border-bottom:1px solid var(--border)}
+.ai-project-pick{display:inline-flex;align-items:center;gap:8px}
+.ai-project-pick svg{width:15px;color:var(--muted)}
+.ai-project-pick select{background:var(--surface-2);border:1px solid var(--border);border-radius:7px;color:var(--text);padding:8px 10px;outline:none;max-width:220px}
+.ai-quicks{display:flex;gap:8px;flex-wrap:wrap;margin-left:auto}
+.ai-quicks .btn{height:34px;padding:0 12px;font-size:12px}
+.ai-quicks .btn svg{width:13px}
+.ai-messages{flex:1;overflow:auto;padding:18px 4px;display:grid;gap:16px;align-content:start}
+.ai-welcome{display:grid;place-items:center;gap:10px;color:var(--muted);padding:60px 0}
+.ai-welcome svg{width:34px;opacity:.6}
+.ai-msg{display:grid;grid-template-columns:34px minmax(0,1fr);gap:12px;align-items:start}
+.ai-msg.user{grid-template-columns:minmax(0,1fr) 34px}
+.ai-msg.user .ai-avatar{order:2}
+.ai-msg.user .ai-bubble{order:1;justify-self:end;background:rgba(115,103,245,.16);white-space:pre-wrap}
+.ai-avatar{width:32px;height:32px;border-radius:8px;display:grid;place-items:center;background:var(--surface-3);color:var(--muted)}
+.ai-msg.assistant .ai-avatar{background:rgba(115,103,245,.16);color:#a9a2ff}
+.ai-avatar svg{width:16px}
+.ai-bubble{max-width:860px;border-radius:9px;background:var(--surface-2);padding:11px 14px;font-size:13.5px;line-height:1.7;overflow-wrap:break-word}
+.ai-bubble.markdown :is(h1,h2,h3){font-size:15px;margin:12px 0 6px}
+.ai-bubble.markdown p{margin:6px 0}
+.ai-bubble.markdown ul,.ai-bubble.markdown ol{margin:6px 0;padding-left:22px}
+.ai-bubble.markdown code{background:rgba(0,0,0,.28);border-radius:4px;padding:1px 5px;font-size:12px}
+.ai-bubble.markdown pre{background:#0d1117;border:1px solid var(--border);border-radius:8px;padding:12px;overflow:auto;margin:8px 0}
+.ai-bubble.markdown pre code{background:transparent;padding:0;font-size:12px;line-height:1.6}
+.ai-bubble.markdown table{border-collapse:collapse;margin:8px 0}
+.ai-bubble.markdown th,.ai-bubble.markdown td{border:1px solid var(--border);padding:5px 10px;font-size:12px}
+.ai-bubble.markdown blockquote{border-left:3px solid var(--primary);margin:8px 0;padding:2px 12px;color:var(--muted)}
+.ai-typing{display:inline-flex;gap:4px;margin-left:2px}
+.ai-typing i{width:6px;height:6px;border-radius:50%;background:var(--primary);animation:aiTyping 1s infinite}
+.ai-typing i:nth-child(2){animation-delay:.2s}
+.ai-typing i:nth-child(3){animation-delay:.4s}
+@keyframes aiTyping{0%,100%{opacity:.25;transform:translateY(0)}50%{opacity:1;transform:translateY(-3px)}}
+.ai-error{color:#ff8a95;font-size:12px;margin:0;padding:0 4px}
+.ai-load-earlier{justify-self:center;border:1px solid var(--glass-border);border-radius:999px;background:var(--surface-2);color:var(--muted);font:inherit;font-size:11.5px;padding:5px 14px;cursor:pointer}
+.ai-load-earlier:hover{color:var(--text);border-color:var(--primary);background:var(--surface-3)}
+
+/* ---------- 模块 AI 介绍卡片(项目详情) ---------- */
+.ai-brief{margin-bottom:24px;padding:15px 20px 16px}
+.ai-brief-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
+.ai-brief-head b{display:flex;align-items:center;gap:8px;font-size:13.5px}
+.ai-brief-head b svg{width:15px;height:15px;color:var(--primary)}
+.ai-brief-head time{font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}
+.ai-brief-regen{display:grid;place-items:center;width:28px;height:28px;border:1px solid var(--border);border-radius:8px;background:var(--surface-2);color:var(--muted);cursor:pointer}
+.ai-brief-regen:hover{color:var(--text);border-color:var(--primary)}
+.ai-brief-regen:disabled{cursor:default;opacity:.7}
+.ai-brief-regen svg{width:13px;height:13px}
+.ai-brief-regen.busy svg{animation:spin .9s linear infinite}
+.ai-brief-ask{margin-left:auto;display:inline-flex;align-items:center;gap:7px;height:34px;min-width:min(300px,40%);padding:0 12px;border:1px solid var(--border);border-radius:999px;background:var(--surface-2);transition:border-color .2s}
+.ai-brief-ask:focus-within{border-color:var(--primary)}
+.ai-brief-ask svg{flex:none;width:14px;height:14px;color:var(--muted)}
+.ai-brief-ask input{flex:1;min-width:0;border:none;background:transparent;color:var(--text);outline:none;font:inherit;font-size:12.5px}
+.ai-brief-body{margin-top:10px;font-size:13px;line-height:1.75;color:color-mix(in srgb,var(--text) 92%,transparent);overflow-wrap:anywhere}
+.ai-brief-body p{margin:5px 0}
+.ai-brief-body pre{white-space:pre-wrap;word-break:break-word;background:rgba(0,0,0,.22);border-radius:8px;padding:10px 12px}
+.ai-brief-body ul,.ai-brief-body ol{margin:5px 0;padding-left:22px}
+.ai-brief-empty{margin:10px 0 0;color:var(--muted);font-size:12.5px}
+
+/* 详情页底部“重新分析”按钮:与上方内容保持呼吸间距(各 tab 共用) */
+.detail-page .center-action{margin:34px 0 10px}
+
+/* ---------- 项目详情 AI 分析 tab:提问栏 + 模块卡片栅格 ---------- */
+.ai-ask-bar{display:flex;align-items:center;gap:12px;padding:14px 18px;margin-bottom:20px}
+.ai-ask-bar>svg{flex:none;width:18px;height:18px;color:var(--primary)}
+.ai-ask-bar input{flex:1;min-width:0;height:40px;border:1px solid var(--border);border-radius:10px;background:var(--surface-2);color:var(--text);padding:0 14px;outline:none;font:inherit;font-size:13px;transition:border-color .2s}
+.ai-ask-bar input:focus{border-color:var(--primary)}
+.ai-ask-bar .btn{flex:none}
+/* 瀑布流两列:卡片高度差异大时避免整行对齐留白 */
+.ai-brief-grid{columns:2;column-gap:18px}
+.ai-brief-grid .ai-brief{break-inside:avoid;margin-bottom:18px}
+@media(max-width:1100px){.ai-brief-grid{columns:1}}
+
+/* ---------- AI 问答抽屉(项目详情内二级页面) ---------- */
+.ai-drawer-overlay{position:fixed;inset:0;background:rgba(5,8,14,.55);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);z-index:80;display:flex;justify-content:flex-end;animation:overlay-in .2s both}
+.ai-drawer{width:min(940px,94vw);height:100vh;display:flex;flex-direction:column;background:linear-gradient(160deg,rgba(123,115,255,.07),transparent 42%),var(--surface);border-left:1px solid var(--glass-border);box-shadow:-30px 0 70px rgba(0,0,0,.45);animation:aiDrawerIn .32s cubic-bezier(.16,1,.3,1) both}
+@keyframes aiDrawerIn{from{transform:translateX(72px);opacity:0}to{transform:none;opacity:1}}
+.ai-drawer-head{flex:none;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:13px 20px;border-bottom:1px solid var(--glass-border)}
+.ai-drawer-head b{display:flex;align-items:center;gap:10px;font-size:15px;min-width:0}
+.ai-drawer-head b svg{flex:none;width:17px;height:17px;color:var(--primary)}
+.ai-drawer-tools{flex:none;display:flex;align-items:center;gap:8px}
+.ai-drawer-tools .btn{height:32px;padding:0 12px;font-size:12px}
+.ai-drawer-close{width:30px;height:30px;display:grid;place-items:center;border:0;border-radius:8px;background:transparent;color:var(--muted);cursor:pointer;transition:background .15s,color .15s}
+.ai-drawer-close:hover{background:var(--surface-3);color:var(--text)}
+.ai-drawer-close svg{width:16px;height:16px}
+.ai-drawer-body{flex:1;min-height:0;display:grid;grid-template-columns:232px minmax(0,1fr)}
+.ai-drawer-convs{min-height:0;display:flex;flex-direction:column;gap:10px;padding:14px;border-right:1px solid var(--glass-border);overflow:hidden}
+.ai-drawer-convs .ai-conv-list{flex:1;min-height:0;overflow:auto}
+.ai-drawer-new{flex:none;height:36px}
+.ai-drawer-chat{min-height:0;display:flex;flex-direction:column;padding:0 18px 16px}
+.ai-drawer-quicks{padding-top:12px}
+.ai-drawer-quicks .ai-quicks{margin-left:0}
+
+/* ---------- 顶栏任务中心 ---------- */
+.tc-badge{background:var(--blue)}
+.tc-dropdown{width:388px}
+.tc-group{display:flex;align-items:center;gap:7px;padding:9px 8px 3px;font-size:11px;font-weight:700;letter-spacing:.5px;color:var(--muted)}
+.tc-group i{width:7px;height:7px;border-radius:50%;background:var(--muted)}
+.tc-group.doing i{background:var(--blue);box-shadow:0 0 9px var(--blue)}
+.tc-group em{font-style:normal;font-weight:600;margin-left:auto}
+.tc-item{display:flex;gap:10px;align-items:center;padding:9px 8px;border-radius:9px;cursor:pointer}
+.tc-item:hover{background:var(--surface-3)}
+.tc-icon{flex:none;width:28px;height:28px;border-radius:8px;display:grid;place-items:center}
+.tc-icon svg{width:14px;height:14px}
+.tc-icon.todo{background:rgba(79,157,245,.14);color:var(--blue)}
+.tc-icon.ticket{background:rgba(115,103,245,.15);color:#a9a2ff}
+.tc-main{flex:1;min-width:0;display:grid;gap:2px}
+.tc-main b{font-size:12.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.tc-main small{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:10.5px}
+.tc-proj{max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:1px 7px;border-radius:999px;background:var(--surface-3);border:1px solid var(--glass-border)}
+.tc-pri{display:inline-flex;align-items:center;gap:3px;color:var(--red);font-weight:700}
+.tc-pri svg{width:10px;height:10px}
+.tc-main time{font-variant-numeric:tabular-nums}
+.tc-main time.overdue{color:var(--red);font-weight:700}
+.tc-acts{flex:none;display:flex;gap:6px;opacity:0;transition:opacity .15s}
+.tc-item:hover .tc-acts{opacity:1}
+.tc-acts button{display:inline-flex;align-items:center;gap:4px;height:24px;padding:0 9px;border-radius:7px;border:1px solid var(--border);background:var(--surface-2);color:var(--text);font:inherit;font-size:11px;cursor:pointer;white-space:nowrap}
+.tc-acts button:hover{border-color:var(--primary);color:#a9a2ff}
+.tc-acts button svg{width:11px;height:11px}
+/* 顶栏笔记中心 */
+.nc-dropdown{width:330px}
+.nc-new{display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 10px;border-radius:8px;border:1px solid var(--border);background:var(--surface-2);color:var(--text);font:inherit;font-size:11.5px;cursor:pointer}
+.nc-new:hover{border-color:var(--primary);color:#a9a2ff}
+.nc-new svg{width:12px;height:12px}
+.nc-item{display:flex;align-items:center;gap:10px;padding:10px 9px;border-radius:9px;cursor:pointer}
+.nc-item:hover{background:var(--surface-3)}
+.nc-item b{flex:1;min-width:0;font-size:12.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.nc-item time{flex:none;font-size:10.5px;color:var(--muted);font-variant-numeric:tabular-nums}
+/* 笔记详情模态框 */
+.note-modal{width:560px;max-width:94vw;padding:0;overflow:hidden}
+.nm-head{display:flex;align-items:center;gap:10px;padding:13px 16px;border-bottom:1px solid var(--border)}
+.nm-head h2{display:flex;align-items:center;gap:8px;margin:0;font-size:14px}
+.nm-head h2 svg{width:15px;height:15px;color:var(--yellow)}
+.nm-saved{color:var(--muted);font-size:11px}
+.nm-tools{margin-left:auto;display:flex;gap:6px}
+.nm-tools button{display:grid;place-items:center;width:28px;height:28px;border:0;border-radius:8px;background:transparent;color:var(--muted);cursor:pointer}
+.nm-tools button:hover{background:var(--surface-3);color:var(--text)}
+.nm-tools .nm-del:hover{color:var(--red)}
+.nm-tools svg{width:15px;height:15px}
+.note-modal .nm-area{display:block;width:100%;height:340px;border:0;border-radius:0;background:transparent;color:var(--text);padding:16px;margin:0;resize:none;outline:none;font:inherit;font-size:13px;line-height:1.75}
+.ai-input-row{display:flex;gap:10px;align-items:flex-end;padding-top:14px;border-top:1px solid var(--border)}
+.ai-input-row textarea{flex:1;min-height:52px;max-height:180px;border:1px solid var(--border);border-radius:8px;background:var(--surface-2);color:var(--text);padding:12px;resize:vertical;outline:none;font:inherit;font-size:13px;line-height:1.6}
+.ai-input-row textarea:focus{border-color:var(--primary)}
+.ai-send{height:44px;flex:none}
+@media(max-width:1280px){.todo-board{grid-template-columns:1fr}.ai-layout{grid-template-columns:240px minmax(0,1fr)}}
+@media(max-width:820px){.wb-grid{grid-template-columns:1fr}}
+@media(max-width:1150px){.calendar-layout{gap:14px}.calendar-cell{min-height:70px;padding:5px}.calendar-grid{grid-auto-rows:minmax(70px,1fr)}.calendar-grid,.calendar-weekdays{gap:4px}.calendar-lunar{font-size:9.5px}.calendar-fest{font-size:9px;padding:2px 5px}.calendar-lunar-card{padding:8px 10px}}
@keyframes shineSweep{from{transform:translateX(-65%) rotate(8deg)}to{transform:translateX(65%) rotate(8deg)}}
@keyframes logoTrace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:68}}
@keyframes logoSpark{to{stroke-dashoffset:-40}}
@@ -176,8 +799,978 @@ html[data-theme=light] .heat-cell.l1{background:#b9efd4}html[data-theme=light] .
@keyframes loadingBarFlow{to{background-position:220% 0}}
@keyframes loadingBarSweep{from{transform:translateX(-44px)}to{transform:translateX(290px)}}
@keyframes loadingTrackSweep{0%,100%{transform:translateX(-62%);opacity:.35}50%{transform:translateX(62%);opacity:.9}}
-@media(max-width:1150px){main::before,main::after{inset-left:72px}.sidebar-bottom{padding-left:0;padding-right:0;justify-content:center}.quick-settings{left:0}.quick-settings-btn{width:40px;height:40px}.stats-grid.three,.stats-grid.four{grid-template-columns:repeat(2,minmax(0,1fr))}.project-card{padding:20px}}
+@media(max-width:1150px){main::before,main::after{left:72px}.sidebar-bottom{padding-left:0;padding-right:0;justify-content:center}.quick-settings{left:0}.quick-settings-btn{width:40px;height:40px}.stats-grid.three,.stats-grid.four{grid-template-columns:repeat(2,minmax(0,1fr))}.project-card{padding:20px}}
@media(max-width:980px){body{min-width:0}.page{padding:24px 24px 56px}.stats-grid.three,.stats-grid.four,.stats-grid.five{grid-template-columns:repeat(2,minmax(0,1fr))}.project-grid{grid-template-columns:1fr}.section-head{align-items:flex-start;gap:12px;flex-direction:column}.project-tools{width:100%;justify-content:flex-start}.group-filter{width:100%;flex-wrap:wrap;height:auto}.group-filter select{flex:1;min-width:160px}.search{width:100%}.git-error-panel{grid-template-columns:36px 1fr}.git-error-panel .btn{grid-column:1/3;width:max-content}}
@media(max-width:720px){.loading-style-grid{grid-template-columns:1fr}.loading-style-card{min-height:104px}}
@media(max-width:980px){.detail-page{--detail-sticky-offset:12px;padding:var(--detail-sticky-offset) 20px 56px}.insights-hero{grid-template-columns:1fr}.detail-head-row{align-items:flex-start;flex-direction:column;gap:10px}.detail-tabs{width:100%;overflow-x:auto}}
@media(prefers-reduced-motion:reduce){main::before,main::after,.logo-track,.logo-spark,.shine-card:hover::after{animation:none!important}.shine-card:hover,.heat-cell:hover{transform:none}.toast{transition:opacity .2s ease}}
+
+/* ---------- 日历节日背景:默认整卡插画(eggart.js 生成),管理员可换成真实照片 ---------- */
+.calendar-cell.has-art,.calendar-cell.has-photo{border-color:rgba(255,255,255,.09)}
+.calendar-cell.has-art::before,.calendar-cell.has-photo::before{content:"";position:absolute;inset:0;border-radius:6px;pointer-events:none;transition:opacity .25s,transform .35s ease;animation:egg-in .5s ease-out backwards}
+.calendar-cell.has-art::before{background:var(--art-bg) center/cover no-repeat;opacity:.88}
+.calendar-cell.has-photo::before{background:linear-gradient(180deg,rgba(8,11,18,.6),rgba(8,11,18,.22) 42%,rgba(8,11,18,.64)),var(--art-bg) center/cover no-repeat;opacity:.96}
+.calendar-cell.has-art:hover::before,.calendar-cell.has-photo:hover::before{opacity:1;transform:scale(1.02)}
+.calendar-cell.has-art.out::before,.calendar-cell.has-photo.out::before{opacity:.35}
+.calendar-cell.has-art>*,.calendar-cell.has-photo>*{position:relative}
+.calendar-cell.has-art .calendar-day,.calendar-cell.has-photo .calendar-day{color:#fff;text-shadow:0 1px 8px rgba(0,0,0,.55)}
+.calendar-cell.has-art .calendar-lunar,.calendar-cell.has-photo .calendar-lunar{color:rgba(255,255,255,.82);text-shadow:0 1px 6px rgba(0,0,0,.5)}
+html[data-theme=light] .calendar-cell.has-art::before{opacity:.82}
+.calendar-egg-vec{float:right;width:52px;height:52px;margin-left:10px;background:no-repeat center/contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.3))}
+.calendar-lunar-card.festive{border-color:rgba(240,94,104,.34);background:linear-gradient(135deg,rgba(240,94,104,.12),rgba(240,94,104,.03) 58%),var(--surface-2)}
+@keyframes egg-in{from{opacity:0;transform:scale(.6)}}
+@media(prefers-reduced-motion:reduce){.calendar-cell.has-art::before,.calendar-cell.has-photo::before{animation:none!important;transition:none}}
+
+/* 节日配图管理(管理员)*/
+.cal-detail-modal{width:min(640px,calc(100vw - 48px));max-height:min(660px,calc(100dvh - 56px));display:flex;flex-direction:column;overflow:hidden}
+.cal-detail-modal header h2{display:flex;align-items:center;gap:9px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.cal-detail-meta{display:flex;flex-wrap:wrap;gap:7px;align-items:center;padding:2px 26px 14px}
+.cal-detail-body{flex:1;min-height:120px;overflow:auto;padding:16px 26px;border-top:1px solid var(--glass-border);background:var(--surface-2)}
+.cal-detail-empty{margin:0;color:var(--muted);font-size:12.5px}
+.cal-detail-foot{display:flex;align-items:center;gap:10px;padding:13px 26px 16px;border-top:1px solid var(--glass-border)}
+.cal-detail-foot .tabs.compact,.cal-detail-flow{margin-right:auto}
+.cal-detail-flow{display:flex;gap:8px;flex-wrap:wrap}
+.cal-detail-foot .icon-actions{flex:none}
+.fest-img-admin{display:flex;flex-direction:column;gap:10px;padding:13px 14px;border-radius:14px;border:1px solid var(--glass-border);background:linear-gradient(155deg,rgba(122,162,247,.07),transparent 52%),var(--surface-2)}
+.fest-img-admin h4{margin:0;display:flex;align-items:center;gap:7px;font-size:11.5px;font-weight:700;letter-spacing:.4px;color:var(--text-dim)}
+.fest-img-admin h4 svg{width:13px;height:13px;color:var(--primary)}
+.fest-style-group{display:flex;flex-direction:column;gap:8px}
+.fest-style-group + .fest-style-group{padding-top:10px;border-top:1px solid var(--glass-border)}
+.fest-style-head{display:flex;align-items:center;gap:8px;min-height:26px}
+.fest-style-name{flex:1;min-width:0;font-size:12.5px;font-weight:700;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.fest-style-ops{display:inline-flex;gap:6px;flex:none}
+/* 自适应列数:容器放得下两张 120px 卡时两列,否则一列。
+ 注意 auto-fit 的重复次数按固定 max 计算,必须用 1fr 作 max 才能按 120px 折行 */
+.fest-style-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px;max-width:390px}
+/* 节日样式管理模态框:头部 icon 按钮打开,仅管理员可见 */
+.fest-admin-btn{width:38px;padding:0;justify-content:center}
+.fest-admin-btn svg{width:16px}
+.fest-admin-modal{width:420px;max-width:92vw}
+.fest-admin-modal .quick-date{margin-left:10px;color:var(--muted);font-size:12px;font-weight:400}
+.fest-admin-body{display:flex;flex-direction:column;gap:14px;padding:4px 26px 24px}
+.fest-admin-body .fest-style-group + .fest-style-group{padding-top:14px;border-top:1px solid var(--glass-border)}
+.fest-admin-body .fest-style-cards{grid-template-columns:repeat(2,minmax(0,172px));max-width:none}
+.fest-card{position:relative;display:flex;flex-direction:column;padding:0;border-radius:12px;border:1.5px solid var(--glass-border);background:var(--surface-3);cursor:pointer;overflow:hidden;text-align:left;transition:border-color .18s,transform .18s,box-shadow .18s}
+.fest-card:hover:not(:disabled){transform:translateY(-1px);border-color:rgba(122,162,247,.45)}
+.fest-card.active{border-color:rgba(122,162,247,.78);box-shadow:0 0 0 3px rgba(122,162,247,.13),0 10px 24px -14px rgba(122,162,247,.55)}
+.fest-card:disabled{opacity:.6;cursor:not-allowed}
+.fest-card-preview{display:flex;align-items:center;justify-content:center;width:100%;aspect-ratio:1/1;background:var(--surface-2) center/cover no-repeat}
+.fest-card-upload{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border:1px dashed rgba(122,162,247,.4);border-radius:9px;background:rgba(122,162,247,.06);font-size:11px;font-weight:650;color:var(--text-dim);white-space:nowrap}
+.fest-card-upload svg{width:13px;height:13px;color:var(--primary)}
+.fest-card-label{display:flex;align-items:center;gap:6px;padding:7px 10px;border-top:1px solid var(--glass-border);font-size:11.5px;font-weight:650;color:var(--text-dim);transition:color .18s;white-space:nowrap}
+.fest-card-label svg{width:12.5px;height:12.5px;color:var(--primary)}
+.fest-card.active .fest-card-label{color:var(--text)}
+.fest-card-check{position:absolute;top:7px;right:7px;width:19px;height:19px;border-radius:50%;background:var(--primary);display:flex;align-items:center;justify-content:center;box-shadow:0 2px 9px rgba(0,0,0,.4);animation:festCheckIn .22s ease}
+.fest-card-check svg{width:11px;height:11px;color:#0d1226;stroke-width:3.2}
+@keyframes festCheckIn{from{transform:scale(.4);opacity:0}to{transform:scale(1);opacity:1}}
+.fest-img-btn{padding:5px 10px;border-radius:8px;border:1px solid var(--glass-border);background:var(--surface-3);color:var(--text);font-size:11.5px;font-weight:650;cursor:pointer;transition:border-color .16s,background .16s;flex:none;display:inline-flex;align-items:center;gap:4px}
+.fest-img-btn svg{width:12px;height:12px}
+.fest-img-btn:hover:not(:disabled){border-color:rgba(122,162,247,.5)}
+.fest-img-btn.danger:hover:not(:disabled){border-color:rgba(240,94,104,.55);color:#f0656e}
+.fest-img-btn:disabled{opacity:.5;cursor:not-allowed}
+
+/* 日历页“每日心语”入口 */
+.daily-entry{display:flex;align-items:center;gap:9px;width:100%;margin-top:2px;padding:11px 13px;border-radius:12px;border:1px solid var(--glass-border);background:linear-gradient(120deg,rgba(122,162,247,.14),rgba(158,206,255,.05) 60%),var(--surface-2);color:var(--text);font-size:12.5px;font-weight:650;cursor:pointer;transition:border-color .18s,transform .18s,box-shadow .18s}
+.daily-entry svg{width:15px;height:15px;color:var(--primary);flex:none}
+.daily-entry .daily-entry-arrow{margin-left:auto;color:var(--text-dim);transition:transform .18s}
+.daily-entry:hover:not(:disabled){border-color:rgba(122,162,247,.5);transform:translateY(-1px);box-shadow:0 10px 26px rgba(0,0,0,.24)}
+.daily-entry:hover:not(:disabled) .daily-entry-arrow{transform:translateX(3px)}
+.daily-entry:disabled{opacity:.45;cursor:not-allowed}
+.daily-open-btn svg{color:var(--primary)}
+
+/* ---------- C 端表单质感:输入控件 / 下拉 / 模态框 / 按钮 ---------- */
+html{color-scheme:dark}
+html[data-theme=light]{color-scheme:light}
+
+.modal input:not([type=checkbox]):not([type=radio]),.modal textarea,.modal select,
+.quick-form input,.quick-form select,
+.rule-add input,.rule-add select,
+.form-panel input:not([type=range]):not([type=checkbox]),.form-panel select{
+ background-color:color-mix(in srgb,var(--surface-2) 86%,transparent);
+ border:1px solid var(--border);border-radius:11px;color:var(--text);
+ min-height:40px;padding:9px 14px;outline:0;font-size:13.5px;
+ box-shadow:inset 0 1.5px 3px rgba(0,0,0,.16),inset 0 -1px 0 rgba(255,255,255,.03);
+ transition:border-color .18s,box-shadow .18s,background-color .18s;
+}
+.modal input:not([type=checkbox]):not([type=radio]):hover,.modal textarea:hover,.modal select:hover,
+.quick-form input:hover,.quick-form select:hover,
+.rule-add input:hover,.rule-add select:hover,
+.form-panel input:not([type=range]):not([type=checkbox]):hover,.form-panel select:hover{
+ border-color:color-mix(in srgb,var(--primary) 42%,var(--border));
+}
+.modal input:not([type=checkbox]):not([type=radio]):focus,.modal textarea:focus,.modal select:focus,
+.quick-form input:focus,.quick-form select:focus,
+.rule-add input:focus,.rule-add select:focus,
+.form-panel input:not([type=range]):not([type=checkbox]):focus,.form-panel select:focus{
+ border-color:var(--primary);background-color:var(--surface-2);
+ box-shadow:0 0 0 3.5px rgba(115,103,245,.16),inset 0 1.5px 3px rgba(0,0,0,.08);
+}
+.modal textarea{min-height:96px;line-height:1.55;resize:vertical}
+::placeholder{color:color-mix(in srgb,var(--muted) 72%,transparent)}
+
+/* 所有下拉统一为自绘箭头(去掉系统灰三角,后台感的主要来源之一)。
+ 部分作用域样式用 background 简写/自带 padding,箭头相关属性用 !important 保证全局一致。 */
+select{
+ appearance:none;-webkit-appearance:none;
+ background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23929bac' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") !important;
+ background-repeat:no-repeat !important;
+ background-position:right 11px center !important;
+ background-size:14px !important;
+ padding-right:32px !important;
+ cursor:pointer;
+}
+select::-ms-expand{display:none}
+
+input[type=range]{accent-color:var(--primary)}
+input[type=checkbox],input[type=radio]{accent-color:var(--primary)}
+
+/* 模态框:更大的圆角、顶部渐变饰线、无分割线的松弛留白 */
+.overlay{background:rgba(7,10,17,.56);backdrop-filter:blur(10px) saturate(120%);-webkit-backdrop-filter:blur(10px) saturate(120%)}
+.modal{position:relative;border-radius:18px;overflow:auto;box-shadow:0 42px 96px rgba(0,0,0,.52),inset 0 1px 0 var(--glass-highlight)}
+.modal::before{content:"";position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,transparent,rgba(139,124,248,.85) 28%,rgba(83,214,162,.6) 72%,transparent);opacity:.9;pointer-events:none}
+.modal header{border-bottom:0;padding:22px 26px 4px;align-items:center}
+.modal header h2{font-size:17px;letter-spacing:.3px}
+.modal header button{width:32px;height:32px;border-radius:10px;display:grid;place-items:center;font-size:20px;line-height:1;transition:background-color .15s,color .15s}
+.modal header button:hover{background:var(--surface-3);color:var(--text)}
+.modal>label{font-size:12px;font-weight:600;letter-spacing:.4px;color:var(--muted);margin:16px 26px 0}
+.modal footer{border-top:0;padding:20px 26px 24px;gap:10px}
+.form-error{margin:14px 26px 0;padding:9px 13px;border-radius:10px;background:rgba(240,94,104,.12);border:1px solid rgba(240,94,104,.32);color:#ff9aa2;font-size:12.5px}
+
+/* 按钮:主按钮渐变 + 光泽,悬停轻微上浮 */
+.btn{border-radius:11px;font-weight:600}
+.btn.primary{background:linear-gradient(140deg,#8f83f9,#6e5ff2 52%,#5b4ce6);border-color:rgba(255,255,255,.16);box-shadow:0 10px 26px rgba(101,87,232,.36),inset 0 1px 0 rgba(255,255,255,.24);text-shadow:0 1px 2px rgba(0,0,0,.16)}
+.btn.primary:hover:not(:disabled){filter:brightness(1.08);transform:translateY(-1px);box-shadow:0 14px 30px rgba(101,87,232,.44),inset 0 1px 0 rgba(255,255,255,.28)}
+.btn.primary:active:not(:disabled){transform:translateY(0) scale(.985);filter:brightness(.98)}
+.btn.primary:disabled{opacity:.55;cursor:not-allowed}
+.btn.danger{background:linear-gradient(140deg,#f2606a,#df4550);border-color:rgba(255,255,255,.14);box-shadow:0 10px 24px rgba(224,75,85,.32),inset 0 1px 0 rgba(255,255,255,.2)}
+.btn.danger:hover:not(:disabled){filter:brightness(1.06);transform:translateY(-1px)}
+
+/* 设置页表单行:标签弱化,控件与模态框同质感 */
+.form-panel label{color:var(--muted);font-size:13px;gap:14px}
+.form-panel label .interval-input{max-width:180px}
+
+/* 快速创建弹层与登录弹层输入对齐同一质感 */
+.quick-modal{width:460px;padding:24px 26px 26px}
+.quick-modal .md-editor textarea{min-height:104px}
+.quick-modal h2{font-size:16px}
+
+/* ============ Markdown 渲染(待办 / 工单内容) ============ */
+.md-content{font-size:12.5px;line-height:1.62;color:var(--muted);word-break:break-word;font-weight:400}
+.md-content>:first-child{margin-top:0}
+.md-content>:last-child{margin-bottom:0}
+.md-content p{margin:4px 0;display:block;white-space:normal;overflow:visible;text-overflow:clip;-webkit-line-clamp:none;color:inherit;font-size:inherit}
+.md-content h1,.md-content h2,.md-content h3,.md-content h4{margin:10px 0 4px;color:var(--text);line-height:1.35}
+.md-content h1{font-size:15px}.md-content h2{font-size:14px}.md-content h3,.md-content h4{font-size:13px}
+.md-content ul,.md-content ol{margin:4px 0;padding-left:20px}
+.md-content li{margin:2px 0}
+.md-content li::marker{color:color-mix(in srgb,var(--primary) 75%,var(--muted))}
+.md-content input[type=checkbox]{width:13px;height:13px;min-height:0;margin:0 6px 0 -18px;vertical-align:-2px;accent-color:var(--primary);box-shadow:none;pointer-events:none}
+.md-content li:has(>input[type=checkbox]){list-style:none}
+.md-content code{background:rgba(115,103,245,.12);border:1px solid rgba(115,103,245,.18);border-radius:5px;padding:1px 5px;font-size:11.5px;color:#a99ffb}
+.md-content pre{background:rgba(0,0,0,.3);border:1px solid var(--border);border-radius:9px;padding:10px 13px;overflow:auto;margin:6px 0}
+.md-content pre code{background:transparent;border:0;padding:0;color:var(--text);font-size:11.5px;line-height:1.6}
+.md-content blockquote{border-left:3px solid var(--primary);margin:6px 0;padding:2px 12px;color:var(--muted);background:rgba(115,103,245,.06);border-radius:0 7px 7px 0}
+.md-content a{color:#8f84fa;text-decoration:none;border-bottom:1px dashed rgba(143,132,250,.4)}
+.md-content a:hover{border-bottom-style:solid}
+.md-content hr{border:0;border-top:1px solid var(--border);margin:10px 0}
+.md-content table{border-collapse:collapse;margin:6px 0;font-size:12px}
+.md-content th,.md-content td{border:1px solid var(--border);padding:4px 9px}
+.md-content th{background:var(--surface-3);color:var(--text)}
+.md-content img{max-width:100%;max-height:300px;display:block;border-radius:10px;border:1px solid var(--glass-border);margin:6px 0;box-shadow:0 6px 18px rgba(0,0,0,.24)}
+.md-content img.md-img-loading{min-width:80px;min-height:48px;background:var(--surface-3);opacity:.4}
+.md-content img.md-img-broken{min-width:0;min-height:0;height:26px;width:auto;padding:4px 10px;opacity:.5;border-style:dashed}
+.md-content del{opacity:.6}
+
+/* 卡片 / 列表里的紧凑版:限高 + 底部渐隐 */
+.md-clamp{max-height:132px;overflow:hidden;-webkit-mask-image:linear-gradient(180deg,#000 70%,transparent);mask-image:linear-gradient(180deg,#000 70%,transparent);margin-top:4px}
+.md-clamp img{max-height:110px}
+.md-clamp pre{max-height:76px;overflow:hidden}
+.ticket-desc{max-height:96px}
+
+/* ---------- 每日心语卡片:随机图 + 蒙版 + 黄历 ---------- */
+.daily-overlay{z-index:96}
+.daily-card{width:432px;max-width:calc(100vw - 64px);border-radius:20px;overflow:hidden;background:var(--surface);border:1px solid var(--glass-border);box-shadow:0 46px 110px rgba(0,0,0,.56),inset 0 1px 0 var(--glass-highlight);animation:daily-in .5s cubic-bezier(.22,1.1,.32,1) both}
+.daily-hero{position:relative;height:246px;background:var(--surface-3)}
+.daily-hero img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;animation:daily-img-in 1.1s ease-out both}
+.daily-mask{position:absolute;inset:0;background:linear-gradient(180deg,rgba(6,9,16,.14) 0%,rgba(6,9,16,.06) 34%,rgba(6,9,16,.72) 100%)}
+.daily-close{position:absolute;top:12px;right:12px;width:32px;height:32px;border-radius:10px;display:grid;place-items:center;color:#fff;background:rgba(10,14,22,.44);border:1px solid rgba(255,255,255,.18);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);cursor:pointer;transition:background .18s,transform .18s;z-index:2}
+.daily-close:hover{background:rgba(10,14,22,.66);transform:rotate(90deg)}
+.daily-close svg{width:16px;height:16px}
+.daily-nav{position:absolute;top:50%;transform:translateY(-50%);width:30px;height:30px;border-radius:999px;display:grid;place-items:center;color:#fff;background:rgba(10,14,22,.4);border:1px solid rgba(255,255,255,.16);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);cursor:pointer;transition:background .18s,opacity .18s;z-index:2}
+.daily-nav.prev{left:12px}
+.daily-nav.next{right:12px}
+.daily-nav:hover:not(:disabled){background:rgba(10,14,22,.66)}
+.daily-nav:disabled{opacity:.32;cursor:not-allowed}
+.daily-nav svg{width:15px;height:15px}
+.daily-history-tag{margin-left:7px;font-style:normal;font-size:9.5px;letter-spacing:1px;padding:1.5px 7px;border-radius:999px;background:rgba(255,217,138,.24);border:1px solid rgba(255,217,138,.42);color:#ffd98a}
+.daily-head{position:absolute;left:22px;right:22px;bottom:16px;display:flex;flex-direction:column;gap:5px;color:#fff;text-shadow:0 2px 14px rgba(0,0,0,.5)}
+.daily-kicker{align-self:flex-start;font-size:10.5px;font-weight:700;letter-spacing:2.5px;padding:4px 10px;border-radius:999px;background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.24);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px)}
+.daily-date{font-size:25px;font-weight:800;letter-spacing:.5px;line-height:1.15}
+.daily-sub{font-size:12.5px;opacity:.92}
+.daily-fest{font-size:11.5px;color:#ffd98a;font-weight:700}
+.daily-body{padding:18px 22px 22px;display:flex;flex-direction:column;gap:14px}
+.daily-quote{margin:0;display:flex;gap:9px;align-items:flex-start;font-size:13.5px;line-height:1.7;color:var(--text)}
+.daily-quote svg{width:15px;height:15px;flex:none;margin-top:4px;color:var(--primary);transform:scaleX(-1)}
+.daily-almanac{display:flex;flex-direction:column;gap:8px}
+.daily-yi,.daily-ji{display:flex;align-items:center;flex-wrap:wrap;gap:6px}
+.daily-yi i,.daily-ji i{font-style:normal;width:22px;height:22px;border-radius:7px;display:grid;place-items:center;font-size:11.5px;font-weight:800;flex:none}
+.daily-yi i{color:#4ad39c;background:rgba(67,201,150,.16)}
+.daily-ji i{color:#ff8b93;background:rgba(240,94,104,.15)}
+.daily-yi span,.daily-ji span{font-size:11.5px;color:var(--muted);background:color-mix(in srgb,var(--surface-2) 82%,transparent);border:1px solid var(--border);border-radius:999px;padding:3px 10px}
+.daily-ok{width:100%;justify-content:center;height:40px}
+@keyframes daily-in{from{opacity:0;transform:translateY(26px) scale(.94)}to{opacity:1;transform:none}}
+@keyframes daily-img-in{from{transform:scale(1.08);filter:saturate(.7)}to{transform:none;filter:none}}
+@media(prefers-reduced-motion:reduce){.daily-card,.daily-hero img{animation:none!important}}
+
+/* 待办/工单编辑:左侧字段列 + 右侧整块 Markdown 编辑区 */
+.modal-split{width:min(960px,calc(100vw - 64px));height:min(680px,calc(100dvh - 56px));overflow:hidden}
+.modal-split header h2{display:flex;align-items:baseline;gap:10px}
+.modal-split header h2 .quick-date{color:var(--muted);font-size:12px;font-weight:500;letter-spacing:.4px}
+.modal-split .split-body{flex:1;display:flex;min-height:0}
+.split-fields{width:288px;flex:none;display:flex;flex-direction:column;overflow-y:auto;padding:14px 24px 18px 26px;border-right:1px solid color-mix(in srgb,var(--border) 72%,transparent)}
+.split-fields label{display:block;font-size:12px;font-weight:600;letter-spacing:.4px;color:var(--muted);margin-top:14px}
+.split-fields>label:first-child{margin-top:2px}
+.split-fields input,.split-fields select{width:100%}
+.split-fields .field-pair{display:grid;grid-template-columns:1fr 1fr;gap:0 12px}
+.split-editor{flex:1;min-width:0;display:flex;flex-direction:column;padding:14px 26px 18px 24px}
+.split-editor .md-toolbar{margin-bottom:0}
+.md-field-label{font-size:12px;font-weight:600;letter-spacing:.4px;color:var(--muted);margin-right:auto}
+.split-editor .md-editor{flex:1;min-height:0}
+.split-editor .md-editor textarea{flex:1;height:auto;min-height:0;resize:none}
+.split-editor .md-preview-box{flex:1;min-height:0;max-height:none}
+.modal-split footer{align-items:center}
+.modal-split footer .form-error{margin:0 auto 0 0;padding:7px 12px}
+
+/* 截止日期快捷标签:今天/明天/N天后,可增删 */
+.due-quick{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
+.dq-chip{position:relative;display:inline-flex;align-items:center;gap:2px;height:24px;padding:0 10px;border-radius:999px;border:1px solid var(--border);background:color-mix(in srgb,var(--surface-2) 78%,transparent);color:var(--muted);font-size:11.5px;font-weight:600;cursor:pointer;transition:border-color .15s,color .15s,background .15s;user-select:none}
+.dq-chip:hover:not(:disabled){border-color:color-mix(in srgb,var(--primary) 45%,var(--border));color:var(--text)}
+.dq-chip.active{background:rgba(115,103,245,.16);border-color:color-mix(in srgb,var(--primary) 62%,var(--border));color:#a99ffb}
+.dq-chip:disabled{opacity:.5;cursor:not-allowed}
+/* 删除角标绝对定位悬浮出现:不占布局空间,chip 宽度恒定,避免 hover 重排导致点击目标跳动 */
+.dq-x{position:absolute;top:-5px;right:-4px;z-index:1;display:grid;place-items:center;width:15px;height:15px;border-radius:50%;background:var(--surface-3);border:1px solid var(--border);color:var(--muted);opacity:0;transform:scale(.6);transition:opacity .15s,transform .15s;cursor:pointer;pointer-events:none}
+.dq-chip:hover .dq-x{opacity:1;transform:scale(1);pointer-events:auto}
+.dq-x:hover{color:#fff;background:var(--red);border-color:var(--red)}
+.dq-x svg{width:9px;height:9px}
+.dq-add{width:26px;padding:0;justify-content:center}
+.dq-add svg{width:12px}
+.dq-input{padding:0 9px;gap:4px;cursor:default}
+.due-quick .dq-input input[type=number]{width:38px;height:20px;min-height:0;border:0;outline:0;background:transparent;color:var(--text);font-size:11.5px;padding:0;margin:0;box-shadow:none;border-radius:0;text-align:center;-moz-appearance:textfield;appearance:textfield}
+.dq-input input::-webkit-outer-spin-button,.dq-input input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
+
+/* 编辑器:编辑/预览切换 + 插图按钮 */
+.md-editor{display:flex;flex-direction:column;gap:0;margin-top:10px}
+.md-toolbar{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:8px}
+.md-toolbar .tabs{margin:0;padding:3px;background:var(--surface-2);border:1px solid var(--border)}
+.md-toolbar .tabs button{height:26px;padding:0 12px;font-size:12px}
+.md-toolbar .btn.md-img-btn{height:30px;padding:0 12px;font-size:12px;border-radius:9px;gap:6px}
+.md-toolbar .btn.md-img-btn svg{width:14px}
+.md-editor textarea{margin-top:0;min-height:132px}
+.md-preview-box{min-height:132px;max-height:320px;overflow:auto;border:1px solid var(--border);border-radius:11px;padding:11px 14px;background:color-mix(in srgb,var(--surface-2) 60%,transparent);box-shadow:inset 0 1.5px 3px rgba(0,0,0,.12)}
+
+/* 个人主页 */
+.profile-page .preview-notice.panel{margin-bottom:16px}
+/* Hero:沉浸式渐变横幅 + 成就统计条 */
+.profile-hero{position:relative;overflow:hidden;padding:30px 30px 0;margin-bottom:22px;border-radius:20px;border:1px solid var(--glass-border);background:linear-gradient(135deg,rgba(115,103,245,.16),rgba(79,157,245,.07) 45%,rgba(67,201,150,.1)),var(--surface)}
+.profile-hero .hero-orb{position:absolute;border-radius:50%;filter:blur(52px);opacity:.6;pointer-events:none}
+.profile-hero .hero-orb.a{width:360px;height:360px;left:-110px;top:-170px;background:radial-gradient(circle,rgba(115,103,245,.55),transparent 70%)}
+.profile-hero .hero-orb.b{width:300px;height:300px;right:-80px;bottom:-170px;background:radial-gradient(circle,rgba(67,201,150,.4),transparent 70%)}
+.profile-hero .hero-orb.c{width:220px;height:220px;right:26%;top:-130px;background:radial-gradient(circle,rgba(79,157,245,.4),transparent 70%)}
+.ph-grid{position:absolute;inset:0;pointer-events:none;background:linear-gradient(rgba(255,255,255,.035) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.035) 1px,transparent 1px);background-size:34px 34px;mask-image:radial-gradient(ellipse 90% 80% at 30% 0%,#000 20%,transparent 75%);-webkit-mask-image:radial-gradient(ellipse 90% 80% at 30% 0%,#000 20%,transparent 75%)}
+.profile-hero-main{position:relative;display:flex;align-items:center;gap:24px;flex-wrap:wrap;padding-bottom:26px}
+/* 渐变描边环头像(点击编辑) */
+.profile-avatar{position:relative;width:96px;height:96px;flex:none;border:0;background:none}
+.profile-avatar.editable{cursor:pointer;padding:0;font:inherit}
+.ph-ring{position:absolute;inset:0;border-radius:50%;background:conic-gradient(from 210deg,#7367f5,#4f9df5,#43c996,#e7bd35,#7367f5);opacity:.9;animation:phSpin 16s linear infinite}
+@keyframes phSpin{to{transform:rotate(1turn)}}
+@media(prefers-reduced-motion:reduce){.ph-ring{animation:none}}
+.ph-photo{position:absolute;inset:4px;border-radius:50%;overflow:hidden;display:grid;place-items:center;background:linear-gradient(135deg,#232c44,#1a2233);border:3px solid var(--surface)}
+.ph-photo img{width:100%;height:100%;object-fit:cover}
+.ph-photo>b{font-size:36px;font-weight:900;color:#e6e9ff}
+.ph-photo>svg{width:38px;height:38px;color:var(--muted)}
+.profile-dot{position:absolute;right:5px;bottom:6px;width:16px;height:16px;border-radius:50%;border:3px solid var(--surface);background:#6b7482;z-index:2}
+.profile-dot.on{background:var(--green);box-shadow:0 0 12px rgba(67,201,150,.9)}
+.profile-dot.err{background:var(--red);box-shadow:0 0 10px rgba(240,94,104,.8)}
+.avatar-edit-mask{position:absolute;inset:4px;border-radius:50%;display:grid;place-items:center;background:rgba(8,10,18,.56);opacity:0;transition:opacity .2s;z-index:2}
+.avatar-edit-mask svg{width:26px;height:26px;color:#fff}
+.profile-avatar.editable:hover .avatar-edit-mask,.profile-avatar.editable:focus-visible .avatar-edit-mask{opacity:1}
+/* 身份区:问候 + 渐变大名字 */
+.profile-id{min-width:0;display:grid;gap:7px}
+.ph-greet{font-size:12px;letter-spacing:2.4px;color:var(--muted);font-weight:800;text-transform:uppercase}
+.ph-name{font-size:30px;font-weight:900;letter-spacing:.4px;line-height:1.05;background:linear-gradient(100deg,#fff,#c6c9ff 60%,#9fb7ff);-webkit-background-clip:text;background-clip:text;color:transparent}
+html[data-theme=light] .ph-name{background:linear-gradient(100deg,#1b2340,#4a3fd8 70%,#3a6fd8);-webkit-background-clip:text;background-clip:text}
+.profile-badges{display:flex;flex-wrap:wrap;gap:8px;margin-top:2px}
+.p-badge{display:inline-flex;align-items:center;gap:6px;height:27px;padding:0 12px;border-radius:999px;font-size:12px;font-weight:700;background:rgba(255,255,255,.05);border:1px solid var(--glass-border);color:var(--muted);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px)}
+html[data-theme=light] .p-badge{background:rgba(255,255,255,.75)}
+.p-badge svg{width:13px;height:13px}
+.p-badge.on{color:var(--green);border-color:rgba(67,201,150,.4);background:rgba(67,201,150,.1);box-shadow:0 0 14px -4px rgba(67,201,150,.5)}
+.p-badge.off{color:var(--muted)}
+.p-badge.warn{color:var(--yellow);border-color:rgba(231,189,53,.4);background:rgba(231,189,53,.1)}
+.p-badge.dim{font-variant-numeric:tabular-nums}
+.profile-guest-hint{margin:0;color:var(--muted);font-size:13.5px}
+.profile-hero-actions{margin-left:auto;display:flex;gap:10px;flex-wrap:wrap}
+.profile-hero-actions .btn{height:40px;border-radius:12px}
+.profile-hero .db-message{position:relative;margin:0 0 16px}
+/* 成就统计条 */
+.ph-stats{position:relative;display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:0 -30px;padding:16px 30px 17px;border-top:1px solid var(--glass-border);background:rgba(10,14,24,.26);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)}
+html[data-theme=light] .ph-stats{background:rgba(255,255,255,.5)}
+@media(max-width:940px){.ph-stats{grid-template-columns:repeat(2,1fr)}}
+.ph-stat{display:flex;align-items:center;gap:12px;min-width:0}
+.ph-stat-ico{width:38px;height:38px;flex:none;border-radius:12px;display:grid;place-items:center}
+.ph-stat-ico svg{width:18px;height:18px}
+.ph-stat-ico.folder{background:rgba(115,103,245,.16);color:#a9a2ff}
+.ph-stat-ico.done{background:rgba(67,201,150,.14);color:var(--green)}
+.ph-stat-ico.ticket{background:rgba(79,157,245,.14);color:var(--blue)}
+.ph-stat-ico.star{background:rgba(231,189,53,.13);color:var(--yellow)}
+.ph-stat>div{display:grid;line-height:1.25;min-width:0}
+.ph-stat b{font-size:19px;font-weight:900;font-variant-numeric:tabular-nums}
+.ph-stat span{font-size:11.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+/* 个人中心左右布局:左侧竖排 tab 列 + 右侧内容面板 */
+.profile-layout{display:grid;grid-template-columns:200px minmax(0,1fr);gap:20px;max-width:960px;margin:0 auto;align-items:start}
+@media(max-width:900px){.profile-layout{grid-template-columns:1fr}.profile-side{flex-direction:row;overflow-x:auto;position:static}}
+.profile-side{position:sticky;top:76px;display:flex;flex-direction:column;gap:4px;padding:6px;border-radius:14px;background:var(--surface-2);border:1px solid var(--border)}
+.profile-side button{display:flex;align-items:center;gap:10px;height:42px;padding:0 14px;border:0;border-radius:10px;background:transparent;color:var(--muted);font:inherit;font-size:13.5px;font-weight:700;cursor:pointer;text-align:left;white-space:nowrap;transition:color .2s,background .2s,box-shadow .2s}
+.profile-side button svg{width:16px;height:16px;flex:none}
+.profile-side button:hover{color:var(--text)}
+.profile-side button.active{color:#fff;background:linear-gradient(135deg,#7367f5,#5a8df0);box-shadow:0 6px 18px -6px rgba(115,103,245,.65)}
+.profile-main{min-width:0}
+.profile-main .profile-card{margin-left:0;margin-right:0;max-width:none}
+/* 内容卡:不铺满时居中 */
+.profile-card{max-width:720px;margin-left:auto;margin-right:auto;border-radius:18px;padding:24px 26px}
+.pc-head{display:flex;align-items:flex-start;gap:14px;margin-bottom:20px}
+.pc-badge{width:44px;height:44px;flex:none;border-radius:14px;display:grid;place-items:center;box-shadow:inset 0 0 0 1px rgba(255,255,255,.1),0 10px 24px -8px rgba(115,103,245,.5)}
+.pc-badge svg{width:20px;height:20px;color:#fff}
+.pc-badge.shield{background:linear-gradient(135deg,#7367f5,#9a5ef0)}
+.pc-badge.cloud{background:linear-gradient(135deg,#3f8ef0,#43c996);box-shadow:inset 0 0 0 1px rgba(255,255,255,.1),0 10px 24px -8px rgba(63,142,240,.5)}
+.pc-head b{display:block;font-size:15.5px}
+.pc-head small{display:block;margin-top:4px;color:var(--muted);font-size:12.5px;line-height:1.55;max-width:520px}
+/* 表单(C 端输入框:前置图标 + 聚焦光晕) */
+.pc-form{display:grid;gap:15px;max-width:440px}
+.pc-field>span{display:block;font-size:12.5px;color:var(--muted);font-weight:700;margin-bottom:7px}
+.pc-input{display:flex;align-items:center;gap:10px;height:44px;padding:0 14px;border-radius:12px;border:1px solid var(--border);background:var(--surface-2);transition:border-color .2s,box-shadow .2s}
+.pc-input:focus-within{border-color:var(--primary);box-shadow:0 0 0 3px rgba(115,103,245,.16)}
+.pc-input.err{border-color:var(--red);box-shadow:0 0 0 3px rgba(240,94,104,.12)}
+.pc-input svg{width:16px;height:16px;color:var(--muted);flex:none}
+.pc-input input{flex:1;min-width:0;border:0;background:none;color:var(--text);outline:none;font:inherit;font-size:13.5px}
+/* 密码强度指示 */
+.pc-strength{display:flex;align-items:center;gap:6px;margin-top:8px}
+.pc-strength i{height:4px;flex:1;max-width:52px;border-radius:2px;background:var(--surface-3);transition:background .25s}
+.pc-strength em{font-style:normal;font-size:11.5px;color:var(--muted);margin-left:2px}
+.pc-strength[data-level="1"] i:nth-child(1){background:var(--red)}
+.pc-strength[data-level="1"] em{color:var(--red)}
+.pc-strength[data-level="2"] :is(i:nth-child(1),i:nth-child(2)){background:var(--yellow)}
+.pc-strength[data-level="2"] em{color:var(--yellow)}
+.pc-strength[data-level="3"] i{background:var(--green)}
+.pc-strength[data-level="3"] em{color:var(--green)}
+.pc-actions{margin-top:20px}
+.pc-actions .btn{height:42px;padding:0 22px;border-radius:12px}
+/* 未登录空态 */
+.pc-empty{display:grid;justify-items:center;gap:13px;padding:28px 0 16px;text-align:center}
+.pc-empty-ico{width:64px;height:64px;border-radius:50%;display:grid;place-items:center;background:radial-gradient(circle at 30% 25%,rgba(115,103,245,.3),rgba(115,103,245,.07));border:1px solid var(--glass-border)}
+.pc-empty-ico svg{width:28px;height:28px;color:#a9a2ff}
+.pc-empty p{margin:0;max-width:380px;color:var(--muted);font-size:13px;line-height:1.7}
+/* 同步状态迷你卡 */
+.pc-stats{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
+@media(max-width:760px){.pc-stats{grid-template-columns:1fr}}
+.pc-stat{display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:14px;border:1px solid var(--border);background:var(--surface-2);transition:border-color .2s}
+.pc-stat:hover{border-color:color-mix(in srgb,var(--primary) 45%,var(--border))}
+.pc-stat-ico{width:40px;height:40px;flex:none;border-radius:12px;display:grid;place-items:center}
+.pc-stat-ico svg{width:18px;height:18px}
+.pc-stat-ico.user{background:rgba(115,103,245,.15);color:#a9a2ff}
+.pc-stat-ico.net-on{background:rgba(67,201,150,.14);color:var(--green)}
+.pc-stat-ico.net-off{background:rgba(146,155,172,.14);color:var(--muted)}
+.pc-stat-ico.time{background:rgba(79,157,245,.14);color:var(--blue)}
+.pc-stat-ico.push{background:rgba(146,155,172,.12);color:var(--muted)}
+.pc-stat-ico.push.warn{background:rgba(231,189,53,.14);color:var(--yellow)}
+.pc-stat>div{min-width:0;display:grid;line-height:1.35}
+.pc-stat span{font-size:11.5px;color:var(--muted)}
+.pc-stat b{font-size:13.5px;font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-variant-numeric:tabular-nums}
+.pc-stat b.ok{color:var(--green)}
+.pc-stat b.warn{color:var(--yellow)}
+/* 同步范围标签 */
+.pc-scope{margin-top:18px}
+.pc-scope>small{display:block;font-size:12px;color:var(--muted);font-weight:700;margin-bottom:9px;letter-spacing:.6px}
+.pc-scope-tags{display:flex;flex-wrap:wrap;gap:8px}
+.pc-tag{display:inline-flex;align-items:center;gap:7px;height:30px;padding:0 13px;border-radius:999px;font-size:12.5px;font-weight:600;color:var(--text);background:rgba(115,103,245,.09);border:1px solid rgba(115,103,245,.25)}
+.pc-tag svg{width:14px;height:14px;color:#a9a2ff}
+/* ============ 项目页统计折叠 ============ */
+.stats-head-bar{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
+.stats-head-bar small{color:var(--muted);font-size:12px;letter-spacing:.4px}
+.stats-toggle{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--border);background:var(--surface-2);color:var(--muted);border-radius:7px;height:28px;padding:0 10px;font:inherit;font-size:12px;cursor:pointer;transition:color .15s,border-color .15s;flex:none}
+.stats-toggle:hover{color:var(--primary);border-color:var(--primary)}
+.stats-toggle svg{width:13px}
+/* 折叠形态:一行紧凑小卡片 */
+.stats-mini{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:24px}
+.mini-stat{display:inline-flex;align-items:center;gap:9px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:7px 13px;font-size:12.5px}
+.mini-stat .tone{width:26px;height:26px;border-radius:7px;display:grid;place-items:center;background:rgba(115,103,245,.18);color:#8178ff;flex:none}
+.mini-stat .tone.green{background:rgba(67,201,150,.16);color:var(--green)}
+.mini-stat .tone.blue{background:rgba(79,157,245,.16);color:var(--blue)}
+.mini-stat .tone.purple{background:rgba(167,139,250,.16);color:#a78bfa}
+.mini-stat .tone svg{width:14px}
+.mini-stat b{font-size:15px}
+.mini-stat small{color:var(--muted)}
+.mini-stat.langs{gap:10px}
+.mini-stat.langs em{display:inline-flex;align-items:center;gap:5px;font-style:normal;color:var(--muted)}
+.mini-stat.langs em s{width:8px;height:8px;border-radius:50%;display:inline-block;text-decoration:none}
+.stats-mini .stats-toggle{margin-left:auto}
+
+/* ============ DatePicker 日期选择器 ============ */
+.dp{position:relative;margin-top:7px}
+.dp-box{display:flex;align-items:center;gap:8px;background:var(--surface-2);border:1px solid var(--border);border-radius:7px;padding:0 10px;height:41px;cursor:pointer;transition:border-color .15s}
+.dp-box:focus-within{border-color:var(--primary)}
+.dp-ico{width:16px;color:var(--muted);flex:none}
+.dp .dp-input{flex:1;min-width:0;border:0;background:transparent;padding:0;margin:0;outline:none;color:var(--text);font:inherit;font-size:13px;height:100%}
+.dp-clear{border:0;background:transparent;color:var(--muted);cursor:pointer;padding:2px;display:grid;place-items:center;flex:none}
+.dp-clear svg{width:14px}
+.dp-clear:hover{color:var(--red)}
+.dp.disabled .dp-box{opacity:.55;cursor:not-allowed}
+.dp-panel{position:absolute;top:calc(100% + 6px);left:0;z-index:70;width:264px;padding:10px;border:1px solid var(--border);border-radius:10px;background:color-mix(in srgb,var(--surface-2) 92%,transparent);box-shadow:0 18px 42px rgba(0,0,0,.38);backdrop-filter:blur(24px) saturate(150%);animation:popoverIn .16s ease-out}
+.dp-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
+.dp-nav{width:28px;height:28px;border:1px solid var(--border);background:var(--surface-2);border-radius:7px;color:var(--muted);cursor:pointer;display:grid;place-items:center;transition:color .15s,border-color .15s}
+.dp-nav:hover{color:var(--text);border-color:var(--primary)}
+.dp-nav svg{width:15px}
+.dp-ym{display:flex;gap:4px}
+.dp-ym button{border:0;background:transparent;color:var(--text);font-weight:700;font-size:13.5px;padding:4px 8px;border-radius:6px;cursor:pointer}
+.dp-ym button:hover,.dp-ym button.on{background:var(--surface-3);color:var(--primary)}
+.dp-week{display:grid;grid-template-columns:repeat(7,1fr);text-align:center;color:var(--muted);font-size:11px;margin-bottom:4px}
+.dp-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:2px}
+.dp-grid.months{grid-template-columns:repeat(3,1fr);gap:6px}
+.dp-day{height:30px;border:0;background:transparent;color:var(--text);border-radius:6px;cursor:pointer;font-size:12.5px;font:inherit}
+.dp-day:hover{background:var(--surface-3)}
+.dp-day.dim{color:var(--muted);opacity:.45}
+.dp-day.today{box-shadow:inset 0 0 0 1px var(--primary)}
+.dp-day.active{background:var(--primary);color:#fff}
+.dp-cell{height:40px;border:1px solid var(--border);background:var(--surface-2);color:var(--text);border-radius:7px;cursor:pointer;font:inherit;font-size:12.5px}
+.dp-cell:hover{border-color:var(--primary)}
+.dp-cell.active{background:var(--primary);border-color:var(--primary);color:#fff}
+.dp-foot{display:flex;justify-content:space-between;margin-top:8px;border-top:1px solid var(--border);padding-top:8px}
+.dp-foot button{border:0;background:transparent;color:var(--primary);cursor:pointer;font-size:12.5px;padding:4px 6px;font:inherit}
+.dp-foot button:hover{text-decoration:underline}
+/* 页头日报导航里的紧凑形态 */
+.team-date-nav .dp{margin-top:0;width:170px}
+.team-date-nav .dp-box{height:34px;border-radius:9px}
+
+/* ============ 排期日历头部:年月切换 + 跳转 ============ */
+.cal-switch{position:relative;display:flex;align-items:center;gap:4px;background:var(--surface-2);border:1px solid var(--border);border-radius:9px;padding:3px;flex:0 0 auto}
+.calendar-nav .cal-nav-btn{width:28px;height:28px;border:0;background:transparent;border-radius:7px;color:var(--muted);cursor:pointer;display:grid;place-items:center;transition:background .15s,color .15s}
+.calendar-nav .cal-nav-btn:hover{background:var(--surface-3);color:var(--primary)}
+.calendar-nav .cal-nav-btn svg{width:16px}
+.cal-ym{display:flex;gap:2px}
+.cal-ym button{border:0;background:transparent;color:var(--text);font-weight:700;font-size:13px;padding:4px 8px;border-radius:6px;cursor:pointer;white-space:nowrap;font:inherit;font-weight:700}
+.cal-ym button:hover,.cal-ym button.on{background:var(--surface-3);color:var(--primary)}
+.cal-pop{position:absolute;top:calc(100% + 8px);left:50%;transform:translateX(-50%);z-index:70;width:250px;padding:10px;border:1px solid var(--border);border-radius:10px;background:color-mix(in srgb,var(--surface-2) 92%,transparent);box-shadow:0 18px 42px rgba(0,0,0,.38);backdrop-filter:blur(24px) saturate(150%);animation:popoverIn .16s ease-out}
+.cal-pop-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
+.cal-pop-head b{font-size:13px}
+.cal-pop-head button{width:26px;height:26px;border:1px solid var(--border);background:var(--surface-2);border-radius:7px;color:var(--muted);cursor:pointer;display:grid;place-items:center}
+.cal-pop-head button:hover{color:var(--text);border-color:var(--primary)}
+.cal-pop-head button svg{width:14px}
+.cal-pop-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px}
+.cal-pop-grid button{height:38px;border:1px solid var(--border);background:var(--surface-2);color:var(--text);border-radius:7px;cursor:pointer;font:inherit;font-size:12.5px}
+.cal-pop-grid button:hover{border-color:var(--primary)}
+.cal-pop-grid button.active{background:var(--primary);border-color:var(--primary);color:#fff}
+.cal-jump{display:flex;align-items:center;gap:6px;height:32px;background:var(--surface-2);border:1px solid var(--border);border-radius:9px;padding:0 9px;flex:0 0 auto}
+.cal-jump:focus-within{border-color:var(--primary)}
+.cal-jump svg{width:14px;color:var(--muted);flex:none}
+.cal-jump input{width:128px;border:0;background:transparent;outline:none;color:var(--text);font:inherit;font-size:12.5px}
+
+/* ============ 团队成员卡片快捷创建 ============ */
+.tm-quick-btn{position:relative;width:30px;height:30px;border:1px solid var(--border);background:var(--surface-2);border-radius:8px;color:var(--muted);cursor:pointer;display:grid;place-items:center;flex:none;transition:color .15s,border-color .15s}
+.tm-quick-btn:hover{color:var(--primary);border-color:var(--primary)}
+.tm-quick-btn svg{width:15px}
+.tm-quick-btn .tm-quick-plus{position:absolute;width:10px;right:-4px;top:-4px;background:var(--primary);color:#fff;border-radius:50%;padding:1px}
+.quick-kind{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:4px}
+.quick-kind button{height:44px;border:1px solid var(--border);background:var(--surface-2);color:var(--text);border-radius:8px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;font:inherit;transition:border-color .15s,background .15s}
+.quick-kind button svg{width:16px}
+.quick-kind button.active{border-color:var(--primary);background:color-mix(in srgb,var(--primary) 14%,var(--surface-2));color:var(--primary)}
+
+/* 侧边栏退出按钮:悬停转为警示色,与快捷设置按钮同尺寸 */
+.quit-app-btn:hover{color:var(--red);border-color:rgba(240,94,104,.5)}
+/* 设置页文件存储操作行 */
+.fs-page-actions{display:flex;gap:12px;margin-top:22px}
+/* 素材库(服务器图片管理) */
+.pc-badge.img{background:linear-gradient(135deg,#e7935a,#e75a8b);box-shadow:inset 0 0 0 1px rgba(255,255,255,.1),0 10px 24px -8px rgba(231,122,90,.5)}
+.assets-bar{display:flex;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap}
+.assets-bar .fs-select.slim{width:auto;min-width:140px;height:38px;padding:0 10px;border-radius:10px}
+.assets-count{font-size:12.5px;color:var(--muted);margin-left:auto}
+.assets-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px}
+.asset-card{position:relative;margin:0;border:1px solid var(--border);border-radius:12px;overflow:hidden;background:var(--surface-2)}
+.asset-card img{display:block;width:100%;height:110px;object-fit:cover;background:var(--surface-3)}
+.asset-card figcaption{padding:8px 10px;display:flex;flex-direction:column;gap:2px}
+.asset-card figcaption b{font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.asset-card figcaption small{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.asset-ops{position:absolute;top:6px;right:6px;display:flex;gap:6px;opacity:0;transition:opacity .15s}
+.asset-card:hover .asset-ops{opacity:1}
+.asset-ops button{display:grid;place-items:center;width:26px;height:26px;border:0;border-radius:8px;background:rgba(0,0,0,.55);color:#fff;cursor:pointer}
+.asset-ops button:hover{background:rgba(0,0,0,.78)}
+.asset-ops button.danger:hover{background:var(--red)}
+.asset-ops svg{width:14px;height:14px}
+.pc-actions.assets-more{display:flex;justify-content:center}
+/* 全局文件存储配置(管理员 id=1,同步页卡片内) */
+.fs-config{margin-top:22px;padding-top:18px;border-top:1px dashed var(--border)}
+.fs-config .fs-head{display:flex;align-items:center;gap:12px;margin-bottom:14px}
+.fs-config .fs-head b{display:block;font-size:14.5px}
+.fs-config .fs-head small{display:block;font-size:12px;color:var(--muted);margin-top:2px}
+.fs-config .pc-field>span{display:block;font-size:12.5px;color:var(--muted);font-weight:700;margin-bottom:6px}
+.fs-select{width:100%;height:44px;border:1px solid var(--border);border-radius:12px;background:var(--surface-2);color:var(--text);padding:0 12px;outline:none;font-size:13.5px}
+.fs-config .pc-actions{display:flex;gap:10px;justify-content:flex-end}
+/* 头像编辑模态框 */
+.avatar-modal{width:min(560px,92vw)}
+.avatar-modal-body{padding:4px 24px 22px;display:flex;flex-direction:column;gap:10px}
+.avatar-modal-body .avatar-row{margin-top:6px}
+.avatar-modal-body label{display:flex;flex-direction:column;gap:6px;font-size:13px;color:var(--muted)}
+.avatar-modal-body select,.avatar-modal-body input{height:36px;border:1px solid var(--border);border-radius:9px;background:var(--surface-2);color:var(--text);padding:0 10px}
+
+/* ---------- 工作台 AI 今日规划 / 下班日报 ---------- */
+.ai-day{margin-bottom:20px}
+.ai-day-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:26px}
+@media(max-width:1150px){.ai-day-grid{grid-template-columns:1fr}}
+.ai-day-block{min-width:0;display:flex;flex-direction:column}
+.ai-day-block>header{display:flex;align-items:center;gap:10px;margin-bottom:4px}
+.ai-day-block>header b{display:flex;align-items:center;gap:8px;font-size:14px}
+.ai-day-block>header b svg{width:16px;height:16px;color:var(--primary)}
+.ai-day-block>header time{font-size:11.5px;color:var(--muted)}
+.ai-day-block>header time.stale{color:var(--yellow)}
+.ai-day-block>header .btn{margin-left:auto;height:32px;padding:0 13px;font-size:12.5px;flex:none}
+.ai-day-body{font-size:13px;line-height:1.7;color:var(--text);overflow-wrap:anywhere}
+.ai-day-body :is(h1,h2,h3){font-size:13.5px;margin:10px 0 4px}
+.ai-day-body ul,.ai-day-body ol{margin:6px 0;padding-left:20px}
+.ai-day-body li{margin:3px 0}
+.ai-day-body p{margin:6px 0}
+.ai-day-empty{margin:14px 0 6px;color:var(--muted);font-size:12.5px}
+.ai-day-nokey{display:flex;align-items:center;gap:14px;padding:6px 2px}
+.ai-day-nokey svg{width:20px;height:20px;color:var(--primary);flex:none}
+.ai-day-nokey p{flex:1;margin:0;color:var(--muted);font-size:13px}
+/* ---------- 待办/工单生命周期时间线 ---------- */
+.lifecycle{margin-top:18px;padding-top:14px;border-top:1px dashed color-mix(in srgb,var(--border) 85%,transparent)}
+.lc-head{display:flex;align-items:center;gap:7px;font-size:12px;font-weight:600;letter-spacing:.4px;color:var(--muted);margin-bottom:11px}
+.lc-head svg{width:13px;height:13px;color:var(--primary)}
+.lc-list{position:relative;display:flex;flex-direction:column;gap:13px}
+.lc-list::before{content:'';position:absolute;left:11px;top:12px;bottom:12px;width:2px;border-radius:1px;background:linear-gradient(180deg,color-mix(in srgb,var(--primary) 40%,transparent),color-mix(in srgb,var(--border) 65%,transparent))}
+.lc-node{position:relative;display:flex;gap:11px;align-items:flex-start}
+.lc-dot{position:relative;z-index:1;flex:none;width:23px;height:23px;border-radius:50%;display:grid;place-items:center;color:var(--muted);background:var(--surface-3);border:1px solid var(--glass-border);box-shadow:0 0 0 3px color-mix(in srgb,var(--surface) 78%,transparent)}
+.lc-dot svg{width:11.5px;height:11.5px}
+.lc-list .lc-node:first-child .lc-dot{color:var(--primary);border-color:color-mix(in srgb,var(--primary) 50%,transparent);background:color-mix(in srgb,var(--primary) 14%,var(--surface-3))}
+.lc-node.todo-doing .lc-dot,.lc-node.ticket-in_progress .lc-dot{color:var(--blue);border-color:rgba(79,157,245,.5);background:rgba(79,157,245,.13)}
+.lc-node.todo-done .lc-dot,.lc-node.ticket-resolved .lc-dot{color:var(--green);border-color:rgba(67,201,150,.5);background:rgba(67,201,150,.13)}
+.lc-node.ticket-closed .lc-dot{color:#bb9af7;border-color:rgba(187,154,247,.45);background:rgba(187,154,247,.12)}
+.lc-node.running .lc-dot{color:var(--yellow);border-color:rgba(231,189,53,.5);background:rgba(231,189,53,.11)}
+.lc-dot.pulse::after{content:'';position:absolute;inset:-1px;border-radius:50%;border:1px solid rgba(231,189,53,.55);animation:lcPulse 2.2s ease-out infinite}
+@keyframes lcPulse{0%{transform:scale(1);opacity:.85}70%,100%{transform:scale(1.6);opacity:0}}
+.lc-body{min-width:0;display:flex;flex-direction:column;gap:2px;padding-top:2px}
+.lc-row{display:flex;align-items:baseline;gap:7px;flex-wrap:wrap}
+.lc-row b{font-size:12.5px;font-weight:700;color:var(--text)}
+.lc-node.done .lc-row b{color:var(--green)}
+.lc-sub{font-style:normal;font-size:11px;color:var(--muted)}
+.lc-gap{font-size:10.5px;font-weight:600;color:color-mix(in srgb,var(--primary) 72%,var(--text));background:color-mix(in srgb,var(--primary) 13%,transparent);padding:1px 8px;border-radius:999px;white-space:nowrap}
+.lc-time{font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}
+.lc-total{display:flex;align-items:center;gap:7px;margin:13px 0 0;padding:8px 12px;border-radius:10px;font-size:12px;font-weight:700;color:var(--green);background:rgba(67,201,150,.1);border:1px solid rgba(67,201,150,.3);box-shadow:0 0 16px -6px rgba(67,201,150,.45)}
+.lc-total svg{flex:none;width:14px;height:14px}
+.cal-detail-body .lifecycle{margin-top:16px}
+
+/* 添加项目来源切换(本地目录 / Git 克隆) */
+.src-switch{display:grid;grid-auto-flow:column;grid-auto-columns:1fr;gap:6px;padding:4px;border:1px solid var(--border);border-radius:10px;background:var(--surface-2);margin-bottom:2px}
+.src-switch button{display:inline-flex;align-items:center;justify-content:center;gap:7px;height:34px;border:0;border-radius:7px;background:transparent;color:var(--muted);font:inherit;font-size:12.5px;cursor:pointer;transition:background .18s,color .18s}
+.src-switch button svg{width:14px;height:14px}
+.src-switch button:hover{color:var(--text)}
+.src-switch button.active{background:color-mix(in srgb,var(--primary) 20%,var(--surface-3));color:#cfcaff;font-weight:600}
+.src-switch button:disabled{opacity:.6;cursor:default}
+
+/* ============ 启动台 ============ */
+.lp-tools{display:flex;align-items:center;gap:10px}
+.lp-sys-toggle{display:inline-flex;align-items:center;gap:7px;color:var(--muted);font-size:12px;cursor:pointer;user-select:none}
+.lp-sys-toggle input{accent-color:var(--primary)}
+.lp-sys-toggle em{font-style:normal;min-width:18px;height:18px;padding:0 5px;border-radius:9px;background:var(--surface-3);display:grid;place-items:center;font-size:10.5px}
+.lp-section{margin-top:18px}
+.lp-title{display:flex;align-items:center;gap:8px;margin:0 0 12px;font-size:13.5px;color:var(--text)}
+.lp-title svg{width:15px;height:15px;color:#a9a2ff}
+.lp-title em{font-style:normal;font-weight:600;color:var(--muted);font-size:12px}
+.lp-empty{padding:26px;border:1px dashed var(--border);border-radius:12px;color:var(--muted);font-size:12.5px;text-align:center}
+.lp-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:14px}
+.lp-card{display:flex;flex-direction:column;gap:9px;padding:14px 15px}
+.lp-card.running{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--green) 26%,transparent)}
+.lp-card header{display:flex;align-items:center;gap:10px}
+.lp-icon{flex:none;width:34px;height:34px;border-radius:9px;display:grid;place-items:center}
+.lp-icon svg{width:17px;height:17px}
+.lp-name{flex:1;min-width:0;display:grid;gap:1px}
+.lp-name b{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.lp-name small{color:var(--muted);font-size:10.5px}
+.lp-dot{flex:none;width:9px;height:9px;border-radius:50%;background:#6b7482}
+.lp-dot.on{background:var(--green);box-shadow:0 0 9px var(--green)}
+.lp-ports{display:flex;align-items:center;gap:5px;flex-wrap:wrap;min-height:20px}
+.lp-port{padding:1px 8px;border-radius:999px;background:var(--surface-3);border:1px solid var(--glass-border);font-size:11px;font-variant-numeric:tabular-nums}
+.lp-port.more{color:var(--muted)}
+.lp-pid{margin-left:auto;color:var(--muted);font-size:10.5px}
+.lp-res{display:flex;gap:12px;color:var(--muted);font-size:11.5px}
+.lp-res span{display:inline-flex;align-items:center;gap:4px;font-variant-numeric:tabular-nums}
+.lp-res svg{width:12px;height:12px}
+.lp-meta{margin:0;color:var(--muted);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.lp-card footer{display:flex;align-items:center;gap:6px;padding-top:9px;border-top:1px solid var(--border)}
+.lp-gap{flex:1}
+.lp-act{display:inline-flex;align-items:center;gap:5px;height:27px;padding:0 10px;border-radius:8px;border:1px solid var(--border);background:var(--surface-2);color:var(--text);font:inherit;font-size:11.5px;cursor:pointer;transition:border-color .15s,color .15s}
+.lp-act svg{width:12px;height:12px}
+.lp-act:hover{border-color:var(--primary);color:#a9a2ff}
+.lp-act.go{border-color:color-mix(in srgb,var(--green) 45%,transparent);color:var(--green)}
+.lp-act.go:hover{border-color:var(--green)}
+.lp-act.halt{border-color:color-mix(in srgb,var(--red) 40%,transparent);color:var(--red)}
+.lp-act.halt:hover{border-color:var(--red)}
+.lp-act.danger:hover{border-color:var(--red);color:var(--red)}
+.lp-act:disabled{opacity:.5;cursor:default}
+.spinning{animation:lpSpin 1s linear infinite}
+@keyframes lpSpin{to{transform:rotate(360deg)}}
+/* 启动台编辑模态框 */
+.lp-modal{width:520px;max-width:94vw;padding:0;overflow:hidden}
+.lp-modal-head{display:flex;align-items:center;justify-content:space-between;padding:13px 16px;border-bottom:1px solid var(--border)}
+.lp-modal-head h2{display:flex;align-items:center;gap:8px;margin:0;font-size:14px}
+.lp-modal-head h2 svg{width:15px;height:15px;color:#a9a2ff}
+.lp-form{padding:16px;display:grid;gap:12px;max-height:62vh;overflow:auto}
+.lp-row{display:grid;grid-template-columns:1fr 130px;gap:10px}
+.lp-field{display:grid;gap:6px;color:var(--muted);font-size:12px}
+.lp-field input,.lp-field select{height:36px;border:1px solid var(--border);border-radius:8px;background:var(--surface-2);color:var(--text);padding:0 11px;margin:0;font:inherit;font-size:12.8px;outline:none;width:100%}
+.lp-field input:focus,.lp-field select:focus{border-color:var(--primary)}
+.lp-dir-row{display:flex;gap:7px}
+.lp-dir-row input{flex:1}
+.lp-dir-row .btn{height:36px;padding:0 11px}
+.lp-suggest{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:-4px}
+.lp-suggest small{color:var(--muted);font-size:11px}
+.lp-suggest button{border:1px dashed var(--border);background:transparent;color:var(--muted);border-radius:999px;padding:2px 10px;font:inherit;font-size:11px;cursor:pointer}
+.lp-suggest button:hover{border-color:var(--primary);color:#a9a2ff}
+.lp-err{margin:0;color:var(--red);font-size:12px}
+.lp-modal-foot{display:flex;justify-content:flex-end;gap:9px;padding:13px 16px;border-top:1px solid var(--border)}
+
+/* ============ 双列侧边导航:一级分类 rail + 二级菜单 sub ============ */
+.sidebar{padding:0;flex-direction:row}
+.side-rail{flex:none;width:76px;display:flex;flex-direction:column;align-items:center;gap:6px;padding:14px 8px;border-right:1px solid var(--border)}
+.rail-logo{width:44px;height:44px;margin-bottom:10px}
+.rail-logo svg{width:34px;height:34px}
+.sidebar .rail-nav{display:flex;flex-direction:column;gap:6px;margin-top:0;width:100%}
+.rail-item{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;width:100%;height:56px;border:0;border-radius:11px;background:transparent;color:var(--muted);cursor:pointer;font:inherit;transition:background .2s,color .2s}
+.rail-item svg{width:19px;height:19px}
+.rail-item span{font-size:10.5px;font-weight:600;letter-spacing:.3px}
+.rail-item:hover{background:var(--surface-3);color:var(--text)}
+.rail-item.active{background:color-mix(in srgb,var(--primary) 17%,transparent);color:#a9a2ff;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--primary) 32%,transparent)}
+.rail-user{position:relative;margin-top:auto;border:0;background:transparent;cursor:pointer;padding:4px;border-radius:11px}
+.rail-user:hover,.rail-user.active{background:var(--surface-3)}
+.rail-pending{position:absolute;top:-3px;right:-5px;min-width:17px;height:17px;font-size:10px;border:1px solid var(--side)}
+.side-sub{flex:1;min-width:0;display:flex;flex-direction:column;padding:14px 10px 16px}
+.sub-brand{flex:none;height:44px;display:flex;align-items:center;padding:0 7px;font-size:14.5px}
+.sub-brand b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.sub-title{margin:12px 7px 5px;font-size:10px;font-weight:700;letter-spacing:1.2px;color:var(--muted);text-transform:uppercase}
+.sidebar .sub-nav{display:flex;flex-direction:column;gap:3px;margin-top:2px}
+.sidebar .sub-nav a{height:38px;display:flex;align-items:center;gap:9px;padding:0 10px;border-radius:8px;color:var(--muted);font-size:12.8px}
+.sidebar .sub-nav a svg{width:16px;height:16px;flex:none}
+.sidebar .sub-nav a:hover{background:var(--surface-3);color:var(--text)}
+.sidebar .sub-nav a.active{background:color-mix(in srgb,var(--primary) 15%,transparent);color:#a9a2ff}
+.sub-caret{margin-left:auto;display:grid;place-items:center;width:20px;height:20px;border:0;border-radius:6px;background:transparent;color:inherit;cursor:pointer;padding:0}
+.sub-caret svg{width:13px;height:13px;transition:transform .18s}
+.sub-caret.open svg{transform:rotate(180deg)}
+.sidebar .sub-children{display:flex;flex-direction:column;gap:2px;margin:1px 0 4px}
+.sidebar .sub-children a{height:31px;padding:0 10px 0 35px;font-size:12px}
+/* 窄屏:只留 rail,点分类弹浮层二级菜单 */
+.rail-flyout{position:fixed;left:78px;width:196px;padding:10px;border:1px solid var(--border);border-radius:12px;background:color-mix(in srgb,var(--surface-2) 90%,transparent);box-shadow:0 18px 42px rgba(0,0,0,.4);backdrop-filter:blur(24px) saturate(150%);animation:popoverIn .16s ease-out;z-index:60;display:flex;flex-direction:column;gap:3px}
+.fly-title{font-size:10px;font-weight:700;letter-spacing:1.2px;color:var(--muted);padding:2px 10px 6px;text-transform:uppercase}
+.rail-flyout a{height:36px;display:flex;align-items:center;gap:9px;padding:0 10px;border-radius:8px;color:var(--muted);text-decoration:none;font-size:12.8px}
+.rail-flyout a svg{width:16px;height:16px}
+.rail-flyout a:hover{background:var(--surface-3);color:var(--text)}
+.rail-flyout a.active{background:color-mix(in srgb,var(--primary) 15%,transparent);color:#a9a2ff}
+.rail-flyout a.fly-child{padding-left:35px}
+@media(max-width:1150px){
+.side-sub{display:none}
+.side-rail{width:72px;border-right:0;padding:14px 6px}
+.sidebar .rail-nav span{display:inline}
+.rail-logo{width:40px;height:40px}
+}
+
+/* ============ 关于模态框 ============ */
+.about-modal{position:relative;width:560px;max-width:94vw;max-height:90vh;overflow:auto;padding:28px 38px 20px;display:flex;flex-direction:column;align-items:center}
+.about-close{position:absolute;top:14px;right:14px;z-index:2;display:grid;place-items:center;width:30px;height:30px;border:0;border-radius:9px;background:color-mix(in srgb,var(--surface-3) 70%,transparent);color:var(--muted);cursor:pointer;transition:background .18s ease,color .18s ease}
+.about-close:hover{background:var(--surface-3);color:var(--text)}
+.about-close svg{width:16px;height:16px}
+.about-hero{position:relative;display:flex;flex-direction:column;align-items:center;gap:9px;width:100%}
+.about-glow{position:absolute;top:-26px;left:50%;transform:translateX(-50%);width:300px;height:240px;border-radius:50%;background:radial-gradient(closest-side,rgba(62,201,167,.3),rgba(79,157,245,.14) 55%,transparent);filter:blur(6px);pointer-events:none}
+.about-logo{position:relative;width:114px;height:114px;border-radius:27px;box-shadow:0 22px 48px -16px rgba(62,201,167,.5),0 0 0 1px var(--glass-border);animation:aboutFloat 5.2s ease-in-out infinite}
+@keyframes aboutFloat{0%,100%{transform:translateY(0)}50%{transform:translateY(-6px)}}
+.about-name{position:relative;margin:12px 0 0;font-size:23px;font-weight:800;letter-spacing:.5px;background:linear-gradient(120deg,#6fe0bd,#6ea4ff 55%,#a99bff);-webkit-background-clip:text;background-clip:text;color:transparent}
+.about-slogan{position:relative;margin:0;font-size:13px;color:var(--muted)}
+.about-badges{position:relative;display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-top:4px}
+.about-badge{font-size:11px;font-weight:600;color:var(--muted);padding:4px 11px;border-radius:999px;border:1px solid var(--glass-border);background:color-mix(in srgb,var(--surface-3) 72%,transparent)}
+.about-badge.ver{color:#fff;border-color:transparent;background:linear-gradient(120deg,#3ec9a7,#4f9df5)}
+.about-intro{margin:16px 0 0;font-size:12.8px;line-height:1.9;color:color-mix(in srgb,var(--text) 84%,var(--muted))}
+.about-feats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:9px;width:100%;margin-top:14px}
+.about-feat{display:flex;align-items:center;gap:8px;padding:10px 12px;border-radius:11px;border:1px solid var(--glass-border);background:color-mix(in srgb,var(--surface-3) 62%,transparent);font-size:12px;font-weight:600;color:var(--text);transition:transform .18s ease,border-color .18s ease}
+.about-feat:hover{transform:translateY(-2px);border-color:color-mix(in srgb,#3ec9a7 45%,var(--glass-border))}
+.about-feat svg{flex:none;width:15px;height:15px;color:#3ec9a7}
+.about-story{width:100%;margin-top:14px;padding:12px 18px;border-radius:13px;border:1px solid var(--glass-border);background:linear-gradient(135deg,rgba(62,201,167,.09),rgba(79,157,245,.07));text-align:left}
+.about-story b{display:block;font-size:12.8px;margin-bottom:5px;color:var(--text)}
+.about-story p{margin:0;font-size:12.3px;line-height:1.9;color:var(--muted)}
+.about-foot{margin:14px 0 0;font-size:11px;color:color-mix(in srgb,var(--muted) 75%,transparent)}
+
+/* ============ 下拉“查看全部” + 消息中心 / 今日任务 / 笔记页面 ============ */
+.bell-more{width:100%;display:flex;align-items:center;justify-content:center;gap:6px;height:38px;border:0;border-top:1px solid var(--border);background:transparent;color:var(--muted);font:inherit;font-size:12.5px;cursor:pointer;transition:background .15s,color .15s}
+.bell-more:hover{background:var(--surface-3);color:var(--text)}
+.bell-more svg{width:14px}
+.msg-chips{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
+.msg-chip{display:inline-flex;align-items:center;gap:7px;height:30px;padding:0 13px;border-radius:999px;border:1px solid var(--border);background:var(--surface-2);color:var(--muted);font:inherit;font-size:12.5px;cursor:pointer;transition:border-color .15s,color .15s,background .15s}
+.msg-chip em{font-style:normal;font-size:11px;opacity:.75}
+.msg-chip:hover{color:var(--text);border-color:var(--primary)}
+.msg-chip.active{background:color-mix(in srgb,var(--primary) 16%,transparent);border-color:color-mix(in srgb,var(--primary) 45%,transparent);color:#a9a2ff}
+.msg-unread-toggle{display:inline-flex;align-items:center;gap:7px;color:var(--muted);font-size:13px;cursor:pointer;user-select:none}
+.msg-panel{padding:6px}
+.msg-panel .msg-row{border-radius:9px;border-bottom:0}
+.msg-empty{display:flex;flex-direction:column;align-items:center;gap:10px}
+.msg-empty svg{width:26px;height:26px;opacity:.5}
+.today-title{display:flex;align-items:center;gap:9px;margin:22px 2px 10px;font-size:15px}
+.today-title svg{width:16px;height:16px;color:var(--muted)}
+.today-section.overdue .today-title svg{color:var(--red)}
+.today-section.today .today-title svg{color:var(--yellow)}
+.today-section.doing .today-title svg{color:var(--blue)}
+.today-title em{font-style:normal;font-size:12px;color:var(--muted);font-weight:600}
+.today-panel{padding:6px}
+.today-item{padding:11px 10px}
+.today-item .tc-main b{font-size:13.5px}
+.today-item .tc-acts{opacity:.85}
+.today-empty{display:flex;flex-direction:column;align-items:center;gap:12px;padding:80px 0;color:var(--muted)}
+.today-empty svg{width:30px;height:30px;opacity:.6}
+.notes-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:14px}
+.note-card{display:flex;flex-direction:column;gap:8px;padding:15px 16px;cursor:pointer}
+.note-card-title{font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.note-card-preview{margin:0;color:var(--muted);font-size:12.5px;line-height:1.55;overflow:hidden;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;flex:1}
+.note-card footer{display:flex;align-items:center;justify-content:space-between;margin-top:auto}
+.note-card time{color:var(--muted);font-size:11px;font-variant-numeric:tabular-nums}
+.note-card-del{display:grid;place-items:center;width:26px;height:26px;border:0;border-radius:7px;background:transparent;color:var(--muted);cursor:pointer;opacity:0;transition:opacity .15s}
+.note-card:hover .note-card-del{opacity:1}
+.note-card-del:hover{background:rgba(240,94,104,.14);color:var(--red)}
+.note-card-del svg{width:14px}
+.note-search{display:inline-flex;align-items:center;gap:8px;height:40px;padding:0 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface-2)}
+.note-search svg{width:15px;color:var(--muted)}
+.note-search input{border:0;background:transparent;color:var(--text);outline:none;width:180px}
+
+/* ============ 页面级 AI 总结抽屉 ============ */
+.scope-overlay{justify-content:flex-end;align-items:stretch;padding:0}
+.scope-drawer{width:520px;max-width:92vw;height:100%;display:flex;flex-direction:column;background:var(--surface);border-left:1px solid var(--border);box-shadow:-24px 0 60px rgba(0,0,0,.45);animation:scopeDrawerIn .22s ease-out}
+@keyframes scopeDrawerIn{from{transform:translateX(48px);opacity:0}to{transform:none;opacity:1}}
+.scope-head{display:flex;align-items:center;gap:10px;padding:16px 18px;border-bottom:1px solid var(--border)}
+.scope-head b{flex:1;display:flex;align-items:center;gap:8px;font-size:15px;min-width:0}
+.scope-head b svg{flex:none;width:16px;color:#a9a2ff}
+.scope-title{color:var(--muted);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.scope-head time{flex:none;color:var(--muted);font-size:11.5px;font-variant-numeric:tabular-nums}
+.scope-body{flex:1;overflow:auto;padding:18px;font-size:13.5px;line-height:1.75}
+.scope-empty,.scope-generating{display:flex;align-items:center;gap:8px;color:var(--muted)}
+.scope-generating svg{width:14px}
+.scope-err{color:var(--red);font-size:12.5px}
+.scope-nokey{display:flex;flex-direction:column;align-items:center;gap:12px;padding:60px 0;color:var(--muted)}
+.scope-nokey svg{width:26px;height:26px;opacity:.6}
+.scope-foot{flex:none;display:flex;justify-content:flex-end;padding:14px 18px;border-top:1px solid var(--border)}
+
+/* ============ 云端项目待绑定横幅 ============ */
+.cloud-pending{margin-bottom:18px;padding:14px 16px;border:1px solid color-mix(in srgb,var(--blue) 34%,transparent);background:linear-gradient(135deg,rgba(79,157,245,.09),transparent 65%)}
+.cloud-pending>header{display:flex;align-items:baseline;gap:12px;margin-bottom:10px}
+.cloud-pending>header b{display:flex;align-items:center;gap:8px;font-size:14px}
+.cloud-pending>header b svg{width:16px;color:var(--blue)}
+.cloud-pending>header em{font-style:normal;font-size:11.5px;color:var(--blue);background:rgba(79,157,245,.14);border-radius:999px;padding:1px 8px}
+.cloud-pending>header small{color:var(--muted)}
+.cloud-pending-list{display:grid;gap:8px}
+.cloud-pending-item{display:flex;align-items:center;gap:12px;padding:9px 12px;border:1px solid var(--border);border-radius:9px;background:var(--surface-2)}
+.cp-main{flex:1;min-width:0;display:grid;gap:2px}
+.cp-main b{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.cp-main small{color:var(--muted);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.cloud-pending-item .btn{height:30px;padding:0 11px;font-size:12px}
+
+/* ============ 日历页头紧凑化:单行标题 + 32px 按钮 ============ */
+.calendar-page .calendar-head{padding:9px 16px;margin-bottom:14px}
+.calendar-page .calendar-head>div{display:flex;align-items:baseline;gap:10px;min-width:0}
+.calendar-page .calendar-head h1{font-size:19px;margin:0;white-space:nowrap}
+.calendar-page .calendar-head p{font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.calendar-page .calendar-head .actions{flex-wrap:nowrap}
+.calendar-page .calendar-head .actions .btn{height:32px;padding:0 10px;font-size:12.5px;white-space:nowrap;flex-shrink:0}
+.calendar-page .calendar-head .calendar-month{font-size:13.5px;white-space:nowrap}
+.calendar-grid-panel{min-height:calc(100vh - 150px)}
+
+/* ============ 左侧导航徽标 ============ */
+.rail-item{position:relative}
+.rail-dot{position:absolute;top:7px;right:9px;width:7px;height:7px;border-radius:50%;background:var(--red);box-shadow:0 0 0 2px var(--side),0 0 8px rgba(240,94,104,.8)}
+.sub-nav a{position:relative}
+.nav-badge{margin-left:auto;flex:none;min-width:19px;height:17px;padding:0 5px;border-radius:999px;display:inline-grid;place-items:center;font-style:normal;font-size:10.5px;font-weight:800;color:#fff;background:linear-gradient(135deg,#f05e68,#e0447f);box-shadow:0 3px 8px -2px rgba(240,94,104,.55);font-variant-numeric:tabular-nums;line-height:1}
+.nav-dot{margin-left:auto;flex:none;width:7px;height:7px;border-radius:50%;background:var(--red);box-shadow:0 0 6px rgba(240,94,104,.75)}
+.sub-nav a .sub-caret{margin-left:6px}
+.rail-flyout .nav-badge,.rail-flyout .nav-dot{margin-left:auto}
+
+/* ============ 个人资料 tab ============ */
+.ph-title-tag{display:inline-block;margin-top:3px;padding:2px 10px;border-radius:999px;font-size:11.5px;color:#a9a2ff;background:rgba(115,103,245,.13);border:1px solid rgba(115,103,245,.3);width:fit-content}
+.pc-badge.id{background:linear-gradient(135deg,#43c996,#3f8ef0);box-shadow:inset 0 0 0 1px rgba(255,255,255,.1),0 10px 24px -8px rgba(67,201,150,.5)}
+.pc-badge.team{background:linear-gradient(135deg,#e0447f,#9a5ef0);box-shadow:inset 0 0 0 1px rgba(255,255,255,.1),0 10px 24px -8px rgba(224,68,127,.5)}
+.pc-badge.ai{background:linear-gradient(135deg,#7367f5,#43c996);box-shadow:inset 0 0 0 1px rgba(255,255,255,.1),0 10px 24px -8px rgba(115,103,245,.5)}
+.pc-form .pc-field-wide{max-width:none}
+.pc-textarea{width:100%;border:1px solid var(--border);border-radius:12px;background:var(--surface-2);color:var(--text);padding:11px 14px;resize:vertical;outline:none;font:inherit;font-size:13.5px;line-height:1.6;transition:border-color .2s,box-shadow .2s}
+.pc-textarea:focus{border-color:var(--primary);box-shadow:0 0 0 3px rgba(115,103,245,.16)}
+.pc-hint{display:block;margin-top:7px;color:var(--muted);font-size:11.5px}
+.pc-hint svg{width:12px;vertical-align:-2px}
+.pc-tags-label{display:flex;align-items:center;gap:7px;font-size:12.5px;color:var(--muted);font-weight:700;margin-bottom:7px}
+.pc-tags-label svg{width:14px}
+/* 技术栈标签编辑器:chips + 行内输入 */
+.tag-editor{display:flex;flex-wrap:wrap;gap:7px;align-items:center;padding:9px 11px;border-radius:12px;border:1px solid var(--border);background:var(--surface-2);transition:border-color .2s,box-shadow .2s}
+.tag-editor:focus-within{border-color:var(--primary);box-shadow:0 0 0 3px rgba(115,103,245,.16)}
+.tag-editor input{flex:1;min-width:130px;border:0;background:none;color:var(--text);outline:none;font:inherit;font-size:13px;height:26px}
+.tech-tag{display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 6px 0 11px;border-radius:999px;font-size:12px;font-weight:700;color:#a9a2ff;background:rgba(115,103,245,.12);border:1px solid rgba(115,103,245,.32)}
+.tech-tag button{display:grid;place-items:center;width:16px;height:16px;padding:0;border:0;border-radius:50%;background:transparent;color:inherit;cursor:pointer;opacity:.7}
+.tech-tag button:hover{opacity:1;background:rgba(115,103,245,.25)}
+.tech-tag button svg{width:11px;height:11px}
+.tech-tag.mini{height:20px;padding:0 8px;font-size:10.5px;gap:0}
+/* 团队 tab:团队选择列表 */
+.team-pick-list{display:grid;gap:9px;margin-bottom:16px}
+.team-pick{display:flex;align-items:center;gap:12px;padding:11px 14px;border-radius:13px;border:1px solid var(--border);background:var(--surface-2);color:var(--text);font:inherit;cursor:pointer;text-align:left;transition:border-color .2s,box-shadow .2s}
+.team-pick:hover{border-color:color-mix(in srgb,var(--primary) 45%,var(--border))}
+.team-pick.current{border-color:var(--primary);box-shadow:0 0 0 3px rgba(115,103,245,.14)}
+.team-pick-badge{width:38px;height:38px;flex:none;border-radius:12px;display:grid;place-items:center;font-weight:900;font-size:16px;color:#fff;background:linear-gradient(135deg,#7367f5,#e0447f)}
+.team-pick-main{flex:1;min-width:0;display:grid;line-height:1.35}
+.team-pick-main b{font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.team-pick-main small{color:var(--muted);font-size:11.5px}
+.team-pick-check{width:18px;color:var(--green);flex:none}
+/* rail 团队切换器弹层:复用 team-pick 行样式,收窄内边距 */
+.ts-dropdown{width:300px}
+.ts-list{display:grid;gap:7px;padding:10px}
+.ts-list .team-pick{padding:9px 11px;border-radius:11px}
+.ts-list .team-pick-badge{width:32px;height:32px;border-radius:10px;font-size:14px}
+.ts-list .bell-empty{padding:18px 10px}
+.ts-list .bell-empty .spin{width:14px;vertical-align:-2px}
+.team-create-row{display:flex;gap:10px;margin-top:6px}
+.team-create-row input{flex:1;height:40px;border:1px solid var(--border);border-radius:11px;background:var(--surface-2);color:var(--text);padding:0 13px;outline:none;font:inherit;font-size:13px;transition:border-color .2s}
+.team-create-row input:focus{border-color:var(--primary)}
+.team-create-row .btn{height:40px;flex:none}
+
+/* ============ 团队页面 ============ */
+.team-page .spacer{flex:1}
+.team-empty{display:grid;justify-items:center;gap:13px;padding:44px 20px;text-align:center;max-width:560px;margin:0 auto}
+.team-empty-ico{width:64px;height:64px;border-radius:50%;display:grid;place-items:center;background:radial-gradient(circle at 30% 25%,rgba(115,103,245,.3),rgba(115,103,245,.07));border:1px solid var(--glass-border)}
+.team-empty-ico svg{width:28px;height:28px;color:#a9a2ff}
+.team-empty-ico.warn{background:radial-gradient(circle at 30% 25%,rgba(231,189,53,.25),rgba(231,189,53,.06))}
+.team-empty-ico.warn svg{color:var(--yellow)}
+.team-empty p{margin:0;color:var(--muted);font-size:13px;line-height:1.7;max-width:420px}
+.team-empty .team-create-row{width:min(420px,100%)}
+/* 团队卡 */
+.team-card{padding:18px 20px;margin-bottom:18px}
+.team-card-head{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
+.team-logo{width:52px;height:52px;flex:none;border-radius:16px;display:grid;place-items:center;font-weight:900;font-size:22px;color:#fff;background:linear-gradient(135deg,#7367f5,#e0447f);box-shadow:0 10px 26px -8px rgba(115,103,245,.6)}
+.team-card-main{min-width:0;display:grid;gap:3px}
+.team-card-main b{display:flex;align-items:center;gap:8px;font-size:17px}
+.team-card-main small{color:var(--muted);font-size:12.5px}
+.team-card-main .icon-btn.sm{width:26px;height:26px;border:0;border-radius:7px;display:grid;place-items:center;background:transparent;color:var(--muted);cursor:pointer}
+.team-card-main .icon-btn.sm:hover{color:var(--text);background:var(--surface-2)}
+.team-card-main .icon-btn.sm svg{width:14px}
+.team-switch{margin-left:auto;display:flex;gap:7px;flex-wrap:wrap}
+.team-chip{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 13px;border-radius:999px;border:1px solid var(--border);background:var(--surface-2);color:var(--muted);font:inherit;font-size:12.5px;font-weight:700;cursor:pointer;transition:border-color .2s,color .2s}
+.team-chip:hover{color:var(--text)}
+.team-chip.current{color:#fff;border-color:transparent;background:linear-gradient(135deg,#7367f5,#5a8df0)}
+.team-chip svg{width:13px}
+.team-card-ops{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:15px;padding-top:14px;border-top:1px solid var(--border)}
+.team-digest-time{display:inline-flex;align-items:center;gap:8px;font-size:12.5px;color:var(--muted)}
+.team-digest-time svg{width:15px}
+.team-digest-time input{height:32px;border:1px solid var(--border);border-radius:9px;background:var(--surface-2);color:var(--text);padding:0 8px;outline:none;font:inherit;font-size:12.5px}
+.btn.danger-ghost{color:var(--red)}
+.btn.danger-ghost:hover{border-color:color-mix(in srgb,var(--red) 55%,var(--border));background:rgba(240,94,104,.08)}
+/* 成员卡片网格 */
+.team-members{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px;margin-bottom:18px}
+.team-member{display:flex;align-items:flex-start;gap:12px;padding:15px 16px;margin:0}
+.tm-avatar{width:44px;height:44px;flex:none;border-radius:50%;overflow:hidden;display:grid;place-items:center;background:linear-gradient(135deg,rgba(115,103,245,.35),rgba(224,68,127,.3));border:1px solid var(--glass-border)}
+.tm-avatar img{width:100%;height:100%;object-fit:cover}
+.tm-avatar b{font-size:17px;color:#fff}
+.tm-main{flex:1;min-width:0;display:grid;gap:4px}
+.tm-main>b{font-size:13.5px;display:flex;align-items:baseline;gap:6px;overflow:hidden;white-space:nowrap}
+.tm-account{color:var(--muted);font-size:11px;font-weight:normal}
+.tm-title{color:var(--muted);font-size:11.5px}
+.tm-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:2px}
+.tm-role{flex:none;display:inline-flex;align-items:center;gap:5px;height:24px;padding:0 10px;border-radius:999px;font-size:11px;font-weight:800}
+.tm-role svg{width:12px}
+.tm-role.owner{color:#ffd166;background:rgba(255,209,102,.12);border:1px solid rgba(255,209,102,.35)}
+.tm-role.admin{color:#a9a2ff;background:rgba(115,103,245,.12);border:1px solid rgba(115,103,245,.35)}
+.tm-role.member{color:var(--muted);background:var(--surface-2);border:1px solid var(--border)}
+.tm-ops{display:flex;gap:4px;flex:none}
+.tm-ops button{width:28px;height:28px;border:0;border-radius:8px;display:grid;place-items:center;background:transparent;color:var(--muted);cursor:pointer}
+.tm-ops button:hover{color:var(--text);background:var(--surface-2)}
+.tm-ops button.danger:hover{color:var(--red);background:rgba(240,94,104,.1)}
+.tm-ops button svg{width:15px}
+.team-create-panel{padding:15px 18px}
+.team-create-panel>small{display:block;color:var(--muted);font-size:12px;font-weight:700;margin-bottom:9px}
+/* 任务筛选 tab */
+.team-filter{display:flex;gap:4px;width:fit-content;padding:5px;border-radius:12px;background:var(--surface-2);border:1px solid var(--border);margin-bottom:14px}
+.team-filter button{height:32px;padding:0 15px;border:0;border-radius:8px;background:transparent;color:var(--muted);font:inherit;font-size:12.5px;font-weight:700;cursor:pointer;transition:color .2s,background .2s}
+.team-filter button:hover{color:var(--text)}
+.team-filter button.active{color:#fff;background:linear-gradient(135deg,#7367f5,#5a8df0)}
+/* 任务列表 */
+.team-task-panel{padding:10px;display:grid;gap:8px}
+.team-none{margin:0;padding:22px 0;text-align:center;color:var(--muted);font-size:12.5px}
+.team-task{display:flex;align-items:center;gap:12px;padding:11px 13px;border:1px solid var(--glass-border);border-radius:11px;background:var(--surface-2);cursor:pointer;transition:border-color .18s}
+.team-task:hover{border-color:color-mix(in srgb,var(--primary) 45%,var(--border))}
+.team-task.done{opacity:.6}
+.team-task.shared{cursor:default}
+.tt-main{flex:1;min-width:0;display:grid;gap:4px}
+.tt-main>b{font-size:13.5px;display:flex;align-items:center;gap:7px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
+.tt-urged{display:inline-grid;place-items:center;color:var(--yellow)}
+.tt-urged svg{width:13px;animation:bellShake 2.4s ease-in-out infinite}
+@keyframes bellShake{0%,86%,100%{transform:rotate(0)}88%{transform:rotate(13deg)}91%{transform:rotate(-11deg)}94%{transform:rotate(7deg)}97%{transform:rotate(-4deg)}}
+.tt-main small{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:11.5px;flex-wrap:wrap}
+.tt-main small svg{width:11px}
+.tt-status{display:inline-flex;align-items:center;height:20px;padding:0 9px;border-radius:999px;font-size:10.5px;font-weight:800}
+.tt-status.st-open{color:var(--blue);background:rgba(79,157,245,.12)}
+.tt-status.st-doing{color:var(--yellow);background:rgba(231,189,53,.13)}
+.tt-status.st-done{color:var(--green);background:rgba(67,201,150,.12)}
+.tt-status.st-closed{color:var(--muted);background:var(--surface-3)}
+.tt-assignee,.tt-owner{display:inline-flex;align-items:center;gap:4px}
+.team-task .tc-acts{display:flex;gap:5px;flex:none;opacity:0;transition:opacity .15s}
+.team-task:hover .tc-acts{opacity:1}
+.team-task .tc-acts button{display:inline-flex;align-items:center;gap:5px;height:28px;padding:0 10px;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--muted);font:inherit;font-size:11.5px;font-weight:700;cursor:pointer}
+.team-task .tc-acts button:hover{color:var(--text);border-color:color-mix(in srgb,var(--primary) 45%,var(--border))}
+.team-task .tc-acts button.warn:hover{color:var(--yellow);border-color:color-mix(in srgb,var(--yellow) 45%,var(--border))}
+.team-task .tc-acts button.danger:hover{color:var(--red);border-color:color-mix(in srgb,var(--red) 45%,var(--border))}
+.team-task .tc-acts button svg{width:13px}
+.team-shared{margin-top:20px}
+/* 团队模态框 */
+.team-modal{width:min(480px,92vw)}
+.team-modal.wide{width:min(640px,94vw)}
+.team-modal-body{padding:6px 24px 20px;display:grid;gap:13px}
+.team-modal-body label{display:grid;gap:6px;font-size:12.5px;color:var(--muted);font-weight:700}
+.team-modal-body input,.team-modal-body select,.team-modal-body textarea{height:38px;border:1px solid var(--border);border-radius:10px;background:var(--surface-2);color:var(--text);padding:0 11px;outline:none;font:inherit;font-size:13px;font-weight:normal;transition:border-color .2s}
+.team-modal-body textarea{height:auto;padding:10px 11px;resize:vertical;line-height:1.6}
+.team-modal-body :is(input,select,textarea):focus{border-color:var(--primary)}
+.team-modal-body.grid2{grid-template-columns:repeat(2,minmax(0,1fr))}
+.team-modal-body .span2{grid-column:span 2}
+.team-modal .modal-actions{display:flex;justify-content:flex-end;gap:10px}
+.team-modal .modal-actions.pad{padding:0 24px 20px}
+.team-detail{padding:4px 24px 22px;display:grid;gap:14px;max-height:64vh;overflow:auto}
+.team-detail-meta{display:flex;align-items:center;gap:12px;flex-wrap:wrap;font-size:12px;color:var(--muted)}
+.team-detail-meta b{color:var(--text);font-weight:700}
+.team-detail-meta .warn{display:inline-flex;align-items:center;gap:5px;color:var(--yellow)}
+.team-detail-meta .warn svg{width:13px}
+/* 日报页 */
+.team-date-nav{display:flex;align-items:center;gap:7px}
+.team-date-nav input{height:34px;border:1px solid var(--border);border-radius:9px;background:var(--surface-2);color:var(--text);padding:0 9px;outline:none;font:inherit;font-size:12.5px}
+.team-date-nav .icon-btn{width:34px;height:34px;border:1px solid var(--border);border-radius:9px;display:grid;place-items:center;background:var(--surface-2);color:var(--muted);cursor:pointer}
+.team-date-nav .icon-btn:hover:not(:disabled){color:var(--text)}
+.team-date-nav .icon-btn:disabled{opacity:.4;cursor:default}
+.team-date-nav .icon-btn svg{width:15px}
+.team-report-mine{padding:18px 20px;margin-bottom:16px}
+.team-report-editor{width:100%;min-height:130px;border:1px solid var(--border);border-radius:12px;background:var(--surface-2);color:var(--text);padding:12px 14px;resize:vertical;outline:none;font:inherit;font-size:13px;line-height:1.65;transition:border-color .2s}
+.team-report-editor:focus{border-color:var(--primary)}
+.team-report-ops{display:flex;align-items:center;gap:10px;margin-top:12px}
+.team-report-ops .spacer{flex:1}
+.team-digest{padding:18px 20px;margin-bottom:16px}
+.team-digest .pc-head{margin-bottom:0;align-items:center}
+.team-digest .pc-head .spacer{flex:1}
+.team-digest .md-body{margin-top:14px;padding-top:14px;border-top:1px solid var(--border)}
+.team-report-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:13px}
+.team-report-card{padding:14px 16px;margin:0}
+.team-report-card header{display:flex;align-items:center;gap:9px}
+.team-report-card header b{flex:1;min-width:0;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.team-report-card header time{color:var(--muted);font-size:11px;flex:none}
+.team-report-card header em{font-style:normal;color:var(--yellow);font-size:11px;font-weight:800;flex:none}
+.team-report-card header .btn.sm{height:26px;padding:0 9px;font-size:11px;flex:none}
+.team-report-card header .btn.sm svg{width:12px}
+.trc-ava{width:26px;height:26px;flex:none;border-radius:50%;display:grid;place-items:center;background:rgba(67,201,150,.14);color:var(--green)}
+.trc-ava.miss{background:rgba(231,189,53,.13);color:var(--yellow)}
+.trc-ava svg{width:14px}
+.team-report-card .md-body{margin-top:10px;font-size:12.5px;max-height:220px;overflow:auto}
+.team-report-card .team-none{padding:10px 0 2px;text-align:left}
+.md-body.sm :is(h1,h2,h3){font-size:13.5px}
+/* 详情模态框:共享到团队选择器 */
+.share-team{display:inline-flex;align-items:center;gap:6px;margin-left:auto}
+.share-team svg{width:13px;color:var(--muted)}
+.share-team select{height:26px;border:1px solid var(--border);border-radius:8px;background:var(--surface-2);color:var(--text);padding:0 7px;outline:none;font:inherit;font-size:11.5px;max-width:150px}
diff --git a/frontend/src/store.js b/frontend/src/store.js
index d61815b..13d5451 100644
--- a/frontend/src/store.js
+++ b/frontend/src/store.js
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
-import { call, on } from './api'
+import { call, on, onTasksChanged } from './api'
export const useAppStore = defineStore('app', {
state: () => ({
@@ -8,13 +8,29 @@ export const useAppStore = defineStore('app', {
projectGroups: [],
selectedProjectGroupId: Number(localStorage.getItem('cc-project-group-id') || 0),
dashboard: { projects: 0, totalLines: 0, commits: 0 },
- settings: { theme: 'dark', locale: 'zh-CN', glassOpacity: 55, gitScope: 'current', autoRefresh: true, loadingStyle: 'fullscreen-orbit' },
+ settings: { theme: 'dark', locale: 'zh-CN', glassOpacity: 55, gitScope: 'current', autoRefresh: true, loadingStyle: 'fullscreen-orbit', imageMode: 'base64' },
tasks: {},
batchTaskIds: [],
+ batchSummary: null,
+ pendingAction: '',
+ favorites: [],
+ unreadMessages: 0,
+ // 左侧导航徽标(消息未读/待办/工单/今日到期/日历红点/团队指派)
+ badges: { unread: 0, todosOpen: 0, ticketsActive: 0, todayDue: 0, calendarDot: false, teamAssigned: 0 },
toast: null,
toastTimer: null,
toastLeaveTimer: null,
- loading: false
+ loading: false,
+ syncStatus: { configured: false, loggedIn: false, userId: 0, username: '', online: false, syncing: false },
+ avatarSrc: '',
+ loginOpen: false,
+ paletteOpen: false,
+ // 日历页"每日心语"入口:设为 'YYYY-MM-DD' 时 DailyCard 打开对应日期的卡片
+ dailyCardDate: '',
+ // 节日名 → { mode: 'photo'|'art', image: dataURL }(管理员上传,随同步分发)
+ festivalImages: {},
+ // 探测到加载失败的节日图(key → true),日历格自动回退动态插画
+ festBroken: {}
}),
actions: {
async boot() {
@@ -22,9 +38,22 @@ export const useAppStore = defineStore('app', {
if (this.bootstrap.state === 'ready') {
this.settings = await call('GetSettings')
this.applyAppearance(this.settings)
+ try { this.syncStatus = await call('GetSyncStatus') } catch {}
+ this.loadFestivalImages()
await this.refresh()
}
},
+ async loadFestivalImages() {
+ try { this.festivalImages = (await call('ListFestivalImages')) || {} } catch { return }
+ // 预探测 photo 模式的图片:解码失败的标记为 broken,展示时回退动态插画
+ for (const [key, it] of Object.entries(this.festivalImages)) {
+ if (it.mode !== 'photo' || !it.image) continue
+ const probe = new Image()
+ probe.onerror = () => { this.festBroken = { ...this.festBroken, [key]: true } }
+ probe.onload = () => { if (this.festBroken[key]) { const n = { ...this.festBroken }; delete n[key]; this.festBroken = n } }
+ probe.src = it.image
+ }
+ },
applyAppearance(settings) {
this.settings = { ...this.settings, ...settings }
let theme = this.settings.theme || 'dark'
@@ -32,6 +61,25 @@ export const useAppStore = defineStore('app', {
document.documentElement.dataset.theme = theme
document.documentElement.style.setProperty('--glass-user-opacity', String((this.settings.glassOpacity || 55) / 100))
localStorage.setItem('cc-settings', JSON.stringify(this.settings))
+ this.resolveAvatar()
+ },
+ // resolveAvatar 把设置中的头像解析为可显示的
源:path 模式需经后端读文件转 dataURL。
+ async resolveAvatar() {
+ const { avatarMode, avatarValue } = this.settings
+ if (!avatarMode || !avatarValue) { this.avatarSrc = ''; return }
+ if (avatarMode === 'path') {
+ try { this.avatarSrc = await call('ReadImageAsDataURL', avatarValue) } catch { this.avatarSrc = '' }
+ return
+ }
+ this.avatarSrc = avatarValue
+ },
+ async refreshSyncStatus() {
+ try { this.syncStatus = await call('GetSyncStatus') } catch {}
+ },
+ // openAccount 是所有登录入口的统一行为:未登录弹全屏登录层,已登录进个人主页。
+ openAccount(router) {
+ if (this.syncStatus.loggedIn) router?.push('/profile')
+ else this.loginOpen = true
},
async saveSettings(patch) {
const next = { ...this.settings, ...patch }
@@ -63,20 +111,35 @@ export const useAppStore = defineStore('app', {
},
async refresh() {
this.loading = true
+ this.refreshSyncStatus()
try {
this.projectGroups = await call('ListProjectGroups')
if (this.selectedProjectGroupId && !this.projectGroups.some(g => g.id === this.selectedProjectGroupId)) {
this.setProjectGroup(0)
}
const groupId = this.selectedProjectGroupId || 0
- ;[this.projects, this.dashboard] = await Promise.all([
+ ;[this.projects, this.dashboard, this.favorites, this.unreadMessages] = await Promise.all([
groupId ? call('ListProjectsByGroup', groupId) : call('ListProjects'),
- groupId ? call('GetDashboardByGroup', groupId) : call('GetDashboard')
+ groupId ? call('GetDashboardByGroup', groupId) : call('GetDashboard'),
+ call('ListFavorites').catch(() => []),
+ call('UnreadMessageCount').catch(() => 0)
])
} finally {
this.loading = false
}
},
+ async toggleFavorite(projectId) {
+ const fav = await call('ToggleFavorite', projectId)
+ this.favorites = fav ? [projectId, ...this.favorites.filter(x => x !== projectId)] : this.favorites.filter(x => x !== projectId)
+ return fav
+ },
+ async refreshUnread() {
+ try { this.unreadMessages = await call('UnreadMessageCount') } catch {}
+ this.refreshBadges()
+ },
+ async refreshBadges() {
+ try { this.badges = await call('GetNavBadges') } catch {}
+ },
async changeProjectGroup(groupId) {
this.setProjectGroup(groupId)
await this.refresh()
@@ -86,7 +149,7 @@ export const useAppStore = defineStore('app', {
if (persist) localStorage.setItem('cc-project-group-id', String(this.selectedProjectGroupId))
},
listen() {
- return on('analysis:progress', e => {
+ const offProgress = on('analysis:progress', e => {
delete this.tasks.__batch_pending__
this.tasks[e.taskId] = e
if (['completed', 'error', 'cancelled'].includes(e.stage)) {
@@ -100,6 +163,30 @@ export const useAppStore = defineStore('app', {
this.refresh()
}
})
+ const offBatch = on('batch:done', s => {
+ this.batchSummary = s
+ this.batchTaskIds = []
+ delete this.tasks.__batch_pending__
+ this.showToast({ type: s.failed ? 'error' : 'success', key: 'batchDoneToast', params: { completed: s.completed, total: s.total } })
+ this.refresh()
+ })
+ const offMessage = on('message:new', () => { this.unreadMessages += 1; this.refreshBadges() })
+ const offSync = on('sync:done', async st => {
+ this.syncStatus = st || this.syncStatus
+ this.refreshBadges()
+ // 拉取可能更新了设置(头像 / API Key),刷新本地副本
+ try { this.applyAppearance(await call('GetSettings')) } catch {}
+ // 拉取可能带来新项目/分组/收藏,刷新项目列表
+ if (st?.pulled > 0) {
+ this.refresh().catch(() => {})
+ this.loadFestivalImages()
+ }
+ })
+ // 徽标:启动即取,任务变更事件 + 60s 轮询兜底
+ this.refreshBadges()
+ const offTasks = onTasksChanged(() => this.refreshBadges())
+ const badgeTimer = setInterval(() => this.refreshBadges(), 60000)
+ return () => { offProgress?.(); offBatch?.(); offMessage?.(); offSync?.(); offTasks?.(); clearInterval(badgeTimer) }
},
async analyze(id, kind = 'all') {
const task = await call('StartAnalysis', id, kind)
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 1a6667b..c1b184a 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -1 +1 @@
-@font-face{font-family:Nunito;src:url('./assets/fonts/nunito-v16-latin-regular.woff2')}*{box-sizing:border-box}html{--bg:#0e141d;--side:#151b24;--surface:#19212b;--surface-2:#202838;--surface-3:#252e40;--border:#303949;--text:#f4f5f8;--muted:#929bac;--primary:#7367f5;--green:#43c996;--blue:#4f9df5;--red:#f05e68;--yellow:#e7bd35;background:var(--bg);color:var(--text);font-family:Nunito,"Segoe UI",sans-serif;letter-spacing:0}html[data-theme=light]{--bg:#f4f6fa;--side:#fff;--surface:#fff;--surface-2:#f6f7fa;--surface-3:#edf0f6;--border:#dce1ea;--text:#111827;--muted:#526071;--primary:#6557e8}body{margin:0;min-width:960px}button,input,textarea,select{font:inherit;letter-spacing:0}.shell{min-height:100vh}.sidebar{position:fixed;inset:0 auto 0 0;width:232px;background:var(--side);border-right:1px solid var(--border);padding:24px 16px;display:flex;flex-direction:column;z-index:10}.brand{height:52px;display:flex;align-items:center;gap:14px;font-size:18px;padding:0 8px}.brand-mark{display:grid;place-items:center;width:40px;height:40px;background:var(--primary);border-radius:8px;color:white}.brand-mark svg{width:23px}.sidebar nav{display:grid;gap:8px;margin-top:35px}.sidebar nav a{height:54px;display:flex;align-items:center;gap:16px;padding:0 18px;color:var(--muted);text-decoration:none;border-radius:7px;transition:background .2s,color .2s}.sidebar nav a:hover,.sidebar nav a.active{background:var(--surface-3);color:var(--text)}.sidebar nav a.active{color:#867eff}.sidebar svg{width:21px}.version{margin-top:auto;border-top:1px solid var(--border);padding:24px 8px 0;color:var(--muted);font-size:13px}.version i{display:inline-block;width:8px;height:8px;border-radius:50%;background:#26c795;margin-right:8px}main{margin-left:232px;min-height:100vh;background:radial-gradient(circle at 100% 0,rgba(91,86,192,.12),transparent 34%),var(--bg)}.page{padding:34px 42px 60px;max-width:1540px;margin:auto}.page-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:30px}.page h1{font-size:30px;margin:0 0 6px}.page-head p,.project-head p{margin:0;color:var(--muted)}.actions{display:flex;align-items:center;gap:12px}.btn{height:40px;border:1px solid var(--border);border-radius:7px;padding:0 16px;display:inline-flex;align-items:center;gap:9px;color:var(--text);background:var(--surface-2);cursor:pointer;transition:border-color .2s,background .2s}.btn svg{width:17px}.btn:hover{border-color:var(--primary)}.btn.primary{background:var(--primary);border-color:var(--primary);color:white;box-shadow:0 8px 18px rgba(115,103,245,.25)}.btn.danger{background:#ef4444;border-color:#ef4444;color:white}.stats-grid{display:grid;gap:16px;margin-bottom:30px}.stats-grid.three{grid-template-columns:repeat(3,1fr)}.stats-grid.four{grid-template-columns:repeat(4,1fr)}.stats-grid.five{grid-template-columns:repeat(5,1fr)}.stat-card{height:124px;border:1px solid var(--border);background:var(--surface);border-radius:8px;padding:23px 24px;display:flex;align-items:center;gap:16px}.stat-icon{width:48px;height:48px;border-radius:8px;display:grid;place-items:center;background:rgba(115,103,245,.18);color:#8178ff}.stat-icon.green{background:rgba(67,201,150,.16);color:var(--green)}.stat-icon.blue{background:rgba(79,157,245,.16);color:var(--blue)}.stat-icon.red{background:rgba(240,94,104,.16);color:var(--red)}.stat-icon svg{width:22px}.stat-card strong{font-size:29px;display:block}.stat-card small{display:block;color:var(--muted);margin-top:5px}.section-head{display:flex;align-items:center;justify-content:space-between}.section-head h2,.panel h2{font-size:18px;margin:0}.search{height:38px;width:280px;border:1px solid var(--border);background:var(--surface-2);border-radius:7px;display:flex;align-items:center;padding:0 13px;color:var(--muted)}.search svg{width:17px}.search input{border:0;outline:0;background:transparent;color:var(--text);width:100%;padding-left:9px}.project-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px;margin-top:20px}.project-card{min-height:280px;border:1px solid var(--border);background:var(--surface);border-radius:8px;padding:24px;cursor:pointer;transition:border-color .2s,background .2s}.project-card:hover{border-color:#555f77;background:var(--surface-2)}.project-title{display:flex;justify-content:space-between}.project-title h3{margin:0 0 5px}.project-title p{color:var(--muted);font-size:12px;margin:0;white-space:nowrap;text-overflow:ellipsis;overflow:hidden;max-width:280px}.icon-actions{display:flex}.icon-actions button,.toast button{border:0;background:transparent;color:var(--muted);cursor:pointer;padding:5px}.icon-actions svg{width:16px}.metric-row{display:grid;grid-template-columns:repeat(3,1fr);margin:27px 0 17px}.metric-row b{display:block;font-size:25px}.metric-row span{font-size:12px;color:var(--muted)}.language-bar{height:6px;border-radius:4px;overflow:hidden;display:flex;background:var(--surface-3)}.legend{display:flex;gap:14px;flex-wrap:wrap;margin-top:15px;color:var(--muted);font-size:11px;min-height:34px}.legend i{width:9px;height:9px;border-radius:50%;display:inline-block;margin-right:5px}.project-card footer{border-top:1px solid var(--border);margin-top:13px;padding-top:13px;display:flex;gap:14px;color:var(--muted);font-size:12px}.positive{color:var(--green)!important}.negative{color:var(--red)!important}.add-card{min-height:280px;border:1px dashed #3a4558;background:transparent;border-radius:8px;color:var(--muted);display:grid;place-content:center;gap:15px;cursor:pointer}.add-card span{width:48px;height:48px;background:var(--surface-2);display:grid;place-items:center;border-radius:50%;margin:auto}.overlay{position:fixed;inset:0;background:rgba(0,0,0,.66);backdrop-filter:blur(5px);z-index:30;display:grid;place-items:center}.modal{width:500px;background:var(--surface);border:1px solid var(--border);border-radius:8px}.modal header,.modal footer{padding:20px 24px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between}.modal footer{border:0;border-top:1px solid var(--border);justify-content:flex-end}.modal header h2{margin:0}.modal header button{border:0;background:transparent;color:var(--muted);font-size:25px;cursor:pointer}.modal>label{display:block;margin:20px 24px 0;font-size:13px}.modal input,.modal textarea,.rule-add input,.rule-add select,.form-panel select{width:100%;background:var(--surface-2);border:1px solid var(--border);border-radius:7px;color:var(--text);padding:10px 12px;margin-top:7px;outline:none}.modal textarea{height:80px;resize:vertical}.browse{display:flex;gap:8px}.browse input{margin-top:7px}.browse .btn{margin-top:7px;flex:none}.form-error{color:var(--red);padding:0 24px}.project-head{display:flex;align-items:center;gap:30px;background:rgba(8,12,19,.65);padding:24px;margin:-10px 0 12px}.project-head h1{font-size:25px}.back{border:0;background:transparent;color:var(--text);display:flex;align-items:center;gap:7px;cursor:pointer}.back svg{width:18px}.tabs{display:flex;gap:3px;background:var(--surface);width:max-content;padding:4px;border-radius:8px;margin:10px 0 25px}.tabs button{height:37px;padding:0 14px;border:0;background:transparent;color:var(--muted);border-radius:7px;display:flex;align-items:center;gap:8px;cursor:pointer}.tabs svg{width:18px}.tabs button.active{background:var(--primary);color:white}.split{display:grid;grid-template-columns:1fr 1fr;gap:22px;margin-bottom:22px}.panel{border:1px solid var(--border);background:var(--surface);border-radius:8px;padding:24px;margin-bottom:22px}.panel>.chart{height:310px}.language-list{height:310px;overflow:auto;margin-top:14px}.language-list>div{display:grid;grid-template-columns:1fr auto auto;gap:20px;background:var(--surface-2);padding:13px;margin-bottom:8px;border-radius:7px}.language-list i{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:8px}.language-list span{color:var(--muted)}.center-action{text-align:center}.heat-panel{height:220px}.heatmap{display:grid;grid-template-rows:repeat(7,12px);grid-auto-flow:column;grid-auto-columns:12px;gap:4px;overflow:hidden;margin-top:25px}.heatmap i{background:#2b3441;border-radius:2px}.heatmap i.l1{background:#28594b}.heatmap i.l2{background:#318266}.heatmap i.l3{background:#3cab7d}.heatmap i.l4{background:#4dd69b}.branch{height:76px;background:var(--surface-2);border-radius:7px;margin-top:10px;padding:12px 16px;display:grid;grid-template-columns:1fr auto;align-items:center}.branch span{display:flex;gap:9px;align-items:center;font-weight:bold}.branch svg{width:17px}.branch .current{color:var(--green)}.branch small{grid-column:1;color:var(--muted)}.branch code{grid-row:1/3;grid-column:2}.commit{min-height:76px;background:var(--surface-2);border-radius:7px;margin-top:10px;padding:12px;display:grid;grid-template-columns:42px 1fr auto auto;gap:9px;align-items:center}.avatar{width:36px;height:36px;border-radius:8px;background:var(--primary);color:#fff;display:grid;place-items:center;font-weight:bold}.commit div{min-width:0}.commit div b,.commit small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.commit small,.contributor small{color:var(--muted);margin-top:5px}.trend .chart{height:250px}.segments{display:flex;background:var(--surface-2);padding:3px;border-radius:7px}.segments button{border:0;background:transparent;color:var(--muted);height:31px;padding:0 13px;border-radius:5px;cursor:pointer}.segments button.active{background:var(--primary);color:white}.contributor{height:65px;background:var(--surface-2);display:grid;grid-template-columns:35px 45px 1fr auto auto auto;gap:12px;align-items:center;padding:0 15px;margin-top:8px;border-radius:7px}.contributor div strong,.contributor div small{display:block}.file-tree{height:620px;overflow:auto;margin-top:18px}.file-tree>div{height:32px;display:flex;align-items:center;gap:7px}.file-tree svg{width:15px;color:var(--yellow)}.file-tree small{margin-left:auto;color:var(--muted)}.folder-size{display:grid;grid-template-columns:1fr auto;gap:8px 20px;background:var(--surface-2);padding:14px;margin-top:10px;border-radius:7px}.folder-size span{color:var(--muted);font-size:12px}.folder-size i{height:5px;background:#3b4353;border-radius:3px}.folder-size em{display:block;height:100%;background:var(--primary);border-radius:3px}.folder-size strong{font-size:13px}.large-file{display:flex;justify-content:space-between;background:var(--surface-2);padding:14px;margin-top:10px}.large-file b,.large-file small{display:block}.large-file small{color:var(--muted);margin-top:5px}.large-file>strong{color:var(--red)}.extension-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-top:15px}.extension-grid>div{background:var(--surface-2);padding:16px;display:flex;gap:10px;justify-content:space-between}.extension-grid code{color:#8178ff}.extension-grid span{color:var(--muted)}.empty{height:100%;min-height:130px;display:grid;place-content:center;text-align:center;color:var(--muted);gap:10px}.empty svg{margin:auto;width:38px}.taskbar{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:25;width:460px;background:var(--surface-3);border:1px solid var(--border);box-shadow:0 12px 30px rgba(0,0,0,.3);padding:12px 15px;border-radius:8px;display:grid;grid-template-columns:1fr 60px;gap:7px}.taskbar div:first-child{display:flex;gap:10px}.taskbar span{color:var(--muted)}.taskbar .progress{grid-column:1/3;height:4px;background:#3b4353}.taskbar .progress i{display:block;height:100%;background:var(--primary)}.toast{position:fixed;right:24px;top:22px;z-index:40;background:var(--surface-3);border:1px solid var(--border);padding:13px 15px;border-radius:7px}.toast.success{border-color:var(--green)}.toast.error{border-color:var(--red)}.toggle{display:flex;align-items:center;gap:7px;color:var(--primary)}.log-panel{min-height:400px}.log{min-height:96px;border-left:3px solid var(--blue);background:var(--surface-2);padding:15px 16px;margin-bottom:8px;display:grid;grid-template-columns:34px 1fr auto;gap:14px}.log.warning{border-color:var(--yellow)}.log.error{border-color:var(--red)}.log>span{width:30px;height:30px;background:rgba(79,157,245,.13);display:grid;place-items:center;border-radius:6px;color:var(--blue)}.log svg{width:15px}.log small,.log b,.log code{display:block}.log small{color:var(--muted)}.log code{background:rgba(0,0,0,.22);padding:8px;margin-top:7px}.log time{color:var(--muted);font-size:12px}.settings-page{max-width:1000px}.settings-tabs{width:100%;display:grid;grid-template-columns:repeat(3,1fr)}.settings-tabs button{justify-content:center}.rule-add h2,.form-panel h2{display:flex;gap:10px;align-items:center}.rule-add h2 svg,.form-panel h2 svg{width:20px;color:var(--primary)}.rule-add>div{display:grid;grid-template-columns:1fr 240px auto;gap:15px;margin-top:20px}.rule-add input,.rule-add select{margin:0}.rule-add>small{color:var(--muted);display:block;margin-top:12px}.rule-group h2 small{color:var(--muted);font-weight:normal}.rule-group>div{display:flex;flex-wrap:wrap;gap:8px;border-top:1px solid var(--border);padding-top:15px;margin-top:15px}.rule-group button{border:0;background:var(--surface-3);color:var(--text);border-radius:7px;padding:8px 10px;display:flex;align-items:center;gap:7px;cursor:pointer}.rule-group button.builtin{cursor:default}.rule-group button small{color:#8178ff}.rule-group svg{width:13px}.form-panel label{display:grid;grid-template-columns:180px 1fr;align-items:center;margin-top:18px}.db-path{background:var(--surface-2);padding:18px;margin:20px 0}.db-path code{color:#8178ff}.migrate{height:78px}.danger-zone h2{color:var(--red);display:flex;gap:9px}.danger-zone h2 svg{width:20px}.danger-zone p{color:var(--muted)}.danger-zone>div{display:grid;grid-template-columns:repeat(3,1fr);gap:15px}.danger-zone button{border:1px solid var(--border);background:var(--surface-2);color:var(--text);padding:20px;text-align:left;display:flex;gap:14px;align-items:center;border-radius:8px;cursor:pointer}.danger-zone button.danger{border-color:#713947}.danger-zone svg{width:25px}.danger-zone b,.danger-zone small{display:block}.danger-zone small{color:var(--muted);margin-top:5px}@media(max-width:1150px){.sidebar{width:72px}.brand b,.sidebar nav span{display:none}.brand{padding:0}.sidebar nav a{justify-content:center;padding:0}.version{font-size:0}.version i{margin:0}main{margin-left:72px}.project-grid{grid-template-columns:repeat(2,1fr)}.stats-grid.five{grid-template-columns:repeat(3,1fr)}.split{grid-template-columns:1fr}.extension-grid{grid-template-columns:repeat(3,1fr)}}@media(prefers-reduced-motion:reduce){*{transition:none!important;scroll-behavior:auto!important}}
+@font-face{font-family:Nunito;src:url('./assets/fonts/nunito-v16-latin-regular.woff2')}*{box-sizing:border-box}html{--bg:#0e141d;--side:#151b24;--surface:#19212b;--surface-2:#202838;--surface-3:#252e40;--border:#303949;--text:#f4f5f8;--muted:#929bac;--primary:#7367f5;--green:#43c996;--blue:#4f9df5;--red:#f05e68;--yellow:#e7bd35;background:var(--bg);color:var(--text);font-family:Nunito,"Segoe UI",sans-serif;letter-spacing:0}html[data-theme=light]{--bg:#f4f6fa;--side:#fff;--surface:#fff;--surface-2:#f6f7fa;--surface-3:#edf0f6;--border:#dce1ea;--text:#111827;--muted:#526071;--primary:#6557e8}body{margin:0;min-width:960px}button,input,textarea,select{font:inherit;letter-spacing:0}.shell{min-height:100vh}.sidebar{position:fixed;inset:0 auto 0 0;width:232px;background:var(--side);border-right:1px solid var(--border);padding:24px 16px;display:flex;flex-direction:column;z-index:10}.brand{height:52px;display:flex;align-items:center;gap:14px;font-size:18px;padding:0 8px}.brand-mark{display:grid;place-items:center;width:40px;height:40px;background:var(--primary);border-radius:8px;color:white}.brand-mark svg{width:23px}.sidebar nav{display:grid;gap:8px;margin-top:35px}.sidebar nav a{height:54px;display:flex;align-items:center;gap:16px;padding:0 18px;color:var(--muted);text-decoration:none;border-radius:7px;transition:background .2s,color .2s}.sidebar nav a:hover,.sidebar nav a.active{background:var(--surface-3);color:var(--text)}.sidebar nav a.active{color:#867eff}.sidebar svg{width:21px}.version{margin-top:auto;border-top:1px solid var(--border);padding:24px 8px 0;color:var(--muted);font-size:13px}.version i{display:inline-block;width:8px;height:8px;border-radius:50%;background:#26c795;margin-right:8px}main{margin-left:232px;min-height:100vh;background:radial-gradient(circle at 100% 0,rgba(91,86,192,.12),transparent 34%),var(--bg)}.page{padding:34px 42px 60px;max-width:1540px;margin:auto}.page-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:30px}.page h1{font-size:30px;margin:0 0 6px}.page-head p,.project-head p{margin:0;color:var(--muted)}.actions{display:flex;align-items:center;gap:12px}.btn{height:40px;border:1px solid var(--border);border-radius:7px;padding:0 16px;display:inline-flex;align-items:center;gap:9px;color:var(--text);background:var(--surface-2);cursor:pointer;transition:border-color .2s,background .2s}.btn svg{width:17px}.btn:hover{border-color:var(--primary)}.btn.primary{background:var(--primary);border-color:var(--primary);color:white;box-shadow:0 8px 18px rgba(115,103,245,.25)}.btn.danger{background:#ef4444;border-color:#ef4444;color:white}.stats-grid{display:grid;gap:16px;margin-bottom:30px}.stats-grid.three{grid-template-columns:repeat(3,1fr)}.stats-grid.four{grid-template-columns:repeat(4,1fr)}.stats-grid.five{grid-template-columns:repeat(5,1fr)}.stat-card{height:124px;border:1px solid var(--border);background:var(--surface);border-radius:8px;padding:23px 24px;display:flex;align-items:center;gap:16px}.stat-icon{width:48px;height:48px;border-radius:8px;display:grid;place-items:center;background:rgba(115,103,245,.18);color:#8178ff}.stat-icon.green{background:rgba(67,201,150,.16);color:var(--green)}.stat-icon.blue{background:rgba(79,157,245,.16);color:var(--blue)}.stat-icon.red{background:rgba(240,94,104,.16);color:var(--red)}.stat-icon svg{width:22px}.stat-card strong{font-size:29px;display:block}.stat-card small{display:block;color:var(--muted);margin-top:5px}.section-head{display:flex;align-items:center;justify-content:space-between}.section-head h2,.panel h2{font-size:18px;margin:0}.search{height:38px;width:280px;border:1px solid var(--border);background:var(--surface-2);border-radius:7px;display:flex;align-items:center;padding:0 13px;color:var(--muted)}.search svg{width:17px}.search input{border:0;outline:0;background:transparent;color:var(--text);width:100%;padding-left:9px}.project-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px;margin-top:20px}.project-card{min-height:280px;border:1px solid var(--border);background:var(--surface);border-radius:8px;padding:24px;cursor:pointer;transition:border-color .2s,background .2s}.project-card:hover{border-color:#555f77;background:var(--surface-2)}.project-title{display:flex;justify-content:space-between}.project-title h3{margin:0 0 5px}.project-title p{color:var(--muted);font-size:12px;margin:0;white-space:nowrap;text-overflow:ellipsis;overflow:hidden;max-width:280px}.icon-actions{display:flex}.icon-actions button,.toast button{border:0;background:transparent;color:var(--muted);cursor:pointer;padding:5px}.icon-actions svg{width:16px}.metric-row{display:grid;grid-template-columns:repeat(3,1fr);margin:27px 0 17px}.metric-row b{display:block;font-size:25px}.metric-row span{font-size:12px;color:var(--muted)}.language-bar{height:6px;border-radius:4px;overflow:hidden;display:flex;background:var(--surface-3)}.legend{display:flex;gap:14px;flex-wrap:wrap;margin-top:15px;color:var(--muted);font-size:11px;min-height:34px}.legend i{width:9px;height:9px;border-radius:50%;display:inline-block;margin-right:5px}.project-card footer{border-top:1px solid var(--border);margin-top:13px;padding-top:13px;display:flex;gap:14px;color:var(--muted);font-size:12px}.positive{color:var(--green)!important}.negative{color:var(--red)!important}.add-card{min-height:280px;border:1px dashed #3a4558;background:transparent;border-radius:8px;color:var(--muted);display:grid;place-content:center;gap:15px;cursor:pointer}.add-card span{width:48px;height:48px;background:var(--surface-2);display:grid;place-items:center;border-radius:50%;margin:auto}.overlay{position:fixed;inset:0;background:rgba(0,0,0,.66);backdrop-filter:blur(5px);z-index:30;display:grid;place-items:center}.modal{width:500px;background:var(--surface);border:1px solid var(--border);border-radius:8px}.modal header,.modal footer{padding:20px 24px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between}.modal footer{border:0;border-top:1px solid var(--border);justify-content:flex-end}.modal header h2{margin:0}.modal header button{border:0;background:transparent;color:var(--muted);font-size:25px;cursor:pointer}.modal>label{display:block;margin:20px 24px 0;font-size:13px}.modal input,.modal textarea,.rule-add input,.rule-add select,.form-panel select{width:100%;background:var(--surface-2);border:1px solid var(--border);border-radius:7px;color:var(--text);padding:10px 12px;margin-top:7px;outline:none}.modal textarea{height:80px;resize:vertical}.browse{display:flex;gap:8px}.browse input{margin-top:7px}.browse .btn{margin-top:7px;flex:none}.form-error{color:var(--red);padding:0 24px}.project-head{display:flex;align-items:center;gap:30px;background:rgba(8,12,19,.65);padding:24px;margin:-10px 0 12px}.project-head h1{font-size:25px}.back{border:0;background:transparent;color:var(--text);display:flex;align-items:center;gap:7px;cursor:pointer}.back svg{width:18px}.tabs{display:flex;gap:3px;background:var(--surface);width:max-content;padding:4px;border-radius:8px;margin:10px 0 25px}.tabs button{height:37px;padding:0 14px;border:0;background:transparent;color:var(--muted);border-radius:7px;display:flex;align-items:center;gap:8px;cursor:pointer}.tabs svg{width:18px}.tabs button.active{background:var(--primary);color:white}.split{display:grid;grid-template-columns:1fr 1fr;gap:22px;margin-bottom:22px}.panel{border:1px solid var(--border);background:var(--surface);border-radius:8px;padding:24px;margin-bottom:22px}.panel>.chart{height:310px}.language-list{height:310px;overflow:auto;margin-top:14px}.language-list>div{display:grid;grid-template-columns:1fr auto auto;gap:20px;background:var(--surface-2);padding:13px;margin-bottom:8px;border-radius:7px}.language-list i{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:8px}.language-list span{color:var(--muted)}.center-action{text-align:center}.heat-panel{height:220px}.heatmap{display:grid;grid-template-rows:repeat(7,12px);grid-auto-flow:column;grid-auto-columns:12px;gap:4px;overflow:hidden;margin-top:25px}.heatmap i{background:#2b3441;border-radius:2px}.heatmap i.l1{background:#28594b}.heatmap i.l2{background:#318266}.heatmap i.l3{background:#3cab7d}.heatmap i.l4{background:#4dd69b}.branch{height:76px;background:var(--surface-2);border-radius:7px;margin-top:10px;padding:12px 16px;display:grid;grid-template-columns:1fr auto;align-items:center}.branch span{display:flex;gap:9px;align-items:center;font-weight:bold}.branch svg{width:17px}.branch .current{color:var(--green)}.branch small{grid-column:1;color:var(--muted)}.branch code{grid-row:1/3;grid-column:2}.commit{min-height:76px;background:var(--surface-2);border-radius:7px;margin-top:10px;padding:12px;display:grid;grid-template-columns:42px 1fr auto auto;gap:9px;align-items:center}.avatar{width:36px;height:36px;border-radius:8px;background:var(--primary);color:#fff;display:grid;place-items:center;font-weight:bold}.commit div{min-width:0}.commit div b,.commit small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.commit small,.contributor small{color:var(--muted);margin-top:5px}.trend .chart{height:250px}.segments{display:flex;background:var(--surface-2);padding:3px;border-radius:7px}.segments button{border:0;background:transparent;color:var(--muted);height:31px;padding:0 13px;border-radius:5px;cursor:pointer}.segments button.active{background:var(--primary);color:white}.contributor{height:65px;background:var(--surface-2);display:grid;grid-template-columns:35px 45px 1fr auto auto auto;gap:12px;align-items:center;padding:0 15px;margin-top:8px;border-radius:7px}.contributor div strong,.contributor div small{display:block}.file-tree{height:620px;overflow:auto;margin-top:18px}.file-tree>div{height:32px;display:flex;align-items:center;gap:7px}.file-tree svg{width:15px;color:var(--yellow)}.file-tree small{margin-left:auto;color:var(--muted)}.folder-size{display:grid;grid-template-columns:1fr auto;gap:8px 20px;background:var(--surface-2);padding:14px;margin-top:10px;border-radius:7px}.folder-size span{color:var(--muted);font-size:12px}.folder-size i{height:5px;background:#3b4353;border-radius:3px}.folder-size em{display:block;height:100%;background:var(--primary);border-radius:3px}.folder-size strong{font-size:13px}.large-file{display:flex;justify-content:space-between;background:var(--surface-2);padding:14px;margin-top:10px}.large-file b,.large-file small{display:block}.large-file small{color:var(--muted);margin-top:5px}.large-file>strong{color:var(--red)}.extension-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-top:15px}.extension-grid>div{background:var(--surface-2);padding:16px;display:flex;gap:10px;justify-content:space-between}.extension-grid code{color:#8178ff}.extension-grid span{color:var(--muted)}.empty{height:100%;min-height:130px;display:grid;place-content:center;text-align:center;color:var(--muted);gap:10px}.empty svg{margin:auto;width:38px}.taskbar{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:25;width:460px;background:var(--surface-3);border:1px solid var(--border);box-shadow:0 12px 30px rgba(0,0,0,.3);padding:12px 15px;border-radius:8px;display:grid;grid-template-columns:1fr 60px;gap:7px}.taskbar div:first-child{display:flex;gap:10px}.taskbar span{color:var(--muted)}.taskbar .progress{grid-column:1/3;height:4px;background:#3b4353}.taskbar .progress i{display:block;height:100%;background:var(--primary)}.toast{position:fixed;right:24px;top:22px;z-index:40;background:var(--surface-3);border:1px solid var(--border);padding:13px 15px;border-radius:7px}.toast.success{border-color:var(--green)}.toast.error{border-color:var(--red)}.toggle{display:flex;align-items:center;gap:7px;color:var(--primary)}.log-panel{min-height:400px}.log{min-height:96px;border-left:3px solid var(--blue);background:var(--surface-2);padding:15px 16px;margin-bottom:8px;display:grid;grid-template-columns:34px 1fr auto;gap:14px}.log.warning{border-color:var(--yellow)}.log.error{border-color:var(--red)}.log>span{width:30px;height:30px;background:rgba(79,157,245,.13);display:grid;place-items:center;border-radius:6px;color:var(--blue)}.log svg{width:15px}.log small,.log b,.log code{display:block}.log small{color:var(--muted)}.log code{background:rgba(0,0,0,.22);padding:8px;margin-top:7px}.log time{color:var(--muted);font-size:12px}.settings-page{max-width:1000px}.settings-tabs{width:100%;display:grid;grid-auto-flow:column;grid-auto-columns:1fr}.settings-tabs button{justify-content:center}.rule-add h2,.form-panel h2{display:flex;gap:10px;align-items:center}.rule-add h2 svg,.form-panel h2 svg{width:20px;color:var(--primary)}.rule-add>div{display:grid;grid-template-columns:1fr 240px auto;gap:15px;margin-top:20px}.rule-add input,.rule-add select{margin:0}.rule-add>small{color:var(--muted);display:block;margin-top:12px}.rule-group h2 small{color:var(--muted);font-weight:normal}.rule-group>div{display:flex;flex-wrap:wrap;gap:8px;border-top:1px solid var(--border);padding-top:15px;margin-top:15px}.rule-group button{border:0;background:var(--surface-3);color:var(--text);border-radius:7px;padding:8px 10px;display:flex;align-items:center;gap:7px;cursor:pointer}.rule-group button.builtin{cursor:default}.rule-group button small{color:#8178ff}.rule-group svg{width:13px}.form-panel label{display:grid;grid-template-columns:180px 1fr;align-items:center;margin-top:18px}.db-path{background:var(--surface-2);padding:18px;margin:20px 0}.db-path code{color:#8178ff}.migrate{height:78px}.danger-zone h2{color:var(--red);display:flex;gap:9px}.danger-zone h2 svg{width:20px}.danger-zone p{color:var(--muted)}.danger-zone>div{display:grid;grid-template-columns:repeat(3,1fr);gap:15px}.danger-zone button{border:1px solid var(--border);background:var(--surface-2);color:var(--text);padding:20px;text-align:left;display:flex;gap:14px;align-items:center;border-radius:8px;cursor:pointer}.danger-zone button.danger{border-color:#713947}.danger-zone svg{width:25px}.danger-zone b,.danger-zone small{display:block}.danger-zone small{color:var(--muted);margin-top:5px}@media(max-width:1150px){.sidebar{width:72px}.brand b,.sidebar nav span{display:none}.brand{padding:0}.sidebar nav a{justify-content:center;padding:0}.version{font-size:0}.version i{margin:0}main{margin-left:72px}.project-grid{grid-template-columns:repeat(2,1fr)}.stats-grid.five{grid-template-columns:repeat(3,1fr)}.split{grid-template-columns:1fr}.extension-grid{grid-template-columns:repeat(3,1fr)}}@media(prefers-reduced-motion:reduce){*{transition:none!important;scroll-behavior:auto!important}}
diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue
index ab85430..3e6a991 100644
--- a/frontend/src/views/Dashboard.vue
+++ b/frontend/src/views/Dashboard.vue
@@ -1,11 +1,12 @@
- {{ t('dashboard') }}
{{ t('dashboardSubtitle') }}
+ {{ t('projects') }}
{{ t('dashboardSubtitle') }}
-
-
-
-
+
+
+ {{ t('cloudPendingTitle') }}{{ cloudPending.length }}
+ {{ t('cloudPendingDesc') }}
+
+
+
+
+ {{ it.name }}
+ {{ [it.group, it.description].filter(Boolean).join(' · ') }}
+
+
+
+
+
+
+
+
+ {{ t('statsOverview') }}
+
+
+
+
+
+
+
+
+
+
{{ filteredDashboard.projects }}{{ t('totalProjects') }}
+
{{ fmt(filteredDashboard.totalLines) }}{{ t('totalLines') }}
+
{{ fmt(filteredDashboard.commits) }}{{ t('commits') }}
+
+ {{ x.name }} {{ langTotal ? Math.round(x.code / langTotal * 100) : 0 }}%
+
+
+
+
+
+ {{ t('batchSummaryTitle') }}
+
+
+
+ {{ t('batchTotal', { n: store.batchSummary.total }) }}
+ {{ store.batchSummary.completed }} {{ t('batchOk') }}
+ {{ store.batchSummary.failed }} {{ t('batchFail') }}
+ {{ store.batchSummary.cancelled }} {{ t('batchCancelled') }}
+
+
+
+
+
+ {{ t('languageDistribution') }}{{ store.selectedProjectGroupId ? groupLabel(store.projectGroups.find(g => g.id === store.selectedProjectGroupId)) : t('allProjectGroups') }}
+
+
+
+ {{ t('languageDetails') }}{{ fmt(langTotal) }} {{ t('lines') }}
+
+
+
+ {{ x.name }}
+ {{ fmt(x.files) }} {{ t('files') }}
+
+ {{ fmt(x.code) }}
+ {{ langTotal ? Math.round(x.code / langTotal * 100) : 0 }}%
+
+
+
{{ t('projects') }}
@@ -185,10 +353,11 @@ async function refreshProject(p) {
-
+
{{ p.name }}
{{ p.groupId === 1 ? t('myProjectGroup') : (p.groupName || t('projectGroup')) }}{{ p.path }}
+
@@ -213,19 +382,27 @@ async function refreshProject(p) {
@@ -236,5 +413,14 @@ async function refreshProject(p) {
+
diff --git a/frontend/src/views/Logs.vue b/frontend/src/views/Logs.vue
index 8d890a8..6d06757 100644
--- a/frontend/src/views/Logs.vue
+++ b/frontend/src/views/Logs.vue
@@ -1,47 +1,176 @@
-
+
-
-
+
+
+
+
+
+
+
- {{ x.category }}{{ x.message }}{{ x.detail }}
- {{ t('noLogs') }}
+
+
+ {{ dayLabel(g.date) }}
+
+
+
+ {{ x.detail }}
+
+
+
+
+
+ {{ t('noLogs') }}{{ t('noLogsHint') }}
+
+
diff --git a/frontend/src/views/ProjectDetail.vue b/frontend/src/views/ProjectDetail.vue
index 45b6b6f..687ea5b 100644
--- a/frontend/src/views/ProjectDetail.vue
+++ b/frontend/src/views/ProjectDetail.vue
@@ -2,12 +2,15 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
-import { ArrowLeft, Code2, GitCommitHorizontal, FolderTree, RefreshCw, Files, MessageSquareText, Rows3, Users, Plus, Minus, HardDrive, Folder, FileWarning, GitBranch, ExternalLink, TriangleAlert, ClipboardCheck } from 'lucide-vue-next'
+import { ArrowLeft, Code2, GitCommitHorizontal, FolderTree, RefreshCw, Files, MessageSquareText, Rows3, Users, Plus, Minus, HardDrive, Folder, FileWarning, GitBranch, ExternalLink, TriangleAlert, ClipboardCheck, Flame, Sparkles, MessageCircleQuestion } from 'lucide-vue-next'
import StatCard from '../components/StatCard.vue'
import ChartView from '../components/ChartView.vue'
import GitHeatmap from '../components/GitHeatmap.vue'
import GitTrend from '../components/GitTrend.vue'
import CommitDrawer from '../components/CommitDrawer.vue'
+import FilePreviewModal from '../components/FilePreviewModal.vue'
+import AIBrief from '../components/AIBrief.vue'
+import ProjectAIDrawer from '../components/ProjectAIDrawer.vue'
import { call } from '../api'
import { useAppStore } from '../store'
@@ -29,8 +32,21 @@ const issueSeverity = ref('all')
const issueType = ref('all')
const detail = ref(null)
const detailLoading = ref(false)
+const preview = ref('')
+const previewLine = ref(0)
+// AI 问答抽屉:详情页内的“二级页面”,不跳转
+const aiDrawer = ref(false)
+const aiAsk = ref('')
+const askText = ref('')
+// 这些检查项的 path 是真实源码文件(folder_concentration 是目录、large_commit 是提交哈希,不可预览)
+const fileIssueTypes = ['large_file', 'long_file', 'todo_marker']
const colors = ['#7b73ff', '#4fd1a1', '#4da5ff', '#f4c84a', '#ef6683', '#23b5d3']
+function openPreview(path, line = 0) {
+ previewLine.value = line
+ preview.value = path
+}
+
const fmt = n => {
n = +n || 0
return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'K' : n.toString()
@@ -101,6 +117,11 @@ async function showCommit(c) {
detail.value = { ...c, files: [] }
try { detail.value = await call('GetCommitDetails', +route.params.id, c.hash) } finally { detailLoading.value = false }
}
+function openChat(text) {
+ aiAsk.value = (text || '').trim()
+ askText.value = ''
+ aiDrawer.value = true
+}
onMounted(load)
@@ -108,14 +129,16 @@ onMounted(load)
@@ -156,6 +179,19 @@ onMounted(load)
+
+ {{ t('fileHotspots') }}{{ t('fileHotspotsHint') }}
+
+
+
+
{{ t('contributorRanking') }}
#{{ i + 1 }}{{ c.name?.[0] }}{{ c.name }}{{ c.email }}
{{ c.commits }} {{ t('commitsUnit') }}+{{ fmt(c.added) }}-{{ fmt(c.deleted) }} {{ t('empty') }}
@@ -168,12 +204,12 @@ onMounted(load)
-
{{ t('directoryStructure') }}
{{ f.name }}{{ f.isDir ? '' : bytes(f.size) }}
+
{{ t('directoryStructure') }}
{{ f.name }}{{ f.isDir ? '' : bytes(f.size) }}
{{ t('folderSize') }}
{{ f.name }}{{ f.files }} {{ t('files') }}{{ bytes(f.size) }}
- {{ t('largeFileDetection') }}
{{ f.name }}{{ f.path }}
{{ bytes(f.size) }}{{ t('noLargeFiles') }}
+ {{ t('largeFileDetection') }}
{{ f.name }}{{ f.path }}
{{ bytes(f.size) }}{{ t('noLargeFiles') }}
-
+
{{ insights.healthScore || 0 }}{{ t('healthScore') }}
@@ -199,12 +235,27 @@ onMounted(load)
{{ t('severity.' + issue.severity) }}
- {{ issue.title }}
{{ issue.detail }}
{{ issue.path }}:{{ issue.line }}{{ issue.evidence }}{{ issue.suggestion }}
+ {{ issue.title }}
{{ issue.detail }}
{{ issue.path }}:{{ issue.line }}{{ issue.evidence }}{{ issue.suggestion }}
{{ t('noIssues') }}
+
+
+
+
+
+
diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue
index 89c6a01..bc332e5 100644
--- a/frontend/src/views/Settings.vue
+++ b/frontend/src/views/Settings.vue
@@ -1,19 +1,31 @@
-
-
-
添加排除规则
支持 * 通配符;目录名会在任意层级匹配{{name}} {{items.length}} 条规则
-
-
数据库位置
已连接当前是浏览器预览模式浏览器无法访问本地数据库和文件选择器,请运行 code-count.exe 使用数据库功能。
当前位置{{settings.databasePath||'未获取到数据库路径'}}
{{dbMessage}}
- 清空数据
选择要清空的数据类型,此操作不可恢复。
+
{{t('settings')}} · {{t(TAB_TITLE[tab])}}
{{t('settingsSubtitle')}}
+
+
+
{{t('addRule')}}
{{t('ruleHint')}}{{name}} {{t('rulesCount',{n:items.length})}}
+
+
+
+
+
+
+
+
+
+
{{t('dbLocation')}}
{{t('dbConnected')}}{{t('previewModeTitle')}}{{t('previewModeDb')}}
{{t('dbCurrentLoc')}}{{settings.databasePath||t('dbNoPath')}}
{{dbMessage}}
+ {{t('dangerZone')}}
{{t('dangerDesc')}}
+
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts
deleted file mode 100644
index 09283ec..0000000
--- a/frontend/wailsjs/go/main/App.d.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
-// This file is automatically generated. DO NOT EDIT
-import {model} from '../models';
-import {main} from '../models';
-
-export function AddRule(arg1:string,arg2:string):Promise;
-
-export function CancelAnalysis(arg1:string):Promise;
-
-export function CheckoutBranch(arg1:number,arg2:string):Promise;
-
-export function ClearData(arg1:string,arg2:number):Promise;
-
-export function ClearLogs():Promise;
-
-export function DeleteProject(arg1:number):Promise;
-
-export function DeleteProjectGroup(arg1:number):Promise;
-
-export function DeleteRule(arg1:number):Promise;
-
-export function GetBootstrapStatus():Promise;
-
-export function GetCommitDetails(arg1:number,arg2:string):Promise;
-
-export function GetDashboard():Promise;
-
-export function GetDashboardByGroup(arg1:number):Promise;
-
-export function GetGitDiagnostics(arg1:number):Promise;
-
-export function GetGitStats(arg1:number):Promise;
-
-export function GetGitStatsForRef(arg1:number,arg2:string):Promise;
-
-export function GetLogs(arg1:string):Promise>;
-
-export function GetProject(arg1:number):Promise;
-
-export function GetProjectInsights(arg1:number):Promise;
-
-export function GetRules():Promise>;
-
-export function GetSettings():Promise;
-
-export function GetStructure(arg1:number):Promise;
-
-export function InitializeDatabase(arg1:string):Promise;
-
-export function ListProjectGroups():Promise>;
-
-export function ListProjects():Promise>;
-
-export function ListProjectsByGroup(arg1:number):Promise>;
-
-export function ListWSLDistros():Promise>;
-
-export function MigrateDatabase(arg1:string):Promise;
-
-export function RefreshProjectInsights(arg1:number):Promise;
-
-export function ReportClientError(arg1:string,arg2:string,arg3:string):Promise;
-
-export function RetryDatabase():Promise;
-
-export function SaveProject(arg1:number,arg2:model.ProjectInput):Promise;
-
-export function SaveProjectGroup(arg1:number,arg2:string):Promise;
-
-export function SaveSettings(arg1:model.AppSettings):Promise;
-
-export function SelectDatabaseFile():Promise;
-
-export function SelectDirectory():Promise;
-
-export function SelectInitialDatabaseFile(arg1:string):Promise;
-
-export function SelectWSLDirectory(arg1:string):Promise;
-
-export function StartAnalysis(arg1:number,arg2:string):Promise;
-
-export function StartBatchAnalysis():Promise>;
-
-export function StartBatchAnalysisByGroup(arg1:number):Promise>;
diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js
deleted file mode 100644
index 3889084..0000000
--- a/frontend/wailsjs/go/main/App.js
+++ /dev/null
@@ -1,163 +0,0 @@
-// @ts-check
-// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
-// This file is automatically generated. DO NOT EDIT
-
-export function AddRule(arg1, arg2) {
- return window['go']['main']['App']['AddRule'](arg1, arg2);
-}
-
-export function CancelAnalysis(arg1) {
- return window['go']['main']['App']['CancelAnalysis'](arg1);
-}
-
-export function CheckoutBranch(arg1, arg2) {
- return window['go']['main']['App']['CheckoutBranch'](arg1, arg2);
-}
-
-export function ClearData(arg1, arg2) {
- return window['go']['main']['App']['ClearData'](arg1, arg2);
-}
-
-export function ClearLogs() {
- return window['go']['main']['App']['ClearLogs']();
-}
-
-export function DeleteProject(arg1) {
- return window['go']['main']['App']['DeleteProject'](arg1);
-}
-
-export function DeleteProjectGroup(arg1) {
- return window['go']['main']['App']['DeleteProjectGroup'](arg1);
-}
-
-export function DeleteRule(arg1) {
- return window['go']['main']['App']['DeleteRule'](arg1);
-}
-
-export function GetBootstrapStatus() {
- return window['go']['main']['App']['GetBootstrapStatus']();
-}
-
-export function GetCommitDetails(arg1, arg2) {
- return window['go']['main']['App']['GetCommitDetails'](arg1, arg2);
-}
-
-export function GetDashboard() {
- return window['go']['main']['App']['GetDashboard']();
-}
-
-export function GetDashboardByGroup(arg1) {
- return window['go']['main']['App']['GetDashboardByGroup'](arg1);
-}
-
-export function GetGitDiagnostics(arg1) {
- return window['go']['main']['App']['GetGitDiagnostics'](arg1);
-}
-
-export function GetGitStats(arg1) {
- return window['go']['main']['App']['GetGitStats'](arg1);
-}
-
-export function GetGitStatsForRef(arg1, arg2) {
- return window['go']['main']['App']['GetGitStatsForRef'](arg1, arg2);
-}
-
-export function GetLogs(arg1) {
- return window['go']['main']['App']['GetLogs'](arg1);
-}
-
-export function GetProject(arg1) {
- return window['go']['main']['App']['GetProject'](arg1);
-}
-
-export function GetProjectInsights(arg1) {
- return window['go']['main']['App']['GetProjectInsights'](arg1);
-}
-
-export function GetRules() {
- return window['go']['main']['App']['GetRules']();
-}
-
-export function GetSettings() {
- return window['go']['main']['App']['GetSettings']();
-}
-
-export function GetStructure(arg1) {
- return window['go']['main']['App']['GetStructure'](arg1);
-}
-
-export function InitializeDatabase(arg1) {
- return window['go']['main']['App']['InitializeDatabase'](arg1);
-}
-
-export function ListProjectGroups() {
- return window['go']['main']['App']['ListProjectGroups']();
-}
-
-export function ListProjects() {
- return window['go']['main']['App']['ListProjects']();
-}
-
-export function ListProjectsByGroup(arg1) {
- return window['go']['main']['App']['ListProjectsByGroup'](arg1);
-}
-
-export function ListWSLDistros() {
- return window['go']['main']['App']['ListWSLDistros']();
-}
-
-export function MigrateDatabase(arg1) {
- return window['go']['main']['App']['MigrateDatabase'](arg1);
-}
-
-export function RefreshProjectInsights(arg1) {
- return window['go']['main']['App']['RefreshProjectInsights'](arg1);
-}
-
-export function ReportClientError(arg1, arg2, arg3) {
- return window['go']['main']['App']['ReportClientError'](arg1, arg2, arg3);
-}
-
-export function RetryDatabase() {
- return window['go']['main']['App']['RetryDatabase']();
-}
-
-export function SaveProject(arg1, arg2) {
- return window['go']['main']['App']['SaveProject'](arg1, arg2);
-}
-
-export function SaveProjectGroup(arg1, arg2) {
- return window['go']['main']['App']['SaveProjectGroup'](arg1, arg2);
-}
-
-export function SaveSettings(arg1) {
- return window['go']['main']['App']['SaveSettings'](arg1);
-}
-
-export function SelectDatabaseFile() {
- return window['go']['main']['App']['SelectDatabaseFile']();
-}
-
-export function SelectDirectory() {
- return window['go']['main']['App']['SelectDirectory']();
-}
-
-export function SelectInitialDatabaseFile(arg1) {
- return window['go']['main']['App']['SelectInitialDatabaseFile'](arg1);
-}
-
-export function SelectWSLDirectory(arg1) {
- return window['go']['main']['App']['SelectWSLDirectory'](arg1);
-}
-
-export function StartAnalysis(arg1, arg2) {
- return window['go']['main']['App']['StartAnalysis'](arg1, arg2);
-}
-
-export function StartBatchAnalysis() {
- return window['go']['main']['App']['StartBatchAnalysis']();
-}
-
-export function StartBatchAnalysisByGroup(arg1) {
- return window['go']['main']['App']['StartBatchAnalysisByGroup'](arg1);
-}
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
deleted file mode 100644
index 0ceefee..0000000
--- a/frontend/wailsjs/go/models.ts
+++ /dev/null
@@ -1,663 +0,0 @@
-export namespace main {
-
- export class BootstrapStatus {
- state: string;
- databasePath: string;
- defaultPath: string;
- errorCode?: string;
- errorDetail?: string;
-
- static createFrom(source: any = {}) {
- return new BootstrapStatus(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.state = source["state"];
- this.databasePath = source["databasePath"];
- this.defaultPath = source["defaultPath"];
- this.errorCode = source["errorCode"];
- this.errorDetail = source["errorDetail"];
- }
- }
-
-}
-
-export namespace model {
-
- export class AppSettings {
- theme: string;
- locale: string;
- gitScope: string;
- databasePath: string;
- autoRefresh: boolean;
- glassOpacity: number;
- loadingStyle: string;
-
- static createFrom(source: any = {}) {
- return new AppSettings(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.theme = source["theme"];
- this.locale = source["locale"];
- this.gitScope = source["gitScope"];
- this.databasePath = source["databasePath"];
- this.autoRefresh = source["autoRefresh"];
- this.glassOpacity = source["glassOpacity"];
- this.loadingStyle = source["loadingStyle"];
- }
- }
- export class CheckoutResult {
- branch: string;
- message: string;
-
- static createFrom(source: any = {}) {
- return new CheckoutResult(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.branch = source["branch"];
- this.message = source["message"];
- }
- }
- export class Contributor {
- name: string;
- email: string;
- commits: number;
- added: number;
- deleted: number;
-
- static createFrom(source: any = {}) {
- return new Contributor(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.name = source["name"];
- this.email = source["email"];
- this.commits = source["commits"];
- this.added = source["added"];
- this.deleted = source["deleted"];
- }
- }
- export class Dashboard {
- projects: number;
- totalLines: number;
- commits: number;
-
- static createFrom(source: any = {}) {
- return new Dashboard(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.projects = source["projects"];
- this.totalLines = source["totalLines"];
- this.commits = source["commits"];
- }
- }
- export class ExclusionRule {
- id: number;
- pattern: string;
- category: string;
- builtin: boolean;
-
- static createFrom(source: any = {}) {
- return new ExclusionRule(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.id = source["id"];
- this.pattern = source["pattern"];
- this.category = source["category"];
- this.builtin = source["builtin"];
- }
- }
- export class ExtensionStat {
- extension: string;
- files: number;
- size: number;
-
- static createFrom(source: any = {}) {
- return new ExtensionStat(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.extension = source["extension"];
- this.files = source["files"];
- this.size = source["size"];
- }
- }
- export class FileEntry {
- path: string;
- name: string;
- extension: string;
- size: number;
- isDir: boolean;
- parent: string;
-
- static createFrom(source: any = {}) {
- return new FileEntry(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.path = source["path"];
- this.name = source["name"];
- this.extension = source["extension"];
- this.size = source["size"];
- this.isDir = source["isDir"];
- this.parent = source["parent"];
- }
- }
- export class FolderStat {
- name: string;
- files: number;
- size: number;
-
- static createFrom(source: any = {}) {
- return new FolderStat(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.name = source["name"];
- this.files = source["files"];
- this.size = source["size"];
- }
- }
- export class GitCommit {
- hash: string;
- author: string;
- email: string;
- message: string;
- date: string;
- added: number;
- deleted: number;
-
- static createFrom(source: any = {}) {
- return new GitCommit(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.hash = source["hash"];
- this.author = source["author"];
- this.email = source["email"];
- this.message = source["message"];
- this.date = source["date"];
- this.added = source["added"];
- this.deleted = source["deleted"];
- }
- }
- export class GitFileChange {
- path: string;
- status: string;
- added: number;
- deleted: number;
-
- static createFrom(source: any = {}) {
- return new GitFileChange(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.path = source["path"];
- this.status = source["status"];
- this.added = source["added"];
- this.deleted = source["deleted"];
- }
- }
- export class GitCommitDetail {
- hash: string;
- author: string;
- email: string;
- message: string;
- date: string;
- added: number;
- deleted: number;
- files: GitFileChange[];
-
- static createFrom(source: any = {}) {
- return new GitCommitDetail(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.hash = source["hash"];
- this.author = source["author"];
- this.email = source["email"];
- this.message = source["message"];
- this.date = source["date"];
- this.added = source["added"];
- this.deleted = source["deleted"];
- this.files = this.convertValues(source["files"], GitFileChange);
- }
-
- convertValues(a: any, classs: any, asMap: boolean = false): any {
- if (!a) {
- return a;
- }
- if (a.slice && a.map) {
- return (a as any[]).map(elem => this.convertValues(elem, classs));
- } else if ("object" === typeof a) {
- if (asMap) {
- for (const key of Object.keys(a)) {
- a[key] = new classs(a[key]);
- }
- return a;
- }
- return new classs(a);
- }
- return a;
- }
- }
- export class GitDiagnostics {
- available: boolean;
- errorCode?: string;
- detail?: string;
- isWsl: boolean;
- distro?: string;
- linuxPath?: string;
- workspaceBranch?: string;
- viewRef?: string;
- refCount: number;
-
- static createFrom(source: any = {}) {
- return new GitDiagnostics(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.available = source["available"];
- this.errorCode = source["errorCode"];
- this.detail = source["detail"];
- this.isWsl = source["isWsl"];
- this.distro = source["distro"];
- this.linuxPath = source["linuxPath"];
- this.workspaceBranch = source["workspaceBranch"];
- this.viewRef = source["viewRef"];
- this.refCount = source["refCount"];
- }
- }
-
- export class GitRef {
- name: string;
- hash: string;
- kind: string;
- current: boolean;
-
- static createFrom(source: any = {}) {
- return new GitRef(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.name = source["name"];
- this.hash = source["hash"];
- this.kind = source["kind"];
- this.current = source["current"];
- }
- }
- export class HeatDay {
- date: string;
- count: number;
-
- static createFrom(source: any = {}) {
- return new HeatDay(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.date = source["date"];
- this.count = source["count"];
- }
- }
- export class GitStats {
- available: boolean;
- error?: string;
- currentBranch: string;
- workspaceBranch: string;
- viewRef: string;
- commitCount: number;
- added: number;
- deleted: number;
- contributorCount: number;
- commits: GitCommit[];
- refs: GitRef[];
- contributors: Contributor[];
- heatmap: HeatDay[];
-
- static createFrom(source: any = {}) {
- return new GitStats(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.available = source["available"];
- this.error = source["error"];
- this.currentBranch = source["currentBranch"];
- this.workspaceBranch = source["workspaceBranch"];
- this.viewRef = source["viewRef"];
- this.commitCount = source["commitCount"];
- this.added = source["added"];
- this.deleted = source["deleted"];
- this.contributorCount = source["contributorCount"];
- this.commits = this.convertValues(source["commits"], GitCommit);
- this.refs = this.convertValues(source["refs"], GitRef);
- this.contributors = this.convertValues(source["contributors"], Contributor);
- this.heatmap = this.convertValues(source["heatmap"], HeatDay);
- }
-
- convertValues(a: any, classs: any, asMap: boolean = false): any {
- if (!a) {
- return a;
- }
- if (a.slice && a.map) {
- return (a as any[]).map(elem => this.convertValues(elem, classs));
- } else if ("object" === typeof a) {
- if (asMap) {
- for (const key of Object.keys(a)) {
- a[key] = new classs(a[key]);
- }
- return a;
- }
- return new classs(a);
- }
- return a;
- }
- }
-
- export class InsightIssue {
- severity: string;
- type: string;
- title: string;
- detail: string;
- path?: string;
- line?: number;
- suggestion: string;
- evidence?: string;
-
- static createFrom(source: any = {}) {
- return new InsightIssue(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.severity = source["severity"];
- this.type = source["type"];
- this.title = source["title"];
- this.detail = source["detail"];
- this.path = source["path"];
- this.line = source["line"];
- this.suggestion = source["suggestion"];
- this.evidence = source["evidence"];
- }
- }
- export class InsightSummary {
- high: number;
- medium: number;
- low: number;
- todoCount: number;
- longFiles: number;
- largeFiles: number;
-
- static createFrom(source: any = {}) {
- return new InsightSummary(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.high = source["high"];
- this.medium = source["medium"];
- this.low = source["low"];
- this.todoCount = source["todoCount"];
- this.longFiles = source["longFiles"];
- this.largeFiles = source["largeFiles"];
- }
- }
- export class LanguageStat {
- name: string;
- files: number;
- code: number;
- comments: number;
- blanks: number;
-
- static createFrom(source: any = {}) {
- return new LanguageStat(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.name = source["name"];
- this.files = source["files"];
- this.code = source["code"];
- this.comments = source["comments"];
- this.blanks = source["blanks"];
- }
- }
- export class LogEntry {
- id: number;
- level: string;
- category: string;
- message: string;
- detail: string;
- createdAt: string;
-
- static createFrom(source: any = {}) {
- return new LogEntry(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.id = source["id"];
- this.level = source["level"];
- this.category = source["category"];
- this.message = source["message"];
- this.detail = source["detail"];
- this.createdAt = source["createdAt"];
- }
- }
- export class ProjectStats {
- totalLines: number;
- codeLines: number;
- commentLines: number;
- blankLines: number;
- fileCount: number;
- commitCount: number;
- addedLines: number;
- deletedLines: number;
- contributorCount: number;
- lastAnalyzed: string;
-
- static createFrom(source: any = {}) {
- return new ProjectStats(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.totalLines = source["totalLines"];
- this.codeLines = source["codeLines"];
- this.commentLines = source["commentLines"];
- this.blankLines = source["blankLines"];
- this.fileCount = source["fileCount"];
- this.commitCount = source["commitCount"];
- this.addedLines = source["addedLines"];
- this.deletedLines = source["deletedLines"];
- this.contributorCount = source["contributorCount"];
- this.lastAnalyzed = source["lastAnalyzed"];
- }
- }
- export class Project {
- id: number;
- name: string;
- path: string;
- description: string;
- groupId: number;
- groupName: string;
- createdAt: string;
- updatedAt: string;
- stats: ProjectStats;
- languages: LanguageStat[];
-
- static createFrom(source: any = {}) {
- return new Project(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.id = source["id"];
- this.name = source["name"];
- this.path = source["path"];
- this.description = source["description"];
- this.groupId = source["groupId"];
- this.groupName = source["groupName"];
- this.createdAt = source["createdAt"];
- this.updatedAt = source["updatedAt"];
- this.stats = this.convertValues(source["stats"], ProjectStats);
- this.languages = this.convertValues(source["languages"], LanguageStat);
- }
-
- convertValues(a: any, classs: any, asMap: boolean = false): any {
- if (!a) {
- return a;
- }
- if (a.slice && a.map) {
- return (a as any[]).map(elem => this.convertValues(elem, classs));
- } else if ("object" === typeof a) {
- if (asMap) {
- for (const key of Object.keys(a)) {
- a[key] = new classs(a[key]);
- }
- return a;
- }
- return new classs(a);
- }
- return a;
- }
- }
- export class ProjectGroup {
- id: number;
- name: string;
- createdAt: string;
- updatedAt: string;
-
- static createFrom(source: any = {}) {
- return new ProjectGroup(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.id = source["id"];
- this.name = source["name"];
- this.createdAt = source["createdAt"];
- this.updatedAt = source["updatedAt"];
- }
- }
- export class ProjectInput {
- name: string;
- path: string;
- description: string;
- groupId: number;
-
- static createFrom(source: any = {}) {
- return new ProjectInput(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.name = source["name"];
- this.path = source["path"];
- this.description = source["description"];
- this.groupId = source["groupId"];
- }
- }
- export class ProjectInsights {
- projectId: number;
- healthScore: number;
- generatedAt: string;
- summary: InsightSummary;
- issues: InsightIssue[];
-
- static createFrom(source: any = {}) {
- return new ProjectInsights(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.projectId = source["projectId"];
- this.healthScore = source["healthScore"];
- this.generatedAt = source["generatedAt"];
- this.summary = this.convertValues(source["summary"], InsightSummary);
- this.issues = this.convertValues(source["issues"], InsightIssue);
- }
-
- convertValues(a: any, classs: any, asMap: boolean = false): any {
- if (!a) {
- return a;
- }
- if (a.slice && a.map) {
- return (a as any[]).map(elem => this.convertValues(elem, classs));
- } else if ("object" === typeof a) {
- if (asMap) {
- for (const key of Object.keys(a)) {
- a[key] = new classs(a[key]);
- }
- return a;
- }
- return new classs(a);
- }
- return a;
- }
- }
-
- export class StructureStats {
- files: FileEntry[];
- totalFiles: number;
- totalDirs: number;
- totalSize: number;
- largeFiles: FileEntry[];
- folders: FolderStat[];
- extensions: ExtensionStat[];
-
- static createFrom(source: any = {}) {
- return new StructureStats(source);
- }
-
- constructor(source: any = {}) {
- if ('string' === typeof source) source = JSON.parse(source);
- this.files = this.convertValues(source["files"], FileEntry);
- this.totalFiles = source["totalFiles"];
- this.totalDirs = source["totalDirs"];
- this.totalSize = source["totalSize"];
- this.largeFiles = this.convertValues(source["largeFiles"], FileEntry);
- this.folders = this.convertValues(source["folders"], FolderStat);
- this.extensions = this.convertValues(source["extensions"], ExtensionStat);
- }
-
- convertValues(a: any, classs: any, asMap: boolean = false): any {
- if (!a) {
- return a;
- }
- if (a.slice && a.map) {
- return (a as any[]).map(elem => this.convertValues(elem, classs));
- } else if ("object" === typeof a) {
- if (asMap) {
- for (const key of Object.keys(a)) {
- a[key] = new classs(a[key]);
- }
- return a;
- }
- return new classs(a);
- }
- return a;
- }
- }
-
-}
-
diff --git a/frontend/wailsjs/runtime/package.json b/frontend/wailsjs/runtime/package.json
deleted file mode 100644
index 1e7c8a5..0000000
--- a/frontend/wailsjs/runtime/package.json
+++ /dev/null
@@ -1,24 +0,0 @@
-{
- "name": "@wailsapp/runtime",
- "version": "2.0.0",
- "description": "Wails Javascript runtime library",
- "main": "runtime.js",
- "types": "runtime.d.ts",
- "scripts": {
- },
- "repository": {
- "type": "git",
- "url": "git+https://github.com/wailsapp/wails.git"
- },
- "keywords": [
- "Wails",
- "Javascript",
- "Go"
- ],
- "author": "Lea Anthony ",
- "license": "MIT",
- "bugs": {
- "url": "https://github.com/wailsapp/wails/issues"
- },
- "homepage": "https://github.com/wailsapp/wails#readme"
-}
diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts
deleted file mode 100644
index 3bbea84..0000000
--- a/frontend/wailsjs/runtime/runtime.d.ts
+++ /dev/null
@@ -1,330 +0,0 @@
-/*
- _ __ _ __
-| | / /___ _(_) /____
-| | /| / / __ `/ / / ___/
-| |/ |/ / /_/ / / (__ )
-|__/|__/\__,_/_/_/____/
-The electron alternative for Go
-(c) Lea Anthony 2019-present
-*/
-
-export interface Position {
- x: number;
- y: number;
-}
-
-export interface Size {
- w: number;
- h: number;
-}
-
-export interface Screen {
- isCurrent: boolean;
- isPrimary: boolean;
- width : number
- height : number
-}
-
-// Environment information such as platform, buildtype, ...
-export interface EnvironmentInfo {
- buildType: string;
- platform: string;
- arch: string;
-}
-
-// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
-// emits the given event. Optional data may be passed with the event.
-// This will trigger any event listeners.
-export function EventsEmit(eventName: string, ...data: any): void;
-
-// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
-export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
-
-// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
-// sets up a listener for the given event name, but will only trigger a given number times.
-export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
-
-// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
-// sets up a listener for the given event name, but will only trigger once.
-export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
-
-// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
-// unregisters the listener for the given event name.
-export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
-
-// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
-// unregisters all listeners.
-export function EventsOffAll(): void;
-
-// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
-// logs the given message as a raw message
-export function LogPrint(message: string): void;
-
-// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
-// logs the given message at the `trace` log level.
-export function LogTrace(message: string): void;
-
-// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
-// logs the given message at the `debug` log level.
-export function LogDebug(message: string): void;
-
-// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
-// logs the given message at the `error` log level.
-export function LogError(message: string): void;
-
-// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
-// logs the given message at the `fatal` log level.
-// The application will quit after calling this method.
-export function LogFatal(message: string): void;
-
-// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
-// logs the given message at the `info` log level.
-export function LogInfo(message: string): void;
-
-// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
-// logs the given message at the `warning` log level.
-export function LogWarning(message: string): void;
-
-// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
-// Forces a reload by the main application as well as connected browsers.
-export function WindowReload(): void;
-
-// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
-// Reloads the application frontend.
-export function WindowReloadApp(): void;
-
-// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
-// Sets the window AlwaysOnTop or not on top.
-export function WindowSetAlwaysOnTop(b: boolean): void;
-
-// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
-// *Windows only*
-// Sets window theme to system default (dark/light).
-export function WindowSetSystemDefaultTheme(): void;
-
-// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
-// *Windows only*
-// Sets window to light theme.
-export function WindowSetLightTheme(): void;
-
-// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
-// *Windows only*
-// Sets window to dark theme.
-export function WindowSetDarkTheme(): void;
-
-// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
-// Centers the window on the monitor the window is currently on.
-export function WindowCenter(): void;
-
-// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
-// Sets the text in the window title bar.
-export function WindowSetTitle(title: string): void;
-
-// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
-// Makes the window full screen.
-export function WindowFullscreen(): void;
-
-// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
-// Restores the previous window dimensions and position prior to full screen.
-export function WindowUnfullscreen(): void;
-
-// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
-// Returns the state of the window, i.e. whether the window is in full screen mode or not.
-export function WindowIsFullscreen(): Promise;
-
-// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
-// Sets the width and height of the window.
-export function WindowSetSize(width: number, height: number): void;
-
-// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
-// Gets the width and height of the window.
-export function WindowGetSize(): Promise;
-
-// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
-// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
-// Setting a size of 0,0 will disable this constraint.
-export function WindowSetMaxSize(width: number, height: number): void;
-
-// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
-// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
-// Setting a size of 0,0 will disable this constraint.
-export function WindowSetMinSize(width: number, height: number): void;
-
-// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
-// Sets the window position relative to the monitor the window is currently on.
-export function WindowSetPosition(x: number, y: number): void;
-
-// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
-// Gets the window position relative to the monitor the window is currently on.
-export function WindowGetPosition(): Promise;
-
-// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
-// Hides the window.
-export function WindowHide(): void;
-
-// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
-// Shows the window, if it is currently hidden.
-export function WindowShow(): void;
-
-// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
-// Maximises the window to fill the screen.
-export function WindowMaximise(): void;
-
-// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
-// Toggles between Maximised and UnMaximised.
-export function WindowToggleMaximise(): void;
-
-// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
-// Restores the window to the dimensions and position prior to maximising.
-export function WindowUnmaximise(): void;
-
-// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
-// Returns the state of the window, i.e. whether the window is maximised or not.
-export function WindowIsMaximised(): Promise;
-
-// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
-// Minimises the window.
-export function WindowMinimise(): void;
-
-// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
-// Restores the window to the dimensions and position prior to minimising.
-export function WindowUnminimise(): void;
-
-// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
-// Returns the state of the window, i.e. whether the window is minimised or not.
-export function WindowIsMinimised(): Promise;
-
-// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
-// Returns the state of the window, i.e. whether the window is normal or not.
-export function WindowIsNormal(): Promise;
-
-// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
-// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
-export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
-
-// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
-// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
-export function ScreenGetAll(): Promise;
-
-// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
-// Opens the given URL in the system browser.
-export function BrowserOpenURL(url: string): void;
-
-// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
-// Returns information about the environment
-export function Environment(): Promise;
-
-// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
-// Quits the application.
-export function Quit(): void;
-
-// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
-// Hides the application.
-export function Hide(): void;
-
-// [Show](https://wails.io/docs/reference/runtime/intro#show)
-// Shows the application.
-export function Show(): void;
-
-// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
-// Returns the current text stored on clipboard
-export function ClipboardGetText(): Promise;
-
-// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
-// Sets a text on the clipboard
-export function ClipboardSetText(text: string): Promise;
-
-// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
-// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
-export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
-
-// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
-// OnFileDropOff removes the drag and drop listeners and handlers.
-export function OnFileDropOff() :void
-
-// Check if the file path resolver is available
-export function CanResolveFilePaths(): boolean;
-
-// Resolves file paths for an array of files
-export function ResolveFilePaths(files: File[]): void
-
-// Notification types
-export interface NotificationOptions {
- id: string;
- title: string;
- subtitle?: string; // macOS and Linux only
- body?: string;
- categoryId?: string;
- data?: { [key: string]: any };
-}
-
-export interface NotificationAction {
- id?: string;
- title?: string;
- destructive?: boolean; // macOS-specific
-}
-
-export interface NotificationCategory {
- id?: string;
- actions?: NotificationAction[];
- hasReplyField?: boolean;
- replyPlaceholder?: string;
- replyButtonTitle?: string;
-}
-
-// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
-// Initializes the notification service for the application.
-// This must be called before sending any notifications.
-export function InitializeNotifications(): Promise;
-
-// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
-// Cleans up notification resources and releases any held connections.
-export function CleanupNotifications(): Promise;
-
-// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
-// Checks if notifications are available on the current platform.
-export function IsNotificationAvailable(): Promise;
-
-// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
-// Requests notification authorization from the user (macOS only).
-export function RequestNotificationAuthorization(): Promise;
-
-// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
-// Checks the current notification authorization status (macOS only).
-export function CheckNotificationAuthorization(): Promise;
-
-// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
-// Sends a basic notification with the given options.
-export function SendNotification(options: NotificationOptions): Promise;
-
-// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
-// Sends a notification with action buttons. Requires a registered category.
-export function SendNotificationWithActions(options: NotificationOptions): Promise;
-
-// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
-// Registers a notification category that can be used with SendNotificationWithActions.
-export function RegisterNotificationCategory(category: NotificationCategory): Promise;
-
-// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
-// Removes a previously registered notification category.
-export function RemoveNotificationCategory(categoryId: string): Promise;
-
-// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
-// Removes all pending notifications from the notification center.
-export function RemoveAllPendingNotifications(): Promise;
-
-// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
-// Removes a specific pending notification by its identifier.
-export function RemovePendingNotification(identifier: string): Promise;
-
-// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
-// Removes all delivered notifications from the notification center.
-export function RemoveAllDeliveredNotifications(): Promise;
-
-// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
-// Removes a specific delivered notification by its identifier.
-export function RemoveDeliveredNotification(identifier: string): Promise;
-
-// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
-// Removes a notification by its identifier (cross-platform convenience function).
-export function RemoveNotification(identifier: string): Promise;
\ No newline at end of file
diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js
deleted file mode 100644
index 556621e..0000000
--- a/frontend/wailsjs/runtime/runtime.js
+++ /dev/null
@@ -1,298 +0,0 @@
-/*
- _ __ _ __
-| | / /___ _(_) /____
-| | /| / / __ `/ / / ___/
-| |/ |/ / /_/ / / (__ )
-|__/|__/\__,_/_/_/____/
-The electron alternative for Go
-(c) Lea Anthony 2019-present
-*/
-
-export function LogPrint(message) {
- window.runtime.LogPrint(message);
-}
-
-export function LogTrace(message) {
- window.runtime.LogTrace(message);
-}
-
-export function LogDebug(message) {
- window.runtime.LogDebug(message);
-}
-
-export function LogInfo(message) {
- window.runtime.LogInfo(message);
-}
-
-export function LogWarning(message) {
- window.runtime.LogWarning(message);
-}
-
-export function LogError(message) {
- window.runtime.LogError(message);
-}
-
-export function LogFatal(message) {
- window.runtime.LogFatal(message);
-}
-
-export function EventsOnMultiple(eventName, callback, maxCallbacks) {
- return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
-}
-
-export function EventsOn(eventName, callback) {
- return EventsOnMultiple(eventName, callback, -1);
-}
-
-export function EventsOff(eventName, ...additionalEventNames) {
- return window.runtime.EventsOff(eventName, ...additionalEventNames);
-}
-
-export function EventsOffAll() {
- return window.runtime.EventsOffAll();
-}
-
-export function EventsOnce(eventName, callback) {
- return EventsOnMultiple(eventName, callback, 1);
-}
-
-export function EventsEmit(eventName) {
- let args = [eventName].slice.call(arguments);
- return window.runtime.EventsEmit.apply(null, args);
-}
-
-export function WindowReload() {
- window.runtime.WindowReload();
-}
-
-export function WindowReloadApp() {
- window.runtime.WindowReloadApp();
-}
-
-export function WindowSetAlwaysOnTop(b) {
- window.runtime.WindowSetAlwaysOnTop(b);
-}
-
-export function WindowSetSystemDefaultTheme() {
- window.runtime.WindowSetSystemDefaultTheme();
-}
-
-export function WindowSetLightTheme() {
- window.runtime.WindowSetLightTheme();
-}
-
-export function WindowSetDarkTheme() {
- window.runtime.WindowSetDarkTheme();
-}
-
-export function WindowCenter() {
- window.runtime.WindowCenter();
-}
-
-export function WindowSetTitle(title) {
- window.runtime.WindowSetTitle(title);
-}
-
-export function WindowFullscreen() {
- window.runtime.WindowFullscreen();
-}
-
-export function WindowUnfullscreen() {
- window.runtime.WindowUnfullscreen();
-}
-
-export function WindowIsFullscreen() {
- return window.runtime.WindowIsFullscreen();
-}
-
-export function WindowGetSize() {
- return window.runtime.WindowGetSize();
-}
-
-export function WindowSetSize(width, height) {
- window.runtime.WindowSetSize(width, height);
-}
-
-export function WindowSetMaxSize(width, height) {
- window.runtime.WindowSetMaxSize(width, height);
-}
-
-export function WindowSetMinSize(width, height) {
- window.runtime.WindowSetMinSize(width, height);
-}
-
-export function WindowSetPosition(x, y) {
- window.runtime.WindowSetPosition(x, y);
-}
-
-export function WindowGetPosition() {
- return window.runtime.WindowGetPosition();
-}
-
-export function WindowHide() {
- window.runtime.WindowHide();
-}
-
-export function WindowShow() {
- window.runtime.WindowShow();
-}
-
-export function WindowMaximise() {
- window.runtime.WindowMaximise();
-}
-
-export function WindowToggleMaximise() {
- window.runtime.WindowToggleMaximise();
-}
-
-export function WindowUnmaximise() {
- window.runtime.WindowUnmaximise();
-}
-
-export function WindowIsMaximised() {
- return window.runtime.WindowIsMaximised();
-}
-
-export function WindowMinimise() {
- window.runtime.WindowMinimise();
-}
-
-export function WindowUnminimise() {
- window.runtime.WindowUnminimise();
-}
-
-export function WindowSetBackgroundColour(R, G, B, A) {
- window.runtime.WindowSetBackgroundColour(R, G, B, A);
-}
-
-export function ScreenGetAll() {
- return window.runtime.ScreenGetAll();
-}
-
-export function WindowIsMinimised() {
- return window.runtime.WindowIsMinimised();
-}
-
-export function WindowIsNormal() {
- return window.runtime.WindowIsNormal();
-}
-
-export function BrowserOpenURL(url) {
- window.runtime.BrowserOpenURL(url);
-}
-
-export function Environment() {
- return window.runtime.Environment();
-}
-
-export function Quit() {
- window.runtime.Quit();
-}
-
-export function Hide() {
- window.runtime.Hide();
-}
-
-export function Show() {
- window.runtime.Show();
-}
-
-export function ClipboardGetText() {
- return window.runtime.ClipboardGetText();
-}
-
-export function ClipboardSetText(text) {
- return window.runtime.ClipboardSetText(text);
-}
-
-/**
- * Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
- *
- * @export
- * @callback OnFileDropCallback
- * @param {number} x - x coordinate of the drop
- * @param {number} y - y coordinate of the drop
- * @param {string[]} paths - A list of file paths.
- */
-
-/**
- * OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
- *
- * @export
- * @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
- * @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
- */
-export function OnFileDrop(callback, useDropTarget) {
- return window.runtime.OnFileDrop(callback, useDropTarget);
-}
-
-/**
- * OnFileDropOff removes the drag and drop listeners and handlers.
- */
-export function OnFileDropOff() {
- return window.runtime.OnFileDropOff();
-}
-
-export function CanResolveFilePaths() {
- return window.runtime.CanResolveFilePaths();
-}
-
-export function ResolveFilePaths(files) {
- return window.runtime.ResolveFilePaths(files);
-}
-
-export function InitializeNotifications() {
- return window.runtime.InitializeNotifications();
-}
-
-export function CleanupNotifications() {
- return window.runtime.CleanupNotifications();
-}
-
-export function IsNotificationAvailable() {
- return window.runtime.IsNotificationAvailable();
-}
-
-export function RequestNotificationAuthorization() {
- return window.runtime.RequestNotificationAuthorization();
-}
-
-export function CheckNotificationAuthorization() {
- return window.runtime.CheckNotificationAuthorization();
-}
-
-export function SendNotification(options) {
- return window.runtime.SendNotification(options);
-}
-
-export function SendNotificationWithActions(options) {
- return window.runtime.SendNotificationWithActions(options);
-}
-
-export function RegisterNotificationCategory(category) {
- return window.runtime.RegisterNotificationCategory(category);
-}
-
-export function RemoveNotificationCategory(categoryId) {
- return window.runtime.RemoveNotificationCategory(categoryId);
-}
-
-export function RemoveAllPendingNotifications() {
- return window.runtime.RemoveAllPendingNotifications();
-}
-
-export function RemovePendingNotification(identifier) {
- return window.runtime.RemovePendingNotification(identifier);
-}
-
-export function RemoveAllDeliveredNotifications() {
- return window.runtime.RemoveAllDeliveredNotifications();
-}
-
-export function RemoveDeliveredNotification(identifier) {
- return window.runtime.RemoveDeliveredNotification(identifier);
-}
-
-export function RemoveNotification(identifier) {
- return window.runtime.RemoveNotification(identifier);
-}
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 265b4a2..1616bfa 100644
--- a/go.mod
+++ b/go.mod
@@ -3,49 +3,40 @@ module view
go 1.25.0
require (
+ github.com/go-sql-driver/mysql v1.10.0
github.com/hhatto/gocloc v0.7.0
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
- github.com/wailsapp/wails/v2 v2.12.0
+ github.com/shirou/gopsutil/v4 v4.26.7
+ github.com/wailsapp/wails/v3 v3.0.0-beta.6
+ golang.org/x/crypto v0.54.0
+ golang.org/x/image v0.44.0
+ golang.org/x/sys v0.47.0
modernc.org/sqlite v1.53.0
)
require (
+ filippo.io/edwards25519 v1.2.0 // indirect
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
- github.com/bep/debounce v1.2.1 // indirect
+ github.com/adrg/xdg v0.5.3 // indirect
+ github.com/coder/websocket v1.8.14 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/ebitengine/purego v0.10.2 // indirect
github.com/go-enry/go-enry/v2 v2.8.0 // indirect
github.com/go-enry/go-oniguruma v1.2.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
- github.com/godbus/dbus/v5 v5.1.0 // indirect
+ github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
- github.com/gorilla/websocket v1.5.3 // indirect
- github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
- github.com/labstack/echo/v4 v4.13.3 // indirect
- github.com/labstack/gommon v0.4.2 // indirect
- github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
- github.com/leaanthony/gosod v1.0.4 // indirect
- github.com/leaanthony/slicer v1.6.0 // indirect
- github.com/leaanthony/u v1.1.1 // indirect
- github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
+ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
- github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
- github.com/pkg/errors v0.9.1 // indirect
+ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
- github.com/rivo/uniseg v0.4.7 // indirect
- github.com/samber/lo v1.49.1 // indirect
- github.com/tkrajina/go-reflector v0.5.8 // indirect
- github.com/valyala/bytebufferpool v1.0.0 // indirect
- github.com/valyala/fasttemplate v1.2.2 // indirect
- github.com/wailsapp/go-webview2 v1.0.22 // indirect
- github.com/wailsapp/mimetype v1.4.1 // indirect
- golang.org/x/crypto v0.33.0 // indirect
- golang.org/x/net v0.35.0 // indirect
- golang.org/x/sys v0.44.0 // indirect
- golang.org/x/text v0.22.0 // indirect
+ github.com/tklauser/go-sysconf v0.3.16 // indirect
+ github.com/tklauser/numcpus v0.11.0 // indirect
+ github.com/yusufpapurcu/wmi v1.2.4 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
-
-// replace github.com/wailsapp/wails/v2 v2.12.0 => C:\Users\admin\go\pkg\mod
diff --git a/go.sum b/go.sum
index 0bcb52f..c5f38ea 100644
--- a/go.sum
+++ b/go.sum
@@ -1,114 +1,99 @@
+filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
+filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
-github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
-github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
+github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
+github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
+github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
+github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
+github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/go-enry/go-enry/v2 v2.8.0 h1:KMW4mSG+8uUF6FaD3iPkFqyfC5tF8gRrsYImq6yhHzo=
github.com/go-enry/go-enry/v2 v2.8.0/go.mod h1:GVzIiAytiS5uT/QiuakK7TF1u4xDab87Y8V5EJRpsIQ=
github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo=
github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4=
+github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
+github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
+github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
-github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
-github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
+github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
+github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
+github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
-github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hhatto/gocloc v0.7.0 h1:PS+C3H7To0kr8dwNDz+ahKRt05pYkUdhR3YAhr/27RA=
github.com/hhatto/gocloc v0.7.0/go.mod h1:H2qL5xyLUYpiUY8JSLHaXYhACYhRuM/j5HWEOR29hus=
-github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
-github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
-github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
-github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
-github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
-github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
-github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
-github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
-github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
-github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
-github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
-github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
-github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
-github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
-github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
-github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
-github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
+github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
-github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
-github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
-github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
-github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
-github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
-github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
+github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
-github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
-github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
-github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
-github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
+github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
+github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc=
github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
-github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
-github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
-github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
-github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
-github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
-github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
-github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
-github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
-github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
-github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
-golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
-golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
-golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
-golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
-golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
-golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
-golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
-golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
+github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
+github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
+github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
+github.com/wailsapp/wails/v3 v3.0.0-beta.6 h1:k9FHF/T39EyTZNCHweRrLt1c6dwV3R3A+r1oCNiPB8I=
+github.com/wailsapp/wails/v3 v3.0.0-beta.6/go.mod h1:A/OaL1mXOnwWynTJv4rZU89Wbk5q3rtq3C3qkx4rRN0=
+github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
+github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
+golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
+golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
+golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
+golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
-golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
-golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
-golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/main.go b/main.go
index 86f1e13..4b0442d 100644
--- a/main.go
+++ b/main.go
@@ -2,38 +2,59 @@ package main
import (
"embed"
+ "log/slog"
+ "os"
- "github.com/wailsapp/wails/v2"
- "github.com/wailsapp/wails/v2/pkg/options"
- "github.com/wailsapp/wails/v2/pkg/options/assetserver"
+ "github.com/wailsapp/wails/v3/pkg/application"
+ "github.com/wailsapp/wails/v3/pkg/services/notifications"
)
//go:embed all:frontend/dist
var assets embed.FS
-func main() {
- // Create an instance of the app structure
- app := NewApp()
+func devtoolsArgs() []string {
+ if p := os.Getenv("CC_DEVTOOLS_PORT"); p != "" {
+ return []string{"--remote-debugging-port=" + p}
+ }
+ return nil
+}
- // Create application with options
- err := wails.Run(&options.App{
- Title: "Code Count",
- Width: 1280,
- Height: 820,
- MinWidth: 960,
- MinHeight: 680,
- AssetServer: &assetserver.Options{
- Assets: assets,
+func main() {
+ app := NewApp()
+ notifier := notifications.New()
+ app.notifier = notifier
+
+ wapp := application.New(application.Options{
+ Name: "年糕崽崽项目管理(PMS)",
+ Description: "本地代码统计与项目工作台",
+ Services: []application.Service{
+ application.NewService(app),
+ application.NewService(notifier),
},
- BackgroundColour: &options.RGBA{R: 13, G: 18, B: 28, A: 1},
- OnStartup: app.startup,
- OnShutdown: app.shutdown,
- Bind: []interface{}{
- app,
+ Assets: application.AssetOptions{
+ Handler: application.AssetFileServerFS(assets),
+ },
+ LogLevel: slog.LevelError,
+ Windows: application.WindowsOptions{
+ // Opt-in DevTools protocol endpoint for automated UI testing; inert unless the env var is set.
+ AdditionalBrowserArgs: devtoolsArgs(),
},
})
- if err != nil {
+ win := wapp.Window.NewWithOptions(application.WebviewWindowOptions{
+ Name: "main",
+ Title: "年糕崽崽项目管理(PMS)",
+ Width: 1280,
+ Height: 820,
+ MinWidth: 960,
+ MinHeight: 680,
+ URL: "/",
+ BackgroundColour: application.NewRGBA(13, 18, 28, 255),
+ })
+
+ app.SetupShell(wapp, win)
+
+ if err := wapp.Run(); err != nil {
println("Error:", err.Error())
}
}
diff --git a/model/models.go b/model/models.go
index 4e073b3..46faa05 100644
--- a/model/models.go
+++ b/model/models.go
@@ -96,23 +96,34 @@ type Contributor struct {
Deleted int64 `json:"deleted"`
}
type HeatDay struct {
- Date string `json:"date"`
- Count int64 `json:"count"`
+ Date string `json:"date"`
+ Count int64 `json:"count"`
+ Added int64 `json:"added"`
+ Deleted int64 `json:"deleted"`
+}
+
+// GitFileHotspot 记录变更最频繁的文件,用于识别高风险热点。
+type GitFileHotspot struct {
+ Path string `json:"path"`
+ Changes int64 `json:"changes"`
+ Added int64 `json:"added"`
+ Deleted int64 `json:"deleted"`
}
type GitStats struct {
- Available bool `json:"available"`
- Error string `json:"error,omitempty"`
- CurrentBranch string `json:"currentBranch"`
- WorkspaceBranch string `json:"workspaceBranch"`
- ViewRef string `json:"viewRef"`
- CommitCount int64 `json:"commitCount"`
- Added int64 `json:"added"`
- Deleted int64 `json:"deleted"`
- ContributorCount int64 `json:"contributorCount"`
- Commits []GitCommit `json:"commits"`
- Refs []GitRef `json:"refs"`
- Contributors []Contributor `json:"contributors"`
- Heatmap []HeatDay `json:"heatmap"`
+ Available bool `json:"available"`
+ Error string `json:"error,omitempty"`
+ CurrentBranch string `json:"currentBranch"`
+ WorkspaceBranch string `json:"workspaceBranch"`
+ ViewRef string `json:"viewRef"`
+ CommitCount int64 `json:"commitCount"`
+ Added int64 `json:"added"`
+ Deleted int64 `json:"deleted"`
+ ContributorCount int64 `json:"contributorCount"`
+ Commits []GitCommit `json:"commits"`
+ Refs []GitRef `json:"refs"`
+ Contributors []Contributor `json:"contributors"`
+ Heatmap []HeatDay `json:"heatmap"`
+ Hotspots []GitFileHotspot `json:"hotspots"`
}
type GitDiagnostics struct {
Available bool `json:"available"`
@@ -179,6 +190,54 @@ type LogEntry struct {
Detail string `json:"detail"`
CreatedAt string `json:"createdAt"`
}
+type Todo struct {
+ ID int64 `json:"id"`
+ UUID string `json:"uuid"`
+ Title string `json:"title"`
+ Content string `json:"content"`
+ ProjectID int64 `json:"projectId"`
+ ProjectName string `json:"projectName"`
+ DueAt string `json:"dueAt"`
+ Priority string `json:"priority"` // low | medium | high
+ Status string `json:"status"` // open | doing | done
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
+ History string `json:"history"` // JSON 数组:[{"status","at"}],记录每次进入某状态的时间
+ TeamID int64 `json:"teamId"` // >0 表示已共享给该团队
+}
+type Ticket struct {
+ ID int64 `json:"id"`
+ UUID string `json:"uuid"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Type string `json:"type"` // feature | bug | task | improvement
+ ProjectID int64 `json:"projectId"`
+ ProjectName string `json:"projectName"`
+ StartAt string `json:"startAt"`
+ DueAt string `json:"dueAt"`
+ Status string `json:"status"` // open | in_progress | resolved | closed
+ Priority string `json:"priority"` // low | medium | high
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
+ History string `json:"history"` // JSON 数组:[{"status","at"}],记录每次进入某状态的时间
+ TeamID int64 `json:"teamId"` // >0 表示已共享给该团队
+}
+type Note struct {
+ ID int64 `json:"id"`
+ UUID string `json:"uuid"`
+ Content string `json:"content"`
+ UpdatedAt string `json:"updatedAt"`
+}
+type Message struct {
+ ID int64 `json:"id"`
+ Kind string `json:"kind"` // todo_due | ticket_due | analysis | sync | system
+ Title string `json:"title"`
+ Body string `json:"body"`
+ SourceType string `json:"sourceType"` // todo | ticket | project | ""
+ SourceID int64 `json:"sourceId"`
+ Read bool `json:"read"`
+ CreatedAt string `json:"createdAt"`
+}
type AppSettings struct {
Theme string `json:"theme"`
Locale string `json:"locale"`
@@ -187,11 +246,174 @@ type AppSettings struct {
AutoRefresh bool `json:"autoRefresh"`
GlassOpacity int `json:"glassOpacity"`
LoadingStyle string `json:"loadingStyle"`
+ // 托盘与定时更新(Phase 2)
+ MinimizeToTray bool `json:"minimizeToTray"`
+ AutoUpdateEnabled bool `json:"autoUpdateEnabled"`
+ AutoUpdateMode string `json:"autoUpdateMode"` // daily | everyNDays | everyNHours
+ AutoUpdateInterval int `json:"autoUpdateInterval"` // everyNDays/everyNHours 的 N
+ AutoUpdateTime string `json:"autoUpdateTime"` // daily/everyNDays 的触发时刻 HH:MM
+ // AI 分析(Phase 5)
+ AIProvider string `json:"aiProvider"` // spark | deepseek
+ SparkKey string `json:"sparkKey"`
+ DeepSeekKey string `json:"deepSeekKey"`
+ // SyncAPIKeys 开启后 API Key 会加密同步到 MySQL(密钥由登录密码派生,服务器不可解密)。
+ SyncAPIKeys bool `json:"syncApiKeys"`
+ // 用户头像:AvatarMode 为 ""(未设置) | base64 | url | path,AvatarValue 依模式存 dataURL / 图片 URL / 本地文件路径。
+ AvatarMode string `json:"avatarMode"`
+ AvatarValue string `json:"avatarValue"`
+ // 内容图片存储:待办/工单 Markdown 里粘贴或插入的图片如何保存。
+ // base64(内嵌 dataURL,随内容同步到云端)| path(写入数据目录,仅本机可见)。
+ ImageMode string `json:"imageMode"`
+}
+
+// AvatarPick 是选择头像图片的结果:Value 为要保存的值,Preview 为可直接显示的图片源。
+// SearchHit 全局搜索(Ctrl+K 命令面板)的一条结果。
+type SearchHit struct {
+ Kind string `json:"kind"` // project | todo | ticket | conversation
+ ID int64 `json:"id"`
+ Title string `json:"title"`
+ Sub string `json:"sub"` // 路径 / 状态 / 供应商等辅助信息
+ Extra string `json:"extra"` // 次级状态(如优先级)
+}
+
+type AvatarPick struct {
+ // Mode 是选图后实际采用的存储模式(base64 | url),auto 选图时由全局配置决定。
+ Mode string `json:"mode"`
+ Value string `json:"value"`
+ Preview string `json:"preview"`
+}
+
+// AIConversation 是一次 AI 分析会话(可绑定项目)。
+type AIConversation struct {
+ ID int64 `json:"id"`
+ ProjectID int64 `json:"projectId"`
+ ProjectName string `json:"projectName"`
+ Provider string `json:"provider"`
+ Title string `json:"title"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
+}
+
+// AIMessage 是会话中的一条消息。
+type AIMessage struct {
+ ID int64 `json:"id"`
+ ConversationID int64 `json:"conversationId"`
+ Role string `json:"role"` // user | assistant
+ Content string `json:"content"`
+ CreatedAt string `json:"createdAt"`
+}
+
+// AISummary 是分析完成后 AI 生成的模块介绍(按项目+模块各存最新一份)。
+type AISummary struct {
+ ProjectID int64 `json:"projectId"`
+ Kind string `json:"kind"` // project | git
+ Content string `json:"content"`
+ Provider string `json:"provider"`
+ GeneratedAt string `json:"generatedAt"`
}
type Dashboard struct {
- Projects int64 `json:"projects"`
- TotalLines int64 `json:"totalLines"`
- Commits int64 `json:"commits"`
+ Projects int64 `json:"projects"`
+ TotalLines int64 `json:"totalLines"`
+ CodeLines int64 `json:"codeLines"`
+ CommentLines int64 `json:"commentLines"`
+ BlankLines int64 `json:"blankLines"`
+ FileCount int64 `json:"fileCount"`
+ Commits int64 `json:"commits"`
+ Contributors int64 `json:"contributors"`
+ Languages []LanguageStat `json:"languages"`
+}
+
+// FileStorageConfig 是全局「文件存储方式」配置:由管理员(云端账号 id=1)设置,
+// 云端存 sync_settings 的 file_storage 行(挂在 id=1 名下),随同步分发给所有账号。
+// mode=local 时图片维持本地行为;mode=server 时内容图(imageMode=server)与
+// 头像选图上传到 nl-pms-api 换取 http URL,远程/跨设备场景直接引用。
+type FileStorageConfig struct {
+ Mode string `json:"mode"` // local | server
+ BaseURL string `json:"baseUrl"` // nl-pms-api 地址,如 http://192.168.1.10:8788
+ APIKey string `json:"apiKey"` // 上传密钥(Authorization: Bearer )
+}
+
+// ServerFile 是 nl-pms-api 素材库中的一条文件记录(含上传者用户名与公开访问 URL)。
+type ServerFile struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ URL string `json:"url"`
+ Mime string `json:"mime"`
+ Size int64 `json:"size"`
+ UserID int64 `json:"userId"`
+ TeamID int64 `json:"teamId"`
+ Kind string `json:"kind"` // avatar | content
+ Username string `json:"username"`
+ CreatedAt string `json:"createdAt"`
+}
+
+// ServerFileList 是素材库的一页结果。
+type ServerFileList struct {
+ Total int64 `json:"total"`
+ Items []ServerFile `json:"items"`
+}
+
+// SyncConfig 是 MySQL 同步服务器的连接配置(保存在本地 SQLite)。
+type SyncConfig struct {
+ Host string `json:"host"`
+ Port int `json:"port"`
+ User string `json:"user"`
+ Password string `json:"password"`
+ Database string `json:"database"`
+}
+
+// SyncStatus 描述当前登录与同步状态,供设置页展示。
+type SyncStatus struct {
+ Configured bool `json:"configured"`
+ LoggedIn bool `json:"loggedIn"`
+ // UserID 为云端账号 id;id=1 视为管理员(可配置节日背景图等全局资源)。
+ UserID int64 `json:"userId"`
+ Username string `json:"username"`
+ LastSyncAt string `json:"lastSyncAt"`
+ Syncing bool `json:"syncing"`
+ Online bool `json:"online"`
+ LastError string `json:"lastError"`
+ Pushed int `json:"pushed"`
+ Pulled int `json:"pulled"`
+ // Pending 为本地待推送(dirty)行数,用于界面徽标。
+ Pending int `json:"pending"`
+}
+
+// FestivalImage 是一个节日的背景配置:mode 决定日历格显示自定义照片还是动态插画。
+// image 在 mode=art 时仍保留,方便管理员切回图片模式无需重新上传。
+type FestivalImage struct {
+ Mode string `json:"mode"` // photo | art
+ Image string `json:"image"`
+}
+
+// LogPage 是日志分页搜索的结果,counts 基于当前搜索条件(忽略级别筛选)。
+type LogPage struct {
+ Items []LogEntry `json:"items"`
+ Total int64 `json:"total"`
+ Info int64 `json:"info"`
+ Warning int64 `json:"warning"`
+ Error int64 `json:"error"`
+}
+
+// BatchSummary 是批量统计完成后的汇总事件载荷。
+type BatchSummary struct {
+ Total int `json:"total"`
+ Completed int `json:"completed"`
+ Failed int `json:"failed"`
+ Cancelled int `json:"cancelled"`
+ Failures []string `json:"failures"`
+}
+
+// FilePreview 是文件预览接口的返回载荷。
+type FilePreview struct {
+ Path string `json:"path"`
+ Name string `json:"name"`
+ Extension string `json:"extension"`
+ Size int64 `json:"size"`
+ Content string `json:"content"`
+ Lines int `json:"lines"`
+ Truncated bool `json:"truncated"`
+ Binary bool `json:"binary"`
}
// TaskEvent 只传递稳定消息键,具体中文或英文由前端根据当前语言即时翻译。
@@ -203,3 +425,51 @@ type TaskEvent struct {
MessageKey string `json:"messageKey"`
Params map[string]any `json:"params,omitempty"`
}
+
+// CloneInput 是「从 Git 克隆并添加项目」的入参。
+type CloneInput struct {
+ URL string `json:"url"`
+ ParentDir string `json:"parentDir"`
+ Name string `json:"name"`
+ GroupID int64 `json:"groupId"`
+ Description string `json:"description"`
+}
+
+// LaunchApp 是启动台保存的应用(含启停命令)。
+type LaunchApp struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Kind string `json:"kind"`
+ Port int `json:"port"`
+ Dir string `json:"dir"`
+ StartCmd string `json:"startCmd"`
+ StopCmd string `json:"stopCmd"`
+ LastPID int64 `json:"lastPid"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
+}
+
+// LaunchEntry 是启动台卡片条目:保存的应用与实时扫描到的监听进程合并后的视图。
+type LaunchEntry struct {
+ ID int64 `json:"id"` // launch_apps id;0 表示未保存的扫描条目
+ Name string `json:"name"`
+ Kind string `json:"kind"`
+ Port int `json:"port"`
+ Dir string `json:"dir"`
+ StartCmd string `json:"startCmd"`
+ StopCmd string `json:"stopCmd"`
+ Running bool `json:"running"`
+ PID int32 `json:"pid"`
+ Exe string `json:"exe"`
+ Cmdline string `json:"cmdline"`
+ Ports []int `json:"ports"`
+ CPU float64 `json:"cpu"` // 占全机 CPU 百分比
+ MemMB float64 `json:"memMB"` // 常驻内存
+ IOKBs float64 `json:"ioKBs"` // 磁盘读写吞吐
+}
+
+// LaunchSuggest 是按项目种类推荐的启停命令。
+type LaunchSuggest struct {
+ Start []string `json:"start"`
+ Stop []string `json:"stop"`
+}
diff --git a/models.go b/models.go
index 91ef058..73bebf8 100644
--- a/models.go
+++ b/models.go
@@ -29,3 +29,26 @@ type LogEntry = model.LogEntry
type AppSettings = model.AppSettings
type Dashboard = model.Dashboard
type TaskEvent = model.TaskEvent
+type GitFileHotspot = model.GitFileHotspot
+type LogPage = model.LogPage
+type BatchSummary = model.BatchSummary
+type FilePreview = model.FilePreview
+type Todo = model.Todo
+type Ticket = model.Ticket
+type Note = model.Note
+type Message = model.Message
+type SyncConfig = model.SyncConfig
+type FileStorageConfig = model.FileStorageConfig
+type ServerFile = model.ServerFile
+type ServerFileList = model.ServerFileList
+type SyncStatus = model.SyncStatus
+type AIConversation = model.AIConversation
+type AIMessage = model.AIMessage
+type AISummary = model.AISummary
+type AvatarPick = model.AvatarPick
+type SearchHit = model.SearchHit
+type FestivalImage = model.FestivalImage
+type LaunchApp = model.LaunchApp
+type LaunchEntry = model.LaunchEntry
+type LaunchSuggest = model.LaunchSuggest
+type CloneInput = model.CloneInput
diff --git a/platform/command.go b/platform/command.go
index 52e6764..701e14e 100644
--- a/platform/command.go
+++ b/platform/command.go
@@ -59,6 +59,9 @@ func RunHidden(ctx context.Context, name string, args ...string) (string, error)
return out, nil
}
+// ConfigureHidden 供需要自建 exec.Cmd 流式读取输出的调用方隐藏控制台窗口。
+func ConfigureHidden(cmd *exec.Cmd) { configureHidden(cmd) }
+
// DecodeWSLList 兼容部分 Windows 版本返回的 UTF-16LE 发行版列表。
func DecodeWSLList(b []byte) string { return decodeOutput(b) }
diff --git a/platform/process_other.go b/platform/process_other.go
index b72705e..10d16e7 100644
--- a/platform/process_other.go
+++ b/platform/process_other.go
@@ -2,6 +2,14 @@
package platform
-import "os/exec"
+import (
+ "os/exec"
+ "syscall"
+)
func configureHidden(_ *exec.Cmd) {}
+
+// ConfigureDetached 供启动台启动长驻服务:放入独立进程组,便于整组停止。
+func ConfigureDetached(cmd *exec.Cmd) {
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+}
diff --git a/platform/process_windows.go b/platform/process_windows.go
index cab12b0..2164995 100644
--- a/platform/process_windows.go
+++ b/platform/process_windows.go
@@ -11,3 +11,9 @@ import (
func configureHidden(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000}
}
+
+// ConfigureDetached 供启动台启动长驻服务:隐藏窗口并放入新进程组,
+// 使子进程独立于主程序运行(0x00000200 = CREATE_NEW_PROCESS_GROUP)。
+func ConfigureDetached(cmd *exec.Cmd) {
+ cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000 | 0x00000200}
+}
diff --git a/service/git.go b/service/git.go
index 2ba3f5d..b2b53ad 100644
--- a/service/git.go
+++ b/service/git.go
@@ -129,27 +129,46 @@ func (g GitService) Diagnostics(ctx context.Context, dir string, ref string) mod
return d
}
+// gitLogMaxCommits 限制单次 git log 解析的提交数量,避免超大仓库拖垮分析。
+const gitLogMaxCommits = 20000
+
+// GitAnalyzeOptions 控制 Git 分析的范围与增量起点。
+type GitAnalyzeOptions struct {
+ Ref string // 统计视图引用;为空表示当前分支
+ AllBranches bool // true 时统计所有分支(gitScope=all)
+ SinceHash string // 已入库的最新提交哈希;非空且仍在历史内时执行增量分析
+}
+
// Analyze 分析工作区当前分支。
func (g GitService) Analyze(ctx context.Context, dir string) (model.GitStats, error) {
- return g.AnalyzeRef(ctx, dir, "")
+ stats, _, e := g.AnalyzeWithOptions(ctx, dir, GitAnalyzeOptions{})
+ return stats, e
}
// AnalyzeRef 只切换统计视图,不修改用户工作区。
func (g GitService) AnalyzeRef(ctx context.Context, dir, ref string) (model.GitStats, error) {
+ stats, _, e := g.AnalyzeWithOptions(ctx, dir, GitAnalyzeOptions{Ref: ref})
+ return stats, e
+}
+
+// AnalyzeWithOptions 返回统计结果和 incremental 标记:
+// incremental=true 时结果只包含 SinceHash 之后的新提交,调用方应执行合并而非整表替换。
+func (g GitService) AnalyzeWithOptions(ctx context.Context, dir string, opt GitAnalyzeOptions) (model.GitStats, bool, error) {
if _, e := g.run(ctx, dir, "rev-parse", "--git-dir"); e != nil {
- return model.GitStats{Available: false, Error: e.Error()}, e
+ return model.GitStats{Available: false, Error: e.Error()}, false, e
}
branch, _ := g.run(ctx, dir, "branch", "--show-current")
branch = strings.TrimSpace(branch)
+ ref := opt.Ref
if ref == "" {
ref = branch
}
- out := model.GitStats{Available: true, CurrentBranch: branch, WorkspaceBranch: branch, ViewRef: ref, Commits: []model.GitCommit{}, Refs: []model.GitRef{}, Contributors: []model.Contributor{}, Heatmap: []model.HeatDay{}}
- refs, e := g.run(ctx, dir, "for-each-ref", "--format=%(refname:short)%x1f%(objectname)%x1f%(refname)", "refs/heads", "refs/remotes")
+ out := model.GitStats{Available: true, CurrentBranch: branch, WorkspaceBranch: branch, ViewRef: ref, Commits: []model.GitCommit{}, Refs: []model.GitRef{}, Contributors: []model.Contributor{}, Heatmap: []model.HeatDay{}, Hotspots: []model.GitFileHotspot{}}
+ refs, e := g.run(ctx, dir, "for-each-ref", "--format=%(refname:short)%x1f%(objectname)%x1f%(refname)", "refs/heads", "refs/remotes", "refs/tags")
if e != nil {
out.Available = false
out.Error = e.Error()
- return out, e
+ return out, false, e
}
for _, line := range strings.Split(refs, "\n") {
p := strings.Split(line, "\x1f")
@@ -162,21 +181,43 @@ func (g GitService) AnalyzeRef(ctx context.Context, dir, ref string) (model.GitS
kind := "local"
if strings.HasPrefix(p[2], "refs/remotes/") {
kind = "remote"
+ } else if strings.HasPrefix(p[2], "refs/tags/") {
+ kind = "tag"
}
out.Refs = append(out.Refs, model.GitRef{Name: p[0], Hash: ShortHash(p[1]), Kind: kind, Current: p[0] == branch})
}
- args := []string{"log", "--use-mailmap", "--date=iso-strict", "--pretty=format:@@CC@@%H%x1f%aN%x1f%aE%x1f%aI%x1f%s", "--numstat"}
- if strings.TrimSpace(ref) != "" {
+ incremental := opt.SinceHash != "" && g.canIncrement(ctx, dir, opt.SinceHash, ref, opt.AllBranches)
+ args := []string{"log", "--use-mailmap", "--date=iso-strict", "--pretty=format:@@CC@@%H%x1f%aN%x1f%aE%x1f%aI%x1f%s", "--numstat", "--max-count", strconv.Itoa(gitLogMaxCommits)}
+ if opt.AllBranches {
+ args = append(args, "--all")
+ }
+ if incremental {
+ args = append(args, "^"+opt.SinceHash)
+ }
+ if !opt.AllBranches && strings.TrimSpace(ref) != "" {
args = append(args, ref)
}
log, e := g.run(ctx, dir, args...)
if e != nil {
out.Available = false
out.Error = e.Error()
- return out, e
+ return out, false, e
}
parseLog(log, &out)
- return out, nil
+ return out, incremental, nil
+}
+
+// canIncrement 校验增量起点仍然存在且属于目标历史,否则回退全量分析。
+func (g GitService) canIncrement(ctx context.Context, dir, since, ref string, all bool) bool {
+ if _, e := g.run(ctx, dir, "rev-parse", "--verify", "--quiet", since+"^{commit}"); e != nil {
+ return false
+ }
+ target := strings.TrimSpace(ref)
+ if all || target == "" {
+ target = "HEAD"
+ }
+ _, e := g.run(ctx, dir, "merge-base", "--is-ancestor", since, target)
+ return e == nil
}
// CommitDetail 按需读取单次提交和逐文件增删行,避免 Git 首页一次传输过多数据。
@@ -253,11 +294,15 @@ func (g GitService) CheckoutBranch(ctx context.Context, dir, ref string) (model.
func parseLog(log string, out *model.GitStats) {
var cur *model.GitCommit
+ fm := map[string]*model.GitFileHotspot{}
+ flush := func() {
+ if cur != nil {
+ out.Commits = append(out.Commits, *cur)
+ }
+ }
for _, line := range strings.Split(log, "\n") {
if strings.HasPrefix(line, "@@CC@@") {
- if cur != nil {
- out.Commits = append(out.Commits, *cur)
- }
+ flush()
p := strings.Split(strings.TrimPrefix(line, "@@CC@@"), "\x1f")
if len(p) == 5 {
cur = &model.GitCommit{Hash: p[0], Author: p[1], Email: strings.ToLower(p[2]), Date: p[3], Message: p[4]}
@@ -268,23 +313,34 @@ func parseLog(log string, out *model.GitStats) {
}
if cur != nil {
p := strings.Split(line, "\t")
- if len(p) >= 2 {
+ if len(p) >= 3 {
a, e1 := strconv.ParseInt(p[0], 10, 64)
d, e2 := strconv.ParseInt(p[1], 10, 64)
- if e1 == nil {
- cur.Added += a
+ if e1 != nil {
+ a = 0
}
- if e2 == nil {
- cur.Deleted += d
+ if e2 != nil {
+ d = 0
+ }
+ cur.Added += a
+ cur.Deleted += d
+ path := strings.TrimSpace(p[2])
+ if path != "" {
+ h := fm[path]
+ if h == nil {
+ h = &model.GitFileHotspot{Path: path}
+ fm[path] = h
+ }
+ h.Changes++
+ h.Added += a
+ h.Deleted += d
}
}
}
}
- if cur != nil {
- out.Commits = append(out.Commits, *cur)
- }
+ flush()
cm := map[string]*model.Contributor{}
- hm := map[string]int64{}
+ hm := map[string]*model.HeatDay{}
cut := time.Now().AddDate(-1, 0, 0)
for _, c := range out.Commits {
out.Added += c.Added
@@ -298,7 +354,15 @@ func parseLog(log string, out *model.GitStats) {
x.Added += c.Added
x.Deleted += c.Deleted
if t, e := time.Parse(time.RFC3339, c.Date); e == nil && t.After(cut) {
- hm[t.Local().Format("2006-01-02")]++
+ key := t.Local().Format("2006-01-02")
+ d := hm[key]
+ if d == nil {
+ d = &model.HeatDay{Date: key}
+ hm[key] = d
+ }
+ d.Count++
+ d.Added += c.Added
+ d.Deleted += c.Deleted
}
}
out.CommitCount = int64(len(out.Commits))
@@ -307,10 +371,22 @@ func parseLog(log string, out *model.GitStats) {
}
out.ContributorCount = int64(len(out.Contributors))
sort.Slice(out.Contributors, func(i, j int) bool { return out.Contributors[i].Commits > out.Contributors[j].Commits })
- for d, c := range hm {
- out.Heatmap = append(out.Heatmap, model.HeatDay{Date: d, Count: c})
+ for _, d := range hm {
+ out.Heatmap = append(out.Heatmap, *d)
}
sort.Slice(out.Heatmap, func(i, j int) bool { return out.Heatmap[i].Date < out.Heatmap[j].Date })
+ for _, h := range fm {
+ out.Hotspots = append(out.Hotspots, *h)
+ }
+ sort.Slice(out.Hotspots, func(i, j int) bool {
+ if out.Hotspots[i].Changes != out.Hotspots[j].Changes {
+ return out.Hotspots[i].Changes > out.Hotspots[j].Changes
+ }
+ return out.Hotspots[i].Added+out.Hotspots[i].Deleted > out.Hotspots[j].Added+out.Hotspots[j].Deleted
+ })
+ if len(out.Hotspots) > 50 {
+ out.Hotspots = out.Hotspots[:50]
+ }
}
func ShortHash(s string) string {