feat: индикатор присутствия в конфигурациях (иконка глаза)

Открытые конфигурации фиксируются в локальном SQLite (app_settings) и
передаются на сервер через qt_client_schema_state.open_config_uuids при
каждом цикле синхронизации. Списки конфигураций обогащаются полем viewers,
в таблицах отображается иконка глаза с подсказкой при наличии других
пользователей, открывших эту конфигурацию.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-06-30 00:56:06 +03:00
parent 9601619d1b
commit 50f0e4f76f
7 changed files with 208 additions and 4 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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()