feat: «глазик» в поле пароля при входе и смена своего пароля (сайт — своя карточка, приложение — «Настройки»); Android 1.11.0

This commit is contained in:
darkorion
2026-10-16 16:41:52 +03:00
parent 4f2aed1770
commit 4cebf300aa
17 changed files with 184 additions and 12 deletions
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "ru.checklists.app" applicationId = "ru.checklists.app"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 16 versionCode = 17
versionName = "1.10.0" versionName = "1.11.0"
} }
signingConfigs { signingConfigs {
@@ -103,6 +103,12 @@ class Repository(
/** Сообщение о смене режима (показывается один раз). */ /** Сообщение о смене режима (показывается один раз). */
val modeChanged = MutableStateFlow<String?>(null) val modeChanged = MutableStateFlow<String?>(null)
/** Сменить свой пароль: другие сессии завершаются, это устройство получает новые токены. */
suspend fun changePassword(old: String, new: String) {
val t = client.api().changePassword(ChangePasswordReq(old, new))
prefs.access = t.accessToken; prefs.refresh = t.refreshToken
}
/** Выйти из демо (если включил сам или включил администратор) и вернуться к рабочим данным. */ /** Выйти из демо (если включил сам или включил администратор) и вернуться к рабочим данным. */
suspend fun leaveDemo() { suspend fun leaveDemo() {
val t = client.api().demoLeave() val t = client.api().demoLeave()
@@ -28,6 +28,9 @@ interface Api {
@POST("auth/login") @POST("auth/login")
suspend fun login(@Body b: LoginReq): TokenRes suspend fun login(@Body b: LoginReq): TokenRes
@POST("auth/change-password")
suspend fun changePassword(@Body b: ChangePasswordReq): TokenRes
@POST("auth/logout") @POST("auth/logout")
suspend fun logout(@Body b: RefreshReq) suspend fun logout(@Body b: RefreshReq)
@@ -6,6 +6,9 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class LoginReq(val username: String, val password: String, val device: String = "android") data class LoginReq(val username: String, val password: String, val device: String = "android")
@Serializable
data class ChangePasswordReq(@SerialName("old_password") val oldPassword: String, @SerialName("new_password") val newPassword: String)
@Serializable @Serializable
data class RefreshReq(@SerialName("refresh_token") val refreshToken: String, val device: String = "android") data class RefreshReq(@SerialName("refresh_token") val refreshToken: String, val device: String = "android")
@@ -30,7 +30,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.checklists.app.data.api.humanMessage import ru.checklists.app.data.api.humanMessage
@@ -79,8 +78,7 @@ fun LoginScreen(onDone: () -> Unit) {
OutlinedTextField(url, { url = it }, label = { Text("Адрес сервера") }, placeholder = { Text("https://checklists.example.ru") }, OutlinedTextField(url, { url = it }, label = { Text("Адрес сервера") }, placeholder = { Text("https://checklists.example.ru") },
singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)) singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri))
OutlinedTextField(user, { user = it }, label = { Text("Логин") }, singleLine = true, modifier = Modifier.fillMaxWidth()) OutlinedTextField(user, { user = it }, label = { Text("Логин") }, singleLine = true, modifier = Modifier.fillMaxWidth())
OutlinedTextField(pass, { pass = it }, label = { Text("Пароль") }, singleLine = true, modifier = Modifier.fillMaxWidth(), PasswordField(pass, { pass = it }, "Пароль")
visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
error?.let { Text(it, color = MaterialTheme.colorScheme.error) } error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Button(onClick = ::go, enabled = !busy && url.isNotBlank() && user.isNotBlank() && pass.isNotEmpty(), modifier = Modifier.fillMaxWidth()) { Button(onClick = ::go, enabled = !busy && url.isNotBlank() && user.isNotBlank() && pass.isNotEmpty(), modifier = Modifier.fillMaxWidth()) {
if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) else Text("Войти") if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) else Text("Войти")
@@ -0,0 +1,88 @@
package ru.checklists.app.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import ru.checklists.app.data.api.humanMessage
import ru.checklists.app.repo
private const val PASSWORD_MIN = 10 // как на сервере (app/schemas.py)
/** Поле пароля с «глазиком»: показать или скрыть введённое. */
@Composable
fun PasswordField(value: String, onChange: (String) -> Unit, label: String, modifier: Modifier = Modifier.fillMaxWidth(), supporting: String? = null) {
var show by remember { mutableStateOf(false) }
OutlinedTextField(value, onChange, label = { Text(label) }, singleLine = true, modifier = modifier,
visualTransformation = if (show) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
supportingText = supporting?.let { { Text(it) } },
trailingIcon = {
val hint = if (show) "Скрыть пароль" else "Показать пароль"
IconButton({ show = !show }, Modifier.semantics { contentDescription = hint }) { Text(if (show) "🙈" else "👁") }
})
}
/** Смена своего пароля: текущий, новый и повтор. Другие сессии завершаются, это устройство остаётся в системе. */
@Composable
fun ChangePasswordDialog(onDismiss: () -> Unit, onDone: () -> Unit) {
val repo = LocalContext.current.repo
val scope = rememberCoroutineScope()
var old by remember { mutableStateOf("") }
var new by remember { mutableStateOf("") }
var repeat by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
fun validate(): String? = when {
old.isEmpty() -> "Введите текущий пароль"
new.length < PASSWORD_MIN -> "Новый пароль — не короче $PASSWORD_MIN символов"
new != repeat -> "Новые пароли не совпадают"
new == old -> "Новый пароль совпадает с текущим"
else -> null
}
AlertDialog(onDismissRequest = { if (!busy) onDismiss() }, title = { Text("Смена пароля") }, text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
PasswordField(old, { old = it }, "Текущий пароль")
PasswordField(new, { new = it }, "Новый пароль", supporting = "Не короче $PASSWORD_MIN символов")
PasswordField(repeat, { repeat = it }, "Новый пароль ещё раз")
Text("После смены на других устройствах и на сайте нужно будет войти заново.",
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary)
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
}, confirmButton = {
TextButton({
error = validate()
if (error == null) {
busy = true
scope.launch {
try { repo.changePassword(old, new); onDone() }
catch (e: Throwable) { error = e.humanMessage() }
finally { busy = false }
}
}
}, enabled = !busy) { Text("Сменить") }
}, dismissButton = { TextButton(onDismiss, enabled = !busy) { Text("Отмена") } })
}
@@ -45,6 +45,8 @@ fun SettingsScreen(onLoggedOut: () -> Unit) {
val error by repo.lastError.collectAsState() val error by repo.lastError.collectAsState()
var confirmLogout by remember { mutableStateOf(false) } var confirmLogout by remember { mutableStateOf(false) }
var unsynced by remember { mutableStateOf(0) } var unsynced by remember { mutableStateOf(0) }
var changePass by remember { mutableStateOf(false) }
val ctx = LocalContext.current
Scaffold(topBar = { Scaffold(topBar = {
ScreenBar(title = { Text("Настройки") }) ScreenBar(title = { Text("Настройки") })
@@ -55,6 +57,7 @@ fun SettingsScreen(onLoggedOut: () -> Unit) {
me?.position?.let { Text("${it.name} · " + if (me?.onShift == true) "на смене" else if (me?.onShift == false) "не на смене" else "") } me?.position?.let { Text("${it.name} · " + if (me?.onShift == true) "на смене" else if (me?.onShift == false) "не на смене" else "") }
if (me?.isAdmin == true) Text("Администратор") else if (me?.isModerator == true) Text("Модератор") if (me?.isAdmin == true) Text("Администратор") else if (me?.isModerator == true) Text("Модератор")
if (me?.isStorekeeper == true) Text("Администратор склада") if (me?.isStorekeeper == true) Text("Администратор склада")
if (me?.demoMode != true) OutlinedButton({ changePass = true }, Modifier.fillMaxWidth()) { Text("🔑 Сменить пароль") }
Text("Сервер", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.secondary, modifier = Modifier.padding(top = 8.dp)) Text("Сервер", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.secondary, modifier = Modifier.padding(top = 8.dp))
Text(repo.prefs.baseUrl) Text(repo.prefs.baseUrl)
@@ -77,6 +80,11 @@ fun SettingsScreen(onLoggedOut: () -> Unit) {
} }
} }
if (changePass) ChangePasswordDialog(onDismiss = { changePass = false }) {
changePass = false
android.widget.Toast.makeText(ctx, "Пароль изменён", android.widget.Toast.LENGTH_LONG).show()
}
if (confirmLogout) AlertDialog( if (confirmLogout) AlertDialog(
onDismissRequest = { confirmLogout = false }, onDismissRequest = { confirmLogout = false },
title = { Text("Выйти из аккаунта?") }, title = { Text("Выйти из аккаунта?") },
+1
View File
@@ -45,6 +45,7 @@
- Не вижу наряд: наряд на должность виден только сотрудникам этой должности, которые сейчас на смене по своему графику; проверьте график в своей карточке. - Не вижу наряд: наряд на должность виден только сотрудникам этой должности, которые сейчас на смене по своему графику; проверьте график в своей карточке.
- График работы задаётся сотруднику (#/users → «Изменить» → «График работы»), не должности. Одну должность могут занимать сменщики: наряды и уведомления должности получает только тот, кто на смене. Без графика — всегда на смене. - График работы задаётся сотруднику (#/users → «Изменить» → «График работы»), не должности. Одну должность могут занимать сменщики: наряды и уведомления должности получает только тот, кто на смене. Без графика — всегда на смене.
- Фото к пункту чек-листа или к заданию можно отметить «хранить более года» (в конструкторе чек-листа рядом с режимом фото, в форме задания) — такие фото не удаляются автоочисткой. Автоочистку (удалять фото старше N дней) включает администратор портала в #/server. - Фото к пункту чек-листа или к заданию можно отметить «хранить более года» (в конструкторе чек-листа рядом с режимом фото, в форме задания) — такие фото не удаляются автоочисткой. Автоочистку (удалять фото старше N дней) включает администратор портала в #/server.
- Смена своего пароля: своя карточка (имя вверху меню, #/user/<свой id>) → блок «🔑 Пароль» → «Сменить пароль»; в приложении Android — «Настройки» → «Сменить пароль». Нужен текущий пароль; другие устройства выйдут из системы. Забытый пароль сбрасывает администратор или модератор в #/users. При входе значок 👁 показывает введённый пароль.
- Мониторинг сервера — #/monitor (администратор портала): загрузка процессора, память, место на дисках. - Мониторинг сервера — #/monitor (администратор портала): загрузка процессора, память, место на дисках.
- Фильтр «Место» над списком оборудования #/equipment и кранов: показывает машины места и вложенных мест. - Фильтр «Место» над списком оборудования #/equipment и кранов: показывает машины места и вложенных мест.
- Мастер первичной настройки: «Завершить и проверить» открывает проверку готовности #/setup/done — что мешает работе и где поправить. - Мастер первичной настройки: «Завершить и проверить» открывает проверку готовности #/setup/done — что мешает работе и где поправить.
+14 -1
View File
@@ -180,6 +180,19 @@ async function route() {
} }
window.addEventListener("hashchange", route); window.addEventListener("hashchange", route);
// поле пароля с «глазиком»: показать или скрыть введённое
function pwEye(input) {
const btn = h("button", { type: "button", class: "pweye", title: "Показать пароль", "aria-label": "Показать пароль" }, "👁");
btn.onclick = () => {
const show = input.type === "password";
input.type = show ? "text" : "password";
btn.textContent = show ? "🙈" : "👁";
btn.title = show ? "Скрыть пароль" : "Показать пароль";
btn.setAttribute("aria-label", btn.title);
};
return h("div", { class: "pwwrap" }, input, btn);
}
function loginPage() { function loginPage() {
$top.replaceChildren(); $banner.replaceChildren(); document.getElementById("appfoot")?.replaceChildren(); $top.replaceChildren(); $banner.replaceChildren(); document.getElementById("appfoot")?.replaceChildren();
const u = h("input", { autofocus: true, autocomplete: "username" }), p = h("input", { type: "password", autocomplete: "current-password" }); const u = h("input", { autofocus: true, autocomplete: "username" }), p = h("input", { type: "password", autocomplete: "current-password" });
@@ -205,7 +218,7 @@ function loginPage() {
}).catch(() => {}); }).catch(() => {});
fetch(API + "/demo/status").then(r => r.json()).then(d => { if (d.active) note.style.display = ""; }).catch(() => {}); fetch(API + "/demo/status").then(r => r.json()).then(d => { if (d.active) note.style.display = ""; }).catch(() => {});
$app.replaceChildren(h("div", { class: "card login" }, h("h1", {}, "Вход"), note, $app.replaceChildren(h("div", { class: "card login" }, h("h1", {}, "Вход"), note,
h("label", {}, "Логин"), u, h("div", { style: "height:10px" }), h("label", {}, "Пароль"), p, h("label", {}, "Логин"), u, h("div", { style: "height:10px" }), h("label", {}, "Пароль"), pwEye(p),
h("div", { style: "height:14px" }), h("button", { onclick: go }, "Войти"), h("div", { style: "margin-top:14px" }, apk), docs)); h("div", { style: "height:14px" }), h("button", { onclick: go }, "Войти"), h("div", { style: "margin-top:14px" }, apk), docs));
} }
+4
View File
@@ -81,3 +81,7 @@ body.menu-open #shade{display:block;position:fixed;inset:0;background:rgba(0,0,0
.fbm{opacity:.7;font-size:13px} .fbm{opacity:.7;font-size:13px}
.fbmodal{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:200;padding:14px} .fbmodal{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:200;padding:14px}
.fbmodal .card{width:100%;max-width:520px}.fbmodal textarea{width:100%;margin-top:8px} .fbmodal .card{width:100%;max-width:520px}.fbmodal textarea{width:100%;margin-top:8px}
/* поле пароля с «глазиком» */
.pwwrap{position:relative}.pwwrap input{width:100%;padding-right:44px}
.pweye{position:absolute;right:4px;top:50%;transform:translateY(-50%);background:transparent;border:0;padding:4px 8px;font-size:18px;cursor:pointer;color:inherit;width:auto;margin:0}
+15 -2
View File
@@ -399,7 +399,7 @@ function renderDrawer() {
h("a", { href: "#", class: route.page === "list" ? "on" : "", onclick: e => { e.preventDefault(); go("")(); } }, h("i", {}, "📋"), "Наряды"), h("a", { href: "#", class: route.page === "list" ? "on" : "", onclick: e => { e.preventDefault(); go("")(); } }, h("i", {}, "📋"), "Наряды"),
h("a", { href: "#/s", class: route.page === "settings" ? "on" : "", onclick: e => { e.preventDefault(); go("#/s")(); } }, h("i", {}, "⚙"), "Настройки и синхронизация"), h("a", { href: "#/s", class: route.page === "settings" ? "on" : "", onclick: e => { e.preventDefault(); go("#/s")(); } }, h("i", {}, "⚙"), "Настройки и синхронизация"),
h("div", { class: "navh" }, "Портал (нужна связь)"), h("div", { class: "navh" }, "Портал (нужна связь)"),
web("#/work", "🎯", "Задачи: всё в одном месте"), web("#/tasks", "🗓", "Задачи и поручения"), web("#/equipment", "🔧", "Оборудование и инциденты"), web("#/faults", "⚠", "Неисправности"), web("#/map", "🗺", "Карта завода"), me.crane_map ? web("#/cranes", "🏗", "Краны") : null, me.crane_map ? web("#/map/cranes", "🗺", "Карта кранов") : null, web("#/work", "🎯", "Задачи: всё в одном месте"), web("#/tasks", "🗓", "Задачи и поручения"), web("#/equipment", "🔧", "Оборудование и отказы"), web("#/faults", "⚠", "Неисправности"), web("#/map", "🗺", "Карта завода"), me.crane_map ? web("#/cranes", "🏗", "Краны") : null, me.crane_map ? web("#/map/cranes", "🗺", "Карта кранов") : null,
web("#/my-tools", "🧰", "Мой инструмент"), web("#/requests", "🛒", "Заявки на инструмент"), web("#/my-tools", "🧰", "Мой инструмент"), web("#/requests", "🛒", "Заявки на инструмент"),
store || eng ? web("#/inventory", "🏬", "Склады и учёт") : null, store || eng ? web("#/inventory", "🏬", "Склады и учёт") : null,
web("#/people", "👥", "Сотрудники"), web("#/user/" + me.id, "👤", "Моя карточка"), web("#/people", "👥", "Сотрудники"), web("#/user/" + me.id, "👤", "Моя карточка"),
@@ -448,6 +448,19 @@ const badges = o => h("span", { class: "row", style: "gap:6px" }, h("span", { cl
const progress = o => { const t = (o.items || []).length; return t ? [(o.items || []).filter(itemDone).length, t] : null; }; const progress = o => { const t = (o.items || []).length; return t ? [(o.items || []).filter(itemDone).length, t] : null; };
// ----- вход ----- // ----- вход -----
// поле пароля с «глазиком»: показать или скрыть введённое
function pwEye(input) {
const btn = h("button", { type: "button", class: "pweye", title: "Показать пароль", "aria-label": "Показать пароль" }, "👁");
btn.onclick = () => {
const show = input.type === "password";
input.type = show ? "text" : "password";
btn.textContent = show ? "🙈" : "👁";
btn.title = show ? "Скрыть пароль" : "Показать пароль";
btn.setAttribute("aria-label", btn.title);
};
return h("div", { class: "pwwrap" }, input, btn);
}
function loginView(my) { function loginView(my) {
setBar(h("h1", {}, "Чек-листы")); setBar(h("h1", {}, "Чек-листы"));
const u = h("input", { autocomplete: "username", autocapitalize: "none", autocorrect: "off", placeholder: "Логин" }), p = h("input", { type: "password", autocomplete: "current-password", placeholder: "Пароль" }); const u = h("input", { autocomplete: "username", autocapitalize: "none", autocorrect: "off", placeholder: "Логин" }), p = h("input", { type: "password", autocomplete: "current-password", placeholder: "Пароль" });
@@ -472,7 +485,7 @@ function loginView(my) {
const ios = /iPhone|iPad|iPod/i.test(navigator.userAgent) && !navigator.standalone; const ios = /iPhone|iPad|iPod/i.test(navigator.userAgent) && !navigator.standalone;
commit(my, h("div", { class: "login stack" }, h("h1", {}, "🔧 Чек-листы"), h("p", { class: "muted" }, "Обходы и обслуживание оборудования"), commit(my, h("div", { class: "login stack" }, h("h1", {}, "🔧 Чек-листы"), h("p", { class: "muted" }, "Обходы и обслуживание оборудования"),
sync.error && hasSession() === false && sync.error.startsWith("Сессия") ? h("p", { class: "warn" }, sync.error) : null, sync.error && hasSession() === false && sync.error.startsWith("Сессия") ? h("p", { class: "warn" }, sync.error) : null,
u, p, err, btn, apk, u, pwEye(p), err, btn, apk,
ios ? h("p", { class: "small muted" }, "Чтобы установить: нажмите «Поделиться» → «На экран Домой».") : null, ios ? h("p", { class: "small muted" }, "Чтобы установить: нажмите «Поделиться» → «На экран Домой».") : null,
h("p", { class: "small" }, h("a", { href: "/", onclick: () => localStorage.setItem("stay_desktop", "1") }, "Полная версия сайта")))); h("p", { class: "small" }, h("a", { href: "/", onclick: () => localStorage.setItem("stay_desktop", "1") }, "Полная версия сайта"))));
} }
+24
View File
@@ -808,6 +808,29 @@ async function peoplePage() {
draw(); draw();
} }
// Смена своего пароля: старый, новый и повтор. Остальные сессии завершаются, эта получает новые токены.
function passwordCard() {
const inp = ac => h("input", { type: "password", autocomplete: ac });
const old = inp("current-password"), nw = inp("new-password"), rep = inp("new-password");
const body = h("div", { style: "display:none;margin-top:8px" },
h("div", { class: "grid" }, field("Текущий пароль", pwEye(old)), field("Новый пароль", pwEye(nw), "Не короче 10 символов"), field("Новый пароль ещё раз", pwEye(rep))),
h("div", { class: "row", style: "margin-top:10px" },
h("button", { onclick: guard(async () => {
if (!old.value) throw new Error("Введите текущий пароль");
if (nw.value.length < 10) throw new Error("Новый пароль — не короче 10 символов");
if (nw.value !== rep.value) throw new Error("Новые пароли не совпадают");
if (nw.value === old.value) throw new Error("Новый пароль совпадает с текущим");
const t = await api("POST", "/auth/change-password", { old_password: old.value, new_password: nw.value });
token = t.access_token; localStorage.setItem("token", token); localStorage.setItem("refresh", t.refresh_token);
old.value = nw.value = rep.value = ""; body.style.display = "none";
toast("Пароль изменён. На других устройствах войдите заново");
}) }, "Сменить пароль"),
h("button", { class: "sec", onclick: () => { body.style.display = "none"; } }, "Отмена")));
return h("div", { class: "card" }, h("div", { class: "row" }, h("h2", { style: "margin:0;flex:1" }, "🔑 Пароль"),
h("button", { class: "sec sm", onclick: () => { body.style.display = body.style.display === "none" ? "" : "none"; if (body.style.display === "") old.focus(); } }, "Сменить пароль")),
h("div", { class: "small muted", style: "margin-top:4px" }, "После смены пароля вход на других устройствах и в приложении нужно будет выполнить заново."), body);
}
async function userCardPage(id) { async function userCardPage(id) {
const c = await api("GET", `/users/${id}/card`); const c = await api("GET", `/users/${id}/card`);
const u = c.user, reload = () => userCardPage(id); const u = c.user, reload = () => userCardPage(id);
@@ -824,6 +847,7 @@ async function userCardPage(id) {
u.department ? h("div", { style: "margin-top:2px" }, "🏢 ", h("a", { href: "#/people?dep=" + u.department.id }, u.department.name)) : null, u.department ? h("div", { style: "margin-top:2px" }, "🏢 ", h("a", { href: "#/people?dep=" + u.department.id }, u.department.name)) : null,
u.position ? h("div", { class: "small", style: "margin-top:4px" }, u.on_shift ? "🟢 Сейчас на смене" : "⚪ Сейчас не на смене") : null, u.position ? h("div", { class: "small", style: "margin-top:4px" }, u.on_shift ? "🟢 Сейчас на смене" : "⚪ Сейчас не на смене") : null,
u.phone && !c.can_edit ? h("div", { style: "margin-top:6px" }, "📞 ", h("a", { href: "tel:" + u.phone.replace(/[^+\d]/g, "") }, u.phone)) : null)), u.phone && !c.can_edit ? h("div", { style: "margin-top:6px" }, "📞 ", h("a", { href: "tel:" + u.phone.replace(/[^+\d]/g, "") }, u.phone)) : null)),
+id === me.id ? passwordCard() : null,
h("div", { class: "cols" }, h("div", { class: "cols" },
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "График"), h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "График"),
c.schedule.name ? h("div", { class: "small muted", style: "margin-bottom:6px" }, c.schedule.name) : null, c.schedule.name ? h("div", { class: "small muted", style: "margin-bottom:6px" }, c.schedule.name) : null,
+4
View File
@@ -306,3 +306,7 @@ button.fault{background:var(--fault);border-color:var(--fault);color:#fff}
/* кнопка закрытия отказа и неисправности: крупная и заметная */ /* кнопка закрытия отказа и неисправности: крупная и заметная */
button.closebtn{background:#16a34a;border:1px solid #16a34a;color:#fff;font-size:16px;font-weight:700;padding:10px 20px;border-radius:10px} button.closebtn{background:#16a34a;border:1px solid #16a34a;color:#fff;font-size:16px;font-weight:700;padding:10px 20px;border-radius:10px}
button.closebtn:hover{filter:brightness(1.08)} button.closebtn:hover{filter:brightness(1.08)}
/* поле пароля с «глазиком» */
.pwwrap{position:relative}.pwwrap input{width:100%;padding-right:44px}
.pweye{position:absolute;right:4px;top:50%;transform:translateY(-50%);background:transparent;border:0;padding:4px 8px;font-size:18px;cursor:pointer;color:inherit;width:auto;margin:0}
Binary file not shown.
+2 -2
View File
@@ -1,9 +1,9 @@
# Готовый APK # Готовый APK
`checklists-1.10.0.apk` — подписанное release-приложение для Android (версия 1.10.0, versionCode 16; подписи v2+v3, только HTTPS). Сервер раздаёт его по `/app.apk` `checklists-1.11.0.apk` — подписанное release-приложение для Android (версия 1.11.0, versionCode 17; подписи v2+v3, только HTTPS). Сервер раздаёт его по `/app.apk`
и показывает ссылку на экране входа, если ничего не загружено в `DATA_DIR/apk/` (загруженный вручную файл важнее). и показывает ссылку на экране входа, если ничего не загружено в `DATA_DIR/apk/` (загруженный вручную файл важнее).
SHA-256: `b2c8d8152fa5c9b19eab34c9341ed574fb24d2c0e64294e8c4cc56644fbada14` SHA-256: `49ed6928967ab665c7171ce64ed1491ab3d1245d03928c74fe2437630ab0db8e`
Обновление: соберите новый APK (см. android/README.md), замените файл здесь, закоммитьте и передеплойте — либо просто Обновление: соберите новый APK (см. android/README.md), замените файл здесь, закоммитьте и передеплойте — либо просто
положите новый файл в том `appdata` по пути `apk/` (без передеплоя). положите новый файл в том `appdata` по пути `apk/` (без передеплоя).
+9 -2
View File
@@ -386,7 +386,8 @@ def build():
"он зададит новый.") "он зададит новый.")
pdf.h2("Вход на сайте") pdf.h2("Вход на сайте")
pdf.wide("d_login", "Страница входа", width=120) pdf.wide("d_login", "Страница входа", width=120)
pdf.p("Введите логин и пароль и нажмите «Войти». Если администратор загрузил приложение для Android, под кнопкой будет " pdf.p("Введите логин и пароль и нажмите «Войти». Значок-глаз в поле пароля показывает введённое (ещё раз — скрывает): удобно "
"проверить пароль перед входом. Если администратор загрузил приложение для Android, под кнопкой будет "
"ссылка для скачивания, а ниже — ссылки на **инструкцию** и **презентацию** системы. После нескольких неверных попыток " "ссылка для скачивания, а ниже — ссылки на **инструкцию** и **презентацию** системы. После нескольких неверных попыток "
"подряд вход на 5 минут блокируется — подождите и повторите.") "подряд вход на 5 минут блокируется — подождите и повторите.")
@@ -843,6 +844,11 @@ def build():
pdf.note("Фото, телефон и «что умею» заполняет сам сотрудник в своей карточке (нажмите своё имя вверху бокового меню); " pdf.note("Фото, телефон и «что умею» заполняет сам сотрудник в своей карточке (нажмите своё имя вверху бокового меню); "
"модератор и администратор тоже могут их изменить. Выданный инструмент коллеге не показывается — только самому " "модератор и администратор тоже могут их изменить. Выданный инструмент коллеге не показывается — только самому "
"сотруднику, складу и инженерам.", "info") "сотруднику, складу и инженерам.", "info")
pdf.h2("Смена своего пароля")
pdf.p("Сменить свой пароль можно самому, без администратора. На сайте: нажмите своё имя вверху бокового меню → блок **«Пароль»** → "
"«Сменить пароль»: текущий пароль, новый (не короче 10 символов) и ещё раз новый. В приложении для Android: «Настройки» → "
"**«Сменить пароль»**. На этом устройстве вы останетесь в системе, а на остальных (и в приложении на телефоне) нужно будет "
"войти заново с новым паролем. Забытый пароль сбрасывает администратор или модератор."),
# ================= 6 ================= # ================= 6 =================
pdf.h1("13. Администратор и модератор") pdf.h1("13. Администратор и модератор")
@@ -1031,7 +1037,8 @@ def build():
"У одного из обслуживаемых узлов машины подходит или уже наступил срок обслуживания. Откройте карточку машины: узлы " "У одного из обслуживаемых узлов машины подходит или уже наступил срок обслуживания. Откройте карточку машины: узлы "
"со статусом «Скоро» и «Требуется» показаны вверху списка."), "со статусом «Скоро» и «Требуется» показаны вверху списка."),
("Я забыл пароль.", ("Я забыл пароль.",
"Обратитесь к администратору или модератору: в разделе «Пользователи» он задаст новый пароль."), "Обратитесь к администратору или модератору: в разделе «Пользователи» он задаст новый пароль. Затем смените его на свой: "
"своя карточка → «Пароль» (в приложении — «Настройки» → «Сменить пароль»)."),
("Время в нарядах отличается от времени на телефоне.", ("Время в нарядах отличается от времени на телефоне.",
"Сервер использует часовой пояс организации. Сроки и отчёты показываются в нём; время на телефоне может отличаться, если у вас " "Сервер использует часовой пояс организации. Сроки и отчёты показываются в нём; время на телефоне может отличаться, если у вас "
"другой часовой пояс."), "другой часовой пояс."),