feat: reorder card layout + svc_cred_hint field for credentials note

This commit is contained in:
2026-04-28 12:20:37 +00:00
parent b06620a793
commit a64d49a8c1
4 changed files with 36 additions and 10 deletions
+4 -1
View File
@@ -199,6 +199,7 @@ class Service(Base):
comment: Mapped[str] = mapped_column(Text, default="")
svc_login: Mapped[str] = mapped_column(String(256), default="")
svc_password: Mapped[str] = mapped_column(String(256), default="")
svc_cred_hint: Mapped[str] = mapped_column(Text, default="")
icon_path: Mapped[str] = mapped_column(Text, default="")
active: Mapped[bool] = mapped_column(Boolean, default=True)
warm_pool_size: Mapped[int] = mapped_column(Integer, default=0)
@@ -1259,6 +1260,7 @@ def ensure_schema_compatibility() -> None:
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS comment TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_login VARCHAR(256) NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_password VARCHAR(256) NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_cred_hint TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS icon_path TEXT NOT NULL DEFAULT ''"))
conn.execute(
text(
@@ -2683,6 +2685,7 @@ def create_service(payload: dict, request: Request, _: User = Depends(require_ad
comment=payload.get("comment", ""),
svc_login=payload.get("svc_login", ""),
svc_password=payload.get("svc_password", ""),
svc_cred_hint=payload.get("svc_cred_hint", ""),
active=payload.get("active", True),
warm_pool_size=max(0, int(payload.get("warm_pool_size", 0))),
)
@@ -2747,7 +2750,7 @@ def edit_service(service_id: int, payload: dict, request: Request, _: User = Dep
service = db.get(Service, service_id)
if not service:
raise HTTPException(status_code=404, detail="Service not found")
for key in ["name", "slug", "target", "active", "comment", "svc_login", "svc_password"]:
for key in ["name", "slug", "target", "active", "comment", "svc_login", "svc_password", "svc_cred_hint"]:
if key in payload:
setattr(service, key, payload[key])
if "type" in payload:
+7
View File
@@ -890,3 +890,10 @@ button {
.svc-credentials + .tile-comment { margin-top: 0.5rem; }
.svc-cred-hint {
margin: 0.35rem 0 0;
font-size: 0.78rem;
color: #4a7090;
line-height: 1.35;
}
+20 -6
View File
@@ -123,7 +123,7 @@
<input class="list-search" id="web_search" placeholder="Поиск WEB сервиса..." oninput="filterList('web_search', '#web_list .web-item')" />
<div class="list-box" id="web_list">
{% for s in web_services %}
<button class="list-item service-row web-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectWebService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.svc_login|tojson}}, {{s.svc_password|tojson}})'>
<button class="list-item service-row web-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectWebService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.svc_login|tojson}}, {{s.svc_password|tojson}}, {{s.svc_cred_hint|tojson}})'>
<img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
<div>
<div>{{s.name}}</div>
@@ -168,6 +168,11 @@
<input id="w_svc_password" placeholder="Пароль для входа в сервис" />
</label>
<label class="field-col">
<span>Подсказка к логину/паролю (необязательно)</span>
<input id="w_svc_cred_hint" placeholder="Например: учётная запись гостя, сбрасывается раз в месяц" />
</label>
<label class="field-col">
<span>Статус</span>
<select id="w_active"><option value="true">active</option><option value="false">inactive</option></select>
@@ -243,6 +248,7 @@
<textarea id="new_w_comment" placeholder="Коротко: что это за сервис"></textarea>
<input id="new_w_svc_login" placeholder="Логин сервиса (необязательно)" />
<input id="new_w_svc_password" placeholder="Пароль сервиса (необязательно)" />
<input id="new_w_svc_cred_hint" placeholder="Подсказка к логину/паролю (необязательно)" />
</label>
<label class="field-col">
@@ -269,7 +275,7 @@
<input class="list-search" id="rdp_search" placeholder="Поиск RDP сервиса..." oninput="filterList('rdp_search', '#rdp_list .rdp-item')" />
<div class="list-box" id="rdp_list">
{% for s in rdp_services %}
<button class="list-item service-row rdp-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectRdpService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.warm_pool_size}}, {{s.svc_login|tojson}}, {{s.svc_password|tojson}})'>
<button class="list-item service-row rdp-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectRdpService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.warm_pool_size}}, {{s.svc_login|tojson}}, {{s.svc_password|tojson}}, {{s.svc_cred_hint|tojson}})'>
<img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
<div>
<div>{{s.name}}</div>
@@ -302,6 +308,7 @@
<textarea id="r_comment" placeholder="Описание для пользователя"></textarea>
<input id="r_svc_login" placeholder="Логин сервиса (показывается на карточке)" />
<input id="r_svc_password" placeholder="Пароль сервиса (показывается на карточке)" />
<input id="r_svc_cred_hint" placeholder="Подсказка к логину/паролю (необязательно)" />
<input id="r_pool" type="number" min="0" placeholder="Количество заранее прогретых слотов" />
<select id="r_active"><option value="true">active</option><option value="false">inactive</option></select>
</div>
@@ -387,6 +394,7 @@
<textarea id="new_r_comment" placeholder="Описание для пользователя"></textarea>
<input id="new_r_svc_login" placeholder="Логин сервиса (необязательно)" />
<input id="new_r_svc_password" placeholder="Пароль сервиса (необязательно)" />
<input id="new_r_svc_cred_hint" placeholder="Подсказка к логину/паролю (необязательно)" />
<input id="new_r_pool" type="number" min="0" value="1" placeholder="Количество прогретых слотов" />
<select id="new_r_active"><option value="true">active</option><option value="false">inactive</option></select>
</div>
@@ -725,7 +733,7 @@
location.reload();
}
function selectWebService(id, name, slug, target, comment, iconPath, active, svcLogin, svcPassword) {
function selectWebService(id, name, slug, target, comment, iconPath, active, svcLogin, svcPassword, svcCredHint) {
document.getElementById('w_id').value = id;
document.getElementById('w_name').value = name;
document.getElementById('w_slug').value = slug;
@@ -733,6 +741,7 @@
document.getElementById('w_comment').value = comment || '';
document.getElementById('w_svc_login').value = svcLogin || '';
document.getElementById('w_svc_password').value = svcPassword || '';
document.getElementById('w_svc_cred_hint').value = svcCredHint || '';
document.getElementById('w_active').value = String(active);
document.getElementById('w_icon_preview').src = iconPath || placeholderIcon;
setCategoryChecks('.w_cat', serviceCategoryMap[id] || []);
@@ -757,6 +766,7 @@
comment: document.getElementById('new_w_comment').value,
svc_login: document.getElementById('new_w_svc_login').value,
svc_password: document.getElementById('new_w_svc_password').value,
svc_cred_hint: document.getElementById('new_w_svc_cred_hint').value,
category_ids: checkedCategoryIds('.new_w_cat'),
active: document.getElementById('new_w_active').value === 'true',
});
@@ -775,6 +785,7 @@
comment: document.getElementById('w_comment').value,
svc_login: document.getElementById('w_svc_login').value,
svc_password: document.getElementById('w_svc_password').value,
svc_cred_hint: document.getElementById('w_svc_cred_hint').value,
category_ids: checkedCategoryIds('.w_cat'),
active: document.getElementById('w_active').value === 'true',
});
@@ -782,7 +793,7 @@
}
function clearWebForm() {
['w_id','w_name','w_slug','w_target','w_comment','w_svc_login','w_svc_password'].forEach(id => document.getElementById(id).value = '');
['w_id','w_name','w_slug','w_target','w_comment','w_svc_login','w_svc_password','w_svc_cred_hint'].forEach(id => document.getElementById(id).value = '');
document.getElementById('w_active').value = 'true';
setCategoryChecks('.w_cat', []);
document.getElementById('w_icon_preview').src = placeholderIcon;
@@ -865,7 +876,7 @@
location.reload();
}
function selectRdpService(id, name, slug, target, comment, iconPath, active, pool, svcLogin, svcPassword) {
function selectRdpService(id, name, slug, target, comment, iconPath, active, pool, svcLogin, svcPassword, svcCredHint) {
const cfg = parseRdpTarget(target);
document.getElementById('r_id').value = id;
document.getElementById('r_name').value = name;
@@ -878,6 +889,7 @@
document.getElementById('r_comment').value = comment || '';
document.getElementById('r_svc_login').value = svcLogin || '';
document.getElementById('r_svc_password').value = svcPassword || '';
document.getElementById('r_svc_cred_hint').value = svcCredHint || '';
document.getElementById('r_active').value = String(active);
document.getElementById('r_pool').value = pool;
setCategoryChecks('.r_cat', serviceCategoryMap[id] || []);
@@ -899,6 +911,7 @@
comment: document.getElementById('new_r_comment').value,
svc_login: document.getElementById('new_r_svc_login').value,
svc_password: document.getElementById('new_r_svc_password').value,
svc_cred_hint: document.getElementById('new_r_svc_cred_hint').value,
category_ids: checkedCategoryIds('.new_r_cat'),
warm_pool_size: parseInt(document.getElementById('new_r_pool').value || '0', 10),
active: document.getElementById('new_r_active').value === 'true',
@@ -918,6 +931,7 @@
comment: document.getElementById('r_comment').value,
svc_login: document.getElementById('r_svc_login').value,
svc_password: document.getElementById('r_svc_password').value,
svc_cred_hint: document.getElementById('r_svc_cred_hint').value,
category_ids: checkedCategoryIds('.r_cat'),
warm_pool_size: parseInt(document.getElementById('r_pool').value || '0', 10),
active: document.getElementById('r_active').value === 'true',
@@ -926,7 +940,7 @@
}
function clearRdpForm() {
['r_id','r_name','r_slug','r_target','r_host','r_port','r_domain','r_comment','r_pool','r_svc_login','r_svc_password'].forEach(id => document.getElementById(id).value = '');
['r_id','r_name','r_slug','r_target','r_host','r_port','r_domain','r_comment','r_pool','r_svc_login','r_svc_password','r_svc_cred_hint'].forEach(id => document.getElementById(id).value = '');
document.getElementById('rdp_slots_box').style.display = 'none';
document.getElementById('r_sec').value = '';
document.getElementById('r_active').value = 'true';
+5 -3
View File
@@ -82,6 +82,7 @@
<div class="tile-icon-box">
<img class="tile-icon" src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
</div>
<h3>{{ service.name }}</h3>
{% if service.svc_login or service.svc_password %}
<div class="svc-credentials" onclick="event.preventDefault();event.stopPropagation()">
{% if service.svc_login %}
@@ -98,13 +99,14 @@
<button class="svc-cred-copy" type="button" data-copy="{{ service.svc_password }}" title="Копировать пароль"></button>
</div>
{% endif %}
{% if service.svc_cred_hint %}
<p class="svc-cred-hint">{{ service.svc_cred_hint }}</p>
{% endif %}
</div>
{% endif %}
{% if service.comment %}
<small class="tile-comment">{{ service_comment_html.get(service.id, '') }}</small>
<div class="tile-comment">{{ service_comment_html.get(service.id, '') }}</div>
{% endif %}
<h3>{{ service.name }}</h3>
<p>Открыть сервис</p>
{% if svc_cats %}
<div class="service-categories">
{% for category in svc_cats %}