From 50f0e4f76fb8f14a5b92dec431026489334b6b01 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Tue, 30 Jun 2026 00:56:06 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B8=D0=BD=D0=B4=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=D1=82=D0=BE=D1=80=20=D0=BF=D1=80=D0=B8=D1=81=D1=83=D1=82=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D0=B8=D1=8F=20=D0=B2=20=D0=BA=D0=BE=D0=BD=D1=84?= =?UTF-8?q?=D0=B8=D0=B3=D1=83=D1=80=D0=B0=D1=86=D0=B8=D1=8F=D1=85=20(?= =?UTF-8?q?=D0=B8=D0=BA=D0=BE=D0=BD=D0=BA=D0=B0=20=D0=B3=D0=BB=D0=B0=D0=B7?= =?UTF-8?q?=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Открытые конфигурации фиксируются в локальном SQLite (app_settings) и передаются на сервер через qt_client_schema_state.open_config_uuids при каждом цикле синхронизации. Списки конфигураций обогащаются полем viewers, в таблицах отображается иконка глаза с подсказкой при наличии других пользователей, открывших эту конфигурацию. Co-Authored-By: Claude Sonnet 4.6 --- cmd/qfs/main.go | 57 ++++++++++++++++++++++++++- internal/localdb/localdb.go | 61 +++++++++++++++++++++++++++++ internal/services/sync/readiness.go | 12 +++++- internal/services/sync/service.go | 50 +++++++++++++++++++++++ web/templates/configs.html | 11 ++++++ web/templates/index.html | 10 +++++ web/templates/project_detail.html | 11 +++++- 7 files changed, 208 insertions(+), 4 deletions(-) diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index 06e81a9..22d2109 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -996,8 +996,27 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect return } + uuids := make([]string, len(cfgs)) + for i, cfg := range cfgs { + uuids[i] = cfg.UUID + } + viewers, _ := syncService.ListActiveViewersByConfigUUIDs(uuids) + + type cfgRow struct { + models.Configuration + Viewers []string `json:"viewers"` + } + rows := make([]cfgRow, len(cfgs)) + for i, cfg := range cfgs { + v := viewers[cfg.UUID] + if v == nil { + v = []string{} + } + rows[i] = cfgRow{Configuration: cfg, Viewers: v} + } + c.JSON(http.StatusOK, gin.H{ - "configurations": cfgs, + "configurations": rows, "total": total, "page": page, "per_page": perPage, @@ -1332,6 +1351,16 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect } c.JSON(http.StatusOK, config) }) + + configs.POST("/:uuid/presence", func(c *gin.Context) { + _ = local.AddOpenConfigUUID(c.Param("uuid")) + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + configs.DELETE("/:uuid/presence", func(c *gin.Context) { + _ = local.RemoveOpenConfigUUID(c.Param("uuid")) + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) } projects := api.Group("/projects") @@ -1672,8 +1701,32 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect } return } + + projUUIDs := make([]string, len(result.Configs)) + for i, cfg := range result.Configs { + projUUIDs[i] = cfg.UUID + } + projViewers, _ := syncService.ListActiveViewersByConfigUUIDs(projUUIDs) + + type projCfgRow struct { + models.Configuration + Viewers []string `json:"viewers"` + } + projRows := make([]projCfgRow, len(result.Configs)) + for i, cfg := range result.Configs { + v := projViewers[cfg.UUID] + if v == nil { + v = []string{} + } + projRows[i] = projCfgRow{Configuration: cfg, Viewers: v} + } + c.Header("X-Config-Status", status) - c.JSON(http.StatusOK, result) + c.JSON(http.StatusOK, gin.H{ + "project_uuid": result.ProjectUUID, + "configurations": projRows, + "total": result.Total, + }) }) projects.PATCH("/:uuid/configs/reorder", func(c *gin.Context) { diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index e35de47..0b47503 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -1235,6 +1235,67 @@ func (l *LocalDB) GetLastComponentSyncError() string { } +const openConfigUUIDsKey = "open_config_uuids" + +// GetOpenConfigUUIDs returns UUIDs of all configurations currently open in the configurator. +func (l *LocalDB) GetOpenConfigUUIDs() []string { + value, ok := l.getAppSettingValue(openConfigUUIDsKey) + if !ok || value == "" { + return nil + } + var uuids []string + if err := json.Unmarshal([]byte(value), &uuids); err != nil { + return nil + } + return uuids +} + +// AddOpenConfigUUID records that a configuration is open in the configurator. +func (l *LocalDB) AddOpenConfigUUID(uuid string) error { + uuids := l.GetOpenConfigUUIDs() + for _, u := range uuids { + if u == uuid { + return nil + } + } + uuids = append(uuids, uuid) + raw, err := json.Marshal(uuids) + if err != nil { + return err + } + return l.db.Exec(` + INSERT INTO app_settings (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `, openConfigUUIDsKey, string(raw), time.Now().Format(time.RFC3339)).Error +} + +// RemoveOpenConfigUUID records that a configuration is no longer open in the configurator. +func (l *LocalDB) RemoveOpenConfigUUID(uuid string) error { + uuids := l.GetOpenConfigUUIDs() + filtered := uuids[:0] + for _, u := range uuids { + if u != uuid { + filtered = append(filtered, u) + } + } + var raw []byte + var err error + if len(filtered) == 0 { + raw = []byte("[]") + } else { + raw, err = json.Marshal(filtered) + if err != nil { + return err + } + } + return l.db.Exec(` + INSERT INTO app_settings (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `, openConfigUUIDsKey, string(raw), time.Now().Format(time.RFC3339)).Error +} + // CountLocalPricelists returns the number of local pricelists func (l *LocalDB) CountLocalPricelists() int64 { var count int64 diff --git a/internal/services/sync/readiness.go b/internal/services/sync/readiness.go index cd2c84d..1af9266 100644 --- a/internal/services/sync/readiness.go +++ b/internal/services/sync/readiness.go @@ -249,6 +249,13 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time) pricelistItemsCount := s.localDB.CountAllPricelistItems() componentsCount := s.localDB.CountComponents() dbSizeBytes := s.localDB.DBFileSizeBytes() + openConfigUUIDs := s.localDB.GetOpenConfigUUIDs() + var openConfigUUIDsJSON *string + if len(openConfigUUIDs) > 0 { + raw, _ := json.Marshal(openConfigUUIDs) + s := string(raw) + openConfigUUIDsJSON = &s + } return mariaDB.Exec(` INSERT INTO qt_client_schema_state ( username, hostname, app_version, @@ -257,9 +264,10 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time) estimate_pricelist_version, warehouse_pricelist_version, competitor_pricelist_version, last_sync_error_code, last_sync_error_text, local_pricelist_count, pricelist_items_count, components_count, db_size_bytes, + open_config_uuids, last_checked_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE app_version = VALUES(app_version), last_sync_at = VALUES(last_sync_at), @@ -277,6 +285,7 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time) pricelist_items_count = VALUES(pricelist_items_count), components_count = VALUES(components_count), db_size_bytes = VALUES(db_size_bytes), + open_config_uuids = VALUES(open_config_uuids), last_checked_at = VALUES(last_checked_at), updated_at = VALUES(updated_at) `, username, hostname, appmeta.Version(), @@ -285,6 +294,7 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time) estimateVersion, warehouseVersion, competitorVersion, lastSyncErrorCode, lastSyncErrorText, localPricelistCount, pricelistItemsCount, componentsCount, dbSizeBytes, + openConfigUUIDsJSON, checkedAt, checkedAt).Error } diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go index 53bd30b..f5ed695 100644 --- a/internal/services/sync/service.go +++ b/internal/services/sync/service.go @@ -630,6 +630,56 @@ func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository. } } +// ListActiveViewersByConfigUUIDs returns a map of configUUID → []username for users +// who currently have those configs open (based on the last two sync cycles). +func (s *Service) ListActiveViewersByConfigUUIDs(uuids []string) (map[string][]string, error) { + if len(uuids) == 0 { + return map[string][]string{}, nil + } + mariaDB, err := s.getDB() + if err != nil || mariaDB == nil { + return map[string][]string{}, nil + } + selfUsername := strings.ToLower(strings.TrimSpace(s.localDB.GetDBUser())) + + type row struct { + Username string `gorm:"column:username"` + OpenConfigJSON string `gorm:"column:open_config_uuids"` + } + var rows []row + if err := mariaDB.Raw(` + SELECT username, open_config_uuids + FROM qt_client_schema_state + WHERE open_config_uuids IS NOT NULL + AND open_config_uuids != '[]' + AND last_checked_at > NOW() - INTERVAL 10 MINUTE + `).Scan(&rows).Error; err != nil { + return map[string][]string{}, nil + } + + wantSet := make(map[string]struct{}, len(uuids)) + for _, u := range uuids { + wantSet[u] = struct{}{} + } + + result := make(map[string][]string) + for _, r := range rows { + if strings.ToLower(strings.TrimSpace(r.Username)) == selfUsername { + continue + } + var openUUIDs []string + if err := json.Unmarshal([]byte(r.OpenConfigJSON), &openUUIDs); err != nil { + continue + } + for _, ou := range openUUIDs { + if _, ok := wantSet[ou]; ok { + result[ou] = append(result[ou], r.Username) + } + } + } + return result, nil +} + // ListUserSyncStatuses returns users who have recorded a client schema state check. func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyncStatus, error) { mariaDB, err := s.getDB() diff --git a/web/templates/configs.html b/web/templates/configs.html index b9a7d6e..2b47641 100644 --- a/web/templates/configs.html +++ b/web/templates/configs.html @@ -242,6 +242,7 @@ function renderConfigs(configs) { html += 'Цена (за 1 шт)'; html += 'Кол-во'; html += 'Сумма'; + html += ''; html += 'Действия'; html += ''; @@ -298,6 +299,16 @@ function renderConfigs(configs) { html += '' + pricePerUnit + ''; html += '' + serverCount + ''; html += '' + total + ''; + const viewers = c.viewers || []; + if (viewers.length > 0) { + const names = viewers.map(escapeHtml).join(', '); + html += ''; + html += ''; + html += ''; + html += ''; + } else { + html += ''; + } html += ''; if (configStatusMode === 'archived') { html += '