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

{% 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 %}