Compare commits
2 Commits
77e31bd64a
...
4c10265db9
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c10265db9 | |||
| 8ee33b6db9 |
@@ -1628,6 +1628,7 @@ OPERATION_LABELS = {
|
|||||||
"admin-approve-correction": "审核批准上课记录纠错",
|
"admin-approve-correction": "审核批准上课记录纠错",
|
||||||
"admin-approve-deletion": "审核批准上课记录删除",
|
"admin-approve-deletion": "审核批准上课记录删除",
|
||||||
"admin-update-course-summary-time": "课程小结补齐时间",
|
"admin-update-course-summary-time": "课程小结补齐时间",
|
||||||
|
"admin-update-course-summary-body": "课程小结修改正文",
|
||||||
"admin-delete-course-summary": "课程小结删除",
|
"admin-delete-course-summary": "课程小结删除",
|
||||||
"rollback-operation": "撤回操作",
|
"rollback-operation": "撤回操作",
|
||||||
}
|
}
|
||||||
@@ -2407,6 +2408,7 @@ def course_summary_index_for_records(root: Path) -> dict[tuple[str, str, str, st
|
|||||||
|
|
||||||
def query_course_summaries(
|
def query_course_summaries(
|
||||||
root: Path,
|
root: Path,
|
||||||
|
classnotes_path: Path | None = None,
|
||||||
q: str = "",
|
q: str = "",
|
||||||
student: str = "",
|
student: str = "",
|
||||||
teacher: str = "",
|
teacher: str = "",
|
||||||
@@ -2437,6 +2439,18 @@ def query_course_summaries(
|
|||||||
]
|
]
|
||||||
for item in matched:
|
for item in matched:
|
||||||
item["matched_fields"] = course_summary_matched_fields(item, keyword)
|
item["matched_fields"] = course_summary_matched_fields(item, keyword)
|
||||||
|
item["matched_record"] = False
|
||||||
|
if classnotes_path is not None and classnotes_path.exists():
|
||||||
|
record_keys = {
|
||||||
|
course_summary_record_key(record.student, record.teacher, record.subject, record.date.replace(".", "-"), record.time)
|
||||||
|
for record in read_classnotes(classnotes_path)
|
||||||
|
}
|
||||||
|
for item in matched:
|
||||||
|
try:
|
||||||
|
key = course_summary_duplicate_key(item)
|
||||||
|
except ValueError:
|
||||||
|
key = ("", "", "", "", "")
|
||||||
|
item["matched_record"] = bool(key and all(key) and key in record_keys)
|
||||||
matched.sort(
|
matched.sort(
|
||||||
key=lambda item: (
|
key=lambda item: (
|
||||||
str(item.get("date_iso") or "0000-00-00"),
|
str(item.get("date_iso") or "0000-00-00"),
|
||||||
@@ -2481,6 +2495,37 @@ def replace_course_summary_block(root: Path, path: Path, summary_id: str, new_ti
|
|||||||
raise ValueError("未找到课程小结")
|
raise ValueError("未找到课程小结")
|
||||||
|
|
||||||
|
|
||||||
|
def replace_course_summary_body(root: Path, path: Path, summary_id: str, new_body: str) -> dict:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
matches = list(COURSE_SUMMARY_HEADING_RE.finditer(text))
|
||||||
|
identity = parse_course_summary_file_identity(root, path)
|
||||||
|
for index, match in enumerate(matches):
|
||||||
|
title = match.group("title").strip()
|
||||||
|
body_start = match.end()
|
||||||
|
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
|
||||||
|
body = text[body_start:end].strip()
|
||||||
|
body_without_meta = re.sub(r"^(?:>\s+.*\n)+\s*", "", body).strip()
|
||||||
|
body_text = body_without_meta or body
|
||||||
|
item_id = sha1_text(f"{identity['relative_path']}|{title}|{index}|{body_text[:200]}", 20)
|
||||||
|
if item_id != summary_id:
|
||||||
|
continue
|
||||||
|
updated_body = new_body.strip()
|
||||||
|
meta_match = re.match(r"(?P<meta>(?:>\s+.*(?:\n|$))+)\s*", body)
|
||||||
|
metadata = meta_match.group("meta").rstrip() if meta_match else ""
|
||||||
|
updated_block = f"{metadata}\n\n{updated_body}" if metadata else updated_body
|
||||||
|
new_text = f"{text[:body_start].rstrip()}\n\n{updated_block}\n\n{text[end:].lstrip()}"
|
||||||
|
atomic_write_text(path, new_text.rstrip() + "\n")
|
||||||
|
new_id = sha1_text(f"{identity['relative_path']}|{title}|{index}|{updated_body[:200]}", 20)
|
||||||
|
return {
|
||||||
|
"id": summary_id,
|
||||||
|
"new_id": new_id,
|
||||||
|
"title": title,
|
||||||
|
"path": str(path),
|
||||||
|
"body": updated_body,
|
||||||
|
}
|
||||||
|
raise ValueError("未找到课程小结")
|
||||||
|
|
||||||
|
|
||||||
def find_course_summary_item(root: Path, summary_id: str) -> dict:
|
def find_course_summary_item(root: Path, summary_id: str) -> dict:
|
||||||
for item in iter_course_summary_markdown(root):
|
for item in iter_course_summary_markdown(root):
|
||||||
if str(item.get("id") or "") == summary_id:
|
if str(item.get("id") or "") == summary_id:
|
||||||
@@ -2516,6 +2561,23 @@ def update_course_summary_time(root: Path, summary_id: str, time_range: str) ->
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def update_course_summary_body(root: Path, summary_id: str, body: str) -> dict:
|
||||||
|
new_body = body.strip()
|
||||||
|
if not new_body:
|
||||||
|
raise ValueError("课程小结正文不能为空")
|
||||||
|
item = find_course_summary_item(root, summary_id)
|
||||||
|
path = Path(str(item.get("source_path") or ""))
|
||||||
|
original = path.read_text(encoding="utf-8")
|
||||||
|
backup_dir = create_data_backup("admin-update-course-summary-body", {path: original}, [summary_id])
|
||||||
|
result = replace_course_summary_body(root, path, summary_id, new_body)
|
||||||
|
result["backup_id"] = backup_dir.name
|
||||||
|
try:
|
||||||
|
prune_data_backups(backup_dir.parent)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def delete_course_summary(root: Path, summary_id: str) -> dict:
|
def delete_course_summary(root: Path, summary_id: str) -> dict:
|
||||||
item = find_course_summary_item(root, summary_id)
|
item = find_course_summary_item(root, summary_id)
|
||||||
path = Path(str(item.get("source_path") or ""))
|
path = Path(str(item.get("source_path") or ""))
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from ..data import (
|
|||||||
reject_admin_task,
|
reject_admin_task,
|
||||||
resolve_duplicate_course_summary_task,
|
resolve_duplicate_course_summary_task,
|
||||||
rollback_operation_log,
|
rollback_operation_log,
|
||||||
|
update_course_summary_body,
|
||||||
update_course_summary_review_task,
|
update_course_summary_review_task,
|
||||||
update_course_summary_time,
|
update_course_summary_time,
|
||||||
)
|
)
|
||||||
@@ -113,6 +114,7 @@ def admin_course_summaries(
|
|||||||
try:
|
try:
|
||||||
return query_course_summaries(
|
return query_course_summaries(
|
||||||
COURSE_SUMMARIES_ROOT,
|
COURSE_SUMMARIES_ROOT,
|
||||||
|
CLASSNOTES_PATH,
|
||||||
q=q,
|
q=q,
|
||||||
student=student,
|
student=student,
|
||||||
teacher=teacher,
|
teacher=teacher,
|
||||||
@@ -279,6 +281,23 @@ def admin_update_course_summary_time(summary_id: str, payload: dict, _user: str
|
|||||||
return {"ok": True, **result}
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/admin/course-summaries/{summary_id}/body")
|
||||||
|
def admin_update_course_summary_body(summary_id: str, payload: dict, _user: str = Depends(verify_admin_auth)):
|
||||||
|
try:
|
||||||
|
with write_lock:
|
||||||
|
result = update_course_summary_body(COURSE_SUMMARIES_ROOT, summary_id, str(payload.get("body") or ""))
|
||||||
|
append_operation_log(
|
||||||
|
OPERATION_LOGS_PATH,
|
||||||
|
"课程小结修改正文",
|
||||||
|
"已更新",
|
||||||
|
summary_id=summary_id,
|
||||||
|
backup_id=str(result.get("backup_id") or ""),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/admin/course-summaries/{summary_id}")
|
@router.delete("/api/admin/course-summaries/{summary_id}")
|
||||||
def admin_delete_course_summary(summary_id: str, _user: str = Depends(verify_admin_auth)):
|
def admin_delete_course_summary(summary_id: str, _user: str = Depends(verify_admin_auth)):
|
||||||
try:
|
try:
|
||||||
|
|||||||
+60
-5
@@ -112,6 +112,7 @@ let currentSummaryReviews = [];
|
|||||||
let activeSummaryReview = null;
|
let activeSummaryReview = null;
|
||||||
let currentSummarySearchItems = [];
|
let currentSummarySearchItems = [];
|
||||||
let expandedSummarySearchId = "";
|
let expandedSummarySearchId = "";
|
||||||
|
let editingSummarySearchId = "";
|
||||||
let currentOperationLogs = [];
|
let currentOperationLogs = [];
|
||||||
let expandedOperationLogId = "";
|
let expandedOperationLogId = "";
|
||||||
const registerPreviewState = {
|
const registerPreviewState = {
|
||||||
@@ -784,14 +785,31 @@ function summarySearchParams() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSummaryActions(item) {
|
function renderSummaryActions(item) {
|
||||||
|
const editButton = `<button class="secondary-button summary-body-edit" type="button" data-summary-id="${escapeHtml(item.id)}">修改课程小结</button>`;
|
||||||
|
const deleteButton = `<button class="secondary-button summary-delete" type="button" data-summary-id="${escapeHtml(item.id)}">删除课程小结</button>`;
|
||||||
if (item.time_range) {
|
if (item.time_range) {
|
||||||
return `<span class="summary-action-note">时间已完整</span>`;
|
return `<div class="summary-time-actions"><span class="summary-action-note">时间已完整</span>${editButton}${deleteButton}</div>`;
|
||||||
}
|
}
|
||||||
const inputId = `summary-time-${item.id}`;
|
const inputId = `summary-time-${item.id}`;
|
||||||
return `<div class="summary-time-actions">
|
return `<div class="summary-time-actions">
|
||||||
<input id="${escapeHtml(inputId)}" class="summary-time-input" data-summary-time-input="${escapeHtml(item.id)}" autocomplete="off" placeholder="08:00-10:00" />
|
<input id="${escapeHtml(inputId)}" class="summary-time-input" data-summary-time-input="${escapeHtml(item.id)}" autocomplete="off" placeholder="08:00-10:00" />
|
||||||
<button class="secondary-button summary-time-save" type="button" data-summary-id="${escapeHtml(item.id)}">补齐时间</button>
|
<button class="secondary-button summary-time-save" type="button" data-summary-id="${escapeHtml(item.id)}">补齐时间</button>
|
||||||
<button class="secondary-button summary-delete" type="button" data-summary-id="${escapeHtml(item.id)}">删除课程小结</button>
|
${editButton}
|
||||||
|
${deleteButton}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummaryBodyEditor(item) {
|
||||||
|
const body = item.body || item.body_preview || "";
|
||||||
|
if (String(item.id) !== editingSummarySearchId) {
|
||||||
|
return `<div class="summary-body summary-search-full">${escapeHtml(body || "暂无正文")}</div>`;
|
||||||
|
}
|
||||||
|
return `<div class="summary-body-edit-panel">
|
||||||
|
<textarea class="summary-body-editor" data-summary-body-editor="${escapeHtml(item.id)}" rows="10">${escapeHtml(body)}</textarea>
|
||||||
|
<div class="summary-edit-actions">
|
||||||
|
<button class="secondary-button summary-body-save" type="button" data-summary-id="${escapeHtml(item.id)}">保存</button>
|
||||||
|
<button class="secondary-button summary-body-cancel" type="button" data-summary-id="${escapeHtml(item.id)}">取消</button>
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -804,7 +822,8 @@ function renderSummarySearchRows(items) {
|
|||||||
return items
|
return items
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const expanded = String(item.id) === expandedSummarySearchId;
|
const expanded = String(item.id) === expandedSummarySearchId;
|
||||||
const mainRow = `<tr class="summary-search-result-row" data-summary-id="${escapeHtml(item.id)}" tabindex="0" role="button" aria-expanded="${expanded ? "true" : "false"}">
|
const unmatchedClass = item.matched_record ? "" : " unmatched-summary";
|
||||||
|
const mainRow = `<tr class="summary-search-result-row${unmatchedClass}" data-summary-id="${escapeHtml(item.id)}" tabindex="0" role="button" aria-expanded="${expanded ? "true" : "false"}">
|
||||||
<td>${escapeHtml(item.date_iso || "未识别")}</td>
|
<td>${escapeHtml(item.date_iso || "未识别")}</td>
|
||||||
<td>${escapeHtml(item.time_range || "缺少时间")}</td>
|
<td>${escapeHtml(item.time_range || "缺少时间")}</td>
|
||||||
<td>${escapeHtml(item.student || "")}<br><small>${escapeHtml(item.group || "")}</small></td>
|
<td>${escapeHtml(item.student || "")}<br><small>${escapeHtml(item.group || "")}</small></td>
|
||||||
@@ -813,14 +832,14 @@ function renderSummarySearchRows(items) {
|
|||||||
const detailRow = expanded
|
const detailRow = expanded
|
||||||
? `<tr class="summary-search-detail-row" data-summary-detail="${escapeHtml(item.id)}">
|
? `<tr class="summary-search-detail-row" data-summary-detail="${escapeHtml(item.id)}">
|
||||||
<td colspan="4">
|
<td colspan="4">
|
||||||
<div class="summary-search-detail-panel">
|
<div class="summary-search-detail-panel${unmatchedClass}">
|
||||||
<div class="summary-search-detail-head">
|
<div class="summary-search-detail-head">
|
||||||
<div>
|
<div>
|
||||||
<div class="record-summary-title">${escapeHtml(item.title || "课程小结正文")}</div>
|
<div class="record-summary-title">${escapeHtml(item.title || "课程小结正文")}</div>
|
||||||
${renderMatchedFields(item)}
|
${renderMatchedFields(item)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-body summary-search-full">${escapeHtml(item.body || item.body_preview || "暂无正文")}</div>
|
${renderSummaryBodyEditor(item)}
|
||||||
<div class="summary-search-actions">${renderSummaryActions(item)}</div>
|
<div class="summary-search-actions">${renderSummaryActions(item)}</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -844,6 +863,7 @@ async function loadSummarySearch() {
|
|||||||
const data = await fetchJson(`/api/admin/course-summaries?${summarySearchParams().toString()}`);
|
const data = await fetchJson(`/api/admin/course-summaries?${summarySearchParams().toString()}`);
|
||||||
currentSummarySearchItems = data.items || [];
|
currentSummarySearchItems = data.items || [];
|
||||||
if (!currentSummarySearchItems.some((item) => String(item.id) === expandedSummarySearchId)) expandedSummarySearchId = "";
|
if (!currentSummarySearchItems.some((item) => String(item.id) === expandedSummarySearchId)) expandedSummarySearchId = "";
|
||||||
|
if (!currentSummarySearchItems.some((item) => String(item.id) === editingSummarySearchId)) editingSummarySearchId = "";
|
||||||
summarySearchMeta.innerHTML = [
|
summarySearchMeta.innerHTML = [
|
||||||
metric("命中小结", `${data.count} 条`),
|
metric("命中小结", `${data.count} 条`),
|
||||||
metric("当前显示", `${data.returned} 条`),
|
metric("当前显示", `${data.returned} 条`),
|
||||||
@@ -871,6 +891,18 @@ async function deleteCourseSummary(summaryId) {
|
|||||||
await loadSummarySearch();
|
await loadSummarySearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateCourseSummaryBody(summaryId, body) {
|
||||||
|
await fetchJson(`/api/admin/course-summaries/${encodeURIComponent(summaryId)}/body`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ body }),
|
||||||
|
});
|
||||||
|
editingSummarySearchId = "";
|
||||||
|
expandedSummarySearchId = "";
|
||||||
|
await loadSummarySearch();
|
||||||
|
await loadOperationLogs();
|
||||||
|
}
|
||||||
|
|
||||||
async function saveSummaryReviewEdits() {
|
async function saveSummaryReviewEdits() {
|
||||||
if (!activeSummaryReview) return;
|
if (!activeSummaryReview) return;
|
||||||
try {
|
try {
|
||||||
@@ -1451,6 +1483,29 @@ summarySearchMissingTime.addEventListener("change", loadSummarySearch);
|
|||||||
summarySearchRows.addEventListener("click", (event) => {
|
summarySearchRows.addEventListener("click", (event) => {
|
||||||
const saveTime = event.target.closest(".summary-time-save");
|
const saveTime = event.target.closest(".summary-time-save");
|
||||||
const deleteSummary = event.target.closest(".summary-delete");
|
const deleteSummary = event.target.closest(".summary-delete");
|
||||||
|
const editSummary = event.target.closest(".summary-body-edit");
|
||||||
|
const saveSummaryBody = event.target.closest(".summary-body-save");
|
||||||
|
const cancelSummaryBody = event.target.closest(".summary-body-cancel");
|
||||||
|
if (editSummary) {
|
||||||
|
editingSummarySearchId = editSummary.dataset.summaryId;
|
||||||
|
renderCurrentSummarySearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cancelSummaryBody) {
|
||||||
|
editingSummarySearchId = "";
|
||||||
|
renderCurrentSummarySearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (saveSummaryBody) {
|
||||||
|
const textarea = summarySearchRows.querySelector(`[data-summary-body-editor='${saveSummaryBody.dataset.summaryId}']`);
|
||||||
|
const body = textarea ? textarea.value.trim() : "";
|
||||||
|
if (!body) {
|
||||||
|
alert("课程小结正文不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateCourseSummaryBody(saveSummaryBody.dataset.summaryId, body).catch((error) => alert(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (saveTime) {
|
if (saveTime) {
|
||||||
const input = summarySearchRows.querySelector(`[data-summary-time-input='${saveTime.dataset.summaryId}']`);
|
const input = summarySearchRows.querySelector(`[data-summary-time-input='${saveTime.dataset.summaryId}']`);
|
||||||
const timeRange = input ? input.value.trim() : "";
|
const timeRange = input ? input.value.trim() : "";
|
||||||
|
|||||||
@@ -726,16 +726,46 @@ textarea:focus {
|
|||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-search-result-row.unmatched-summary td:first-child {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-search-result-row.unmatched-summary td:first-child::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
.summary-search-detail-row td {
|
.summary-search-detail-row td {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: #fbfcfd;
|
background: #fbfcfd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-search-detail-panel {
|
.summary-search-detail-panel {
|
||||||
|
position: relative;
|
||||||
padding: 14px 16px 16px;
|
padding: 14px 16px 16px;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-search-detail-panel.unmatched-summary {
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-search-detail-panel.unmatched-summary::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
.summary-search-detail-head {
|
.summary-search-detail-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -748,10 +778,29 @@ textarea:focus {
|
|||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-body-edit-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-body-editor {
|
||||||
|
min-height: 220px;
|
||||||
|
font-family: Arial, "Songti SC", SimSun, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-edit-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.summary-search-actions {
|
.summary-search-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-start;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
@@ -760,7 +809,7 @@ textarea:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.summary-search-actions .summary-time-actions {
|
.summary-search-actions .summary-time-actions {
|
||||||
justify-content: flex-end;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-action-note {
|
.summary-action-note {
|
||||||
@@ -1698,7 +1747,7 @@ td {
|
|||||||
|
|
||||||
.summary-search-actions,
|
.summary-search-actions,
|
||||||
.summary-search-actions .summary-time-actions {
|
.summary-search-actions .summary-time-actions {
|
||||||
justify-content: stretch;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-search-actions .secondary-button,
|
.summary-search-actions .secondary-button,
|
||||||
|
|||||||
Reference in New Issue
Block a user