You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
218 lines
9.6 KiB
218 lines
9.6 KiB
{% extends "base.html" %}
|
|
|
|
{% block title %}HappyWedding{% endblock %}
|
|
|
|
{% block content %}
|
|
<section class="mb-4 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
|
<div>
|
|
<div class="flex items-center gap-2">
|
|
<h2 class="text-xl font-semibold text-neutral-800 dark:text-neutral-100">管理首页</h2>
|
|
<span class="status-badge status-badge-muted">总户数 {{ stats.total_households }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<span class="summary-metric">预计 {{ stats.total_expected_attendees }}</span>
|
|
<span class="summary-metric">儿童 {{ stats.total_children }}</span>
|
|
<span class="status-badge status-badge-accent">礼金 ¥{{ '%.2f'|format(stats.total_gift_amount) }}</span>
|
|
<a class="btn btn-primary btn-sm shrink-0" href="{{ url_for('main.new_household') }}">新增一户</a>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="mb-3 card">
|
|
<div class="card-body space-y-3 !p-3 sm:!p-4">
|
|
<form action="{{ url_for('main.index') }}" method="get" class="space-y-2.5" id="admin-search-form">
|
|
<input type="hidden" name="page" value="{{ page }}">
|
|
<input type="hidden" name="per_page" value="{{ per_page }}">
|
|
|
|
<div class="flex flex-col gap-2.5 xl:flex-row xl:items-center">
|
|
<div class="xl:min-w-[20rem] xl:flex-[1.25]">
|
|
<input class="form-input h-10" id="main-search" name="q" type="text" value="{{ search_term }}" placeholder="户主 / 成员 / 拼音 / 标签 / 备注" autocomplete="off">
|
|
</div>
|
|
|
|
<div class="grid flex-1 grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-6">
|
|
<select id="side-filter" name="side" class="form-select">
|
|
<option value="">所属侧:全部</option>
|
|
{% for item in side_options %}
|
|
<option value="{{ item }}" {% if side == item %}selected{% endif %}>所属侧:{{ household_value_label('side', item) }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<select id="invite-status-filter" name="invite_status" class="form-select">
|
|
<option value="">邀请状态:全部</option>
|
|
{% for item in invite_status_options %}
|
|
<option value="{{ item }}" {% if invite_status == item %}selected{% endif %}>邀请状态:{{ household_value_label('invite_status', item) }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<select id="attendance-status-filter" name="attendance_status" class="form-select">
|
|
<option value="">到场状态:全部</option>
|
|
{% for item in attendance_status_options %}
|
|
<option value="{{ item }}" {% if attendance_status == item %}selected{% endif %}>到场状态:{{ household_value_label('attendance_status', item) }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<select id="tag-option-filter" name="tag_option_id" class="form-select">
|
|
<option value="">标签:全部</option>
|
|
{% for option in tag_options %}
|
|
<option value="{{ option.id }}" {% if tag_option_id == option.id %}selected{% endif %}>标签:{{ option.option_label }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<select id="sort-by" name="sort_by" class="form-select">
|
|
{% for item in sort_field_options %}
|
|
<option value="{{ item }}" {% if sort_by == item %}selected{% endif %}>排序字段:{{ sort_field_labels[item] }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<select id="sort-order" name="sort_order" class="form-select">
|
|
<option value="desc" {% if sort_order == 'desc' %}selected{% endif %}>排序方向:降序</option>
|
|
<option value="asc" {% if sort_order == 'asc' %}selected{% endif %}>排序方向:升序</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2 border-t border-neutral-100 pt-3 text-xs text-neutral-500 dark:border-neutral-700 dark:text-neutral-400 sm:flex-row sm:items-center sm:justify-between">
|
|
<a class="inline-flex items-center gap-1 font-medium text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200" href="{{ url_for('main.index') }}">清空筛选</a>
|
|
<div class="flex flex-wrap items-center gap-x-2 gap-y-1.5">
|
|
<a class="text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200" href="{{ url_for('csv.download_household_template') }}">下载 CSV 模板</a>
|
|
<span class="text-neutral-300 dark:text-neutral-600">·</span>
|
|
<a class="text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200" href="{{ url_for('csv.export_households', scope='all') }}">导出全部</a>
|
|
<span class="text-neutral-300 dark:text-neutral-600">·</span>
|
|
<a class="text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200" href="{{ url_for('csv.export_households', scope='filtered', q=search_term, side=side, invite_status=invite_status, attendance_status=attendance_status, sort_by=sort_by, sort_order=sort_order, tag_option_id=tag_option_id) }}">导出当前筛选</a>
|
|
<span class="text-neutral-300 dark:text-neutral-600">·</span>
|
|
<a class="text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200" href="{{ url_for('csv.import_households_page') }}">导入 CSV</a>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</section>
|
|
|
|
{% include "main/_household_results.html" %}
|
|
|
|
<script>
|
|
(function () {
|
|
const form = document.getElementById('admin-search-form');
|
|
const resultsRegionId = 'household-results-region';
|
|
if (!form) {
|
|
return;
|
|
}
|
|
|
|
let debounceTimer = null;
|
|
let currentRequest = null;
|
|
|
|
function syncPaginationControlValues() {
|
|
const perPageField = form.querySelector('input[name="per_page"]');
|
|
const regionPerPage = document.querySelector('[data-pagination-per-page]');
|
|
if (perPageField instanceof HTMLInputElement && regionPerPage instanceof HTMLSelectElement) {
|
|
regionPerPage.value = perPageField.value;
|
|
}
|
|
}
|
|
|
|
function setPage(value) {
|
|
const pageField = form.querySelector('input[name="page"]');
|
|
if (pageField instanceof HTMLInputElement) {
|
|
pageField.value = value;
|
|
}
|
|
}
|
|
|
|
function setPerPage(value) {
|
|
const perPageField = form.querySelector('input[name="per_page"]');
|
|
if (perPageField instanceof HTMLInputElement) {
|
|
perPageField.value = value;
|
|
}
|
|
}
|
|
|
|
function resetToFirstPage() {
|
|
setPage('1');
|
|
}
|
|
|
|
function scheduleRefresh() {
|
|
window.clearTimeout(debounceTimer);
|
|
debounceTimer = window.setTimeout(refreshResults, 300);
|
|
}
|
|
|
|
async function refreshResults() {
|
|
if (currentRequest) {
|
|
currentRequest.abort();
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
currentRequest = controller;
|
|
const params = new URLSearchParams(new FormData(form));
|
|
params.set('partial', 'results');
|
|
|
|
try {
|
|
const response = await window.fetch(`${form.action}?${params.toString()}`, {
|
|
headers: {
|
|
'X-HW-Partial': 'results',
|
|
},
|
|
signal: controller.signal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`unexpected status ${response.status}`);
|
|
}
|
|
|
|
const html = await response.text();
|
|
const parser = new DOMParser();
|
|
const documentFragment = parser.parseFromString(html, 'text/html');
|
|
const newRegion = documentFragment.getElementById(resultsRegionId);
|
|
const currentRegion = document.getElementById(resultsRegionId);
|
|
if (!newRegion || !currentRegion) {
|
|
return;
|
|
}
|
|
|
|
currentRegion.replaceWith(newRegion);
|
|
syncPaginationControlValues();
|
|
const newUrl = `${form.action}?${new URLSearchParams(new FormData(form)).toString()}`;
|
|
window.history.replaceState({}, '', newUrl);
|
|
} catch (error) {
|
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
return;
|
|
}
|
|
window.console.error('Admin live refresh failed', error);
|
|
} finally {
|
|
if (currentRequest === controller) {
|
|
currentRequest = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
form.querySelectorAll('input, select').forEach(function (field) {
|
|
if (field instanceof HTMLInputElement && field.type === 'hidden') {
|
|
return;
|
|
}
|
|
|
|
const eventName = field.tagName === 'SELECT' ? 'change' : 'input';
|
|
field.addEventListener(eventName, function () {
|
|
resetToFirstPage();
|
|
scheduleRefresh();
|
|
});
|
|
});
|
|
|
|
document.addEventListener('click', function (event) {
|
|
const target = event.target instanceof Element ? event.target.closest('[data-pagination-page]') : null;
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
const pageValue = target.getAttribute('data-pagination-page');
|
|
if (!pageValue) {
|
|
return;
|
|
}
|
|
|
|
setPage(pageValue);
|
|
void refreshResults();
|
|
});
|
|
|
|
document.addEventListener('change', function (event) {
|
|
const target = event.target;
|
|
if (!(target instanceof HTMLSelectElement) || !target.matches('[data-pagination-per-page]')) {
|
|
return;
|
|
}
|
|
|
|
setPerPage(target.value);
|
|
resetToFirstPage();
|
|
void refreshResults();
|
|
});
|
|
|
|
syncPaginationControlValues();
|
|
}());
|
|
</script>
|
|
{% endblock %}
|
|
|