diff --git a/assets/css/admin/user-auth.css b/assets/css/admin/user-auth.css new file mode 100644 index 00000000..7c97af23 --- /dev/null +++ b/assets/css/admin/user-auth.css @@ -0,0 +1,212 @@ +/* 认证审核原生后台页。卡片、表单与颜色令牌继承 native-ui.css。 */ +.aksrt-verify-page [hidden], +.aksrt-verify-dialog [hidden] { display: none !important; } + +.aksrt-verify-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px 18px; + padding: 14px 20px; + border-bottom: 1px solid var(--an-border-soft); +} +.aksrt-verify-toolbar__views { + display: flex; + flex: 1 1 620px; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px 18px; +} +.aksrt-verify-toolbar .subsubsub { margin: 0; padding: 0; box-shadow: none; } +.aksrt-verify-toolbar .aksrt-native-filters { flex: 0 1 auto; margin: 0; } +.aksrt-verify-toolbar .aksrt-native-filters input[type="search"] { min-width: 200px; } + +.aksrt-verify-page .aksrt-verify-table { min-width: 1040px; table-layout: fixed; } +.aksrt-verify-table .column-user { width: 20%; } +.aksrt-verify-table .column-profile { width: 24%; } +.aksrt-verify-table .column-evidence { width: 10%; } +.aksrt-verify-table .column-status { width: 10%; } +.aksrt-verify-table .column-time { width: 18%; } +.aksrt-verify-table .column-actions { width: 18%; } +.aksrt-verify-page .aksrt-verify-table td { vertical-align: middle; overflow-wrap: anywhere; } +.aksrt-verify-summary { + display: -webkit-box; + max-height: 3.1em; + margin-top: 4px; + color: var(--an-text-2); + font-size: 12px; + line-height: 1.55; + overflow: hidden; + overflow-wrap: anywhere; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} +.aksrt-verify-sub { margin-top: 4px; color: var(--an-text-2); font-size: 12px; line-height: 1.55; overflow-wrap: anywhere; } +.aksrt-verify-row-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; } +.aksrt-verify-row-actions a, +.aksrt-verify-row-actions button { white-space: nowrap; } +.aksrt-verify-row-actions .button-link-delete { color: var(--an-danger); } +.aksrt-verify-page :is(a, button):focus-visible, +.aksrt-verify-dialog :is(a, button, input, textarea):focus-visible { + outline: 2px solid var(--an-primary); + outline-offset: 2px; +} + +html.aksrt-verify-modal-open { overflow: hidden; } +.aksrt-verify-dialog { + width: min(760px, calc(100vw - 32px)); + max-width: none; + max-height: calc(100vh - 40px); + max-height: calc(100dvh - 40px); + margin: auto; + padding: 0; + overflow: hidden; + color: var(--an-text-2); + background: var(--an-card); + border: 1px solid var(--an-border-soft); + border-radius: var(--an-radius); + box-shadow: var(--an-shadow-hover); +} +.aksrt-verify-dialog::backdrop { background: rgba(15, 23, 42, .45); } +.aksrt-verify-dialog__frame { display: flex; flex-direction: column; max-height: inherit; min-height: 0; margin: 0; } +.aksrt-verify-dialog__head { + display: flex; + flex: none; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 19px 24px 17px; + border-bottom: 1px solid var(--an-border-soft); +} +.aksrt-verify-dialog .aksrt-verify-dialog__head h2 { + margin: 0; + padding: 0; + color: var(--an-text); + font-size: 18px; + font-weight: 600; + line-height: 1.45; +} +.aksrt-verify-dialog__head p { margin: 5px 0 0; color: var(--an-text-2); font-size: 13px; line-height: 1.6; } +.aksrt-verify-dialog__head [data-aksrt-dialog-close] { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + color: var(--an-text-2); + background: transparent; + border: 0; + border-radius: var(--an-radius-sm); + cursor: pointer; +} +.aksrt-verify-dialog__head [data-aksrt-dialog-close]:hover { color: var(--an-text); background: var(--an-bg-soft); } +.aksrt-verify-dialog__body { min-height: 0; padding: 20px 24px; overflow-y: auto; overscroll-behavior: contain; } +.aksrt-verify-dialog__foot { + display: flex; + flex: none; + justify-content: flex-end; + gap: 8px; + padding: 14px 24px; + background: var(--an-card); + border-top: 1px solid var(--an-border-soft); +} +.aksrt-verify-dialog__foot .button { min-width: 116px; } +.aksrt-verify-dialog__foot .button.is-danger { color: var(--an-card); background: var(--an-danger); border-color: var(--an-danger); } +.aksrt-verify-dialog__foot .button.is-danger:hover { background: var(--an-danger); border-color: var(--an-danger); } +.aksrt-verify-dialog__foot .button:disabled { cursor: not-allowed; } + +.aksrt-verify-applicant { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; min-width: 0; margin: 0 0 18px; color: var(--an-text); font-size: 17px; font-weight: 600; line-height: 1.45; overflow-wrap: anywhere; } +.aksrt-verify-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 18px; + margin: 0; + padding: 14px 16px; + background: var(--an-bg-soft); + border-radius: var(--an-radius-sm); +} +.aksrt-verify-meta > div { min-width: 0; } +.aksrt-verify-meta dt { color: var(--an-text-2); font-size: 12px; line-height: 1.5; } +.aksrt-verify-meta dd { margin: 3px 0 0; color: var(--an-text); overflow-wrap: anywhere; } +.aksrt-verify-section { margin-top: 20px; } +.aksrt-verify-dialog .aksrt-verify-section h3, +.aksrt-verify-review > h3 { + margin: 0 0 9px; + padding: 0; + color: var(--an-text); + font-size: 14px; + font-weight: 600; + line-height: 1.45; +} +.aksrt-verify-copy { color: var(--an-text-2); line-height: 1.75; white-space: pre-wrap; overflow-wrap: anywhere; } +.aksrt-verify-evidence { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; margin: 0; padding: 0; list-style: none; } +.aksrt-verify-evidence li { min-width: 0; } +.aksrt-verify-evidence a { + display: flex; + align-items: center; + gap: 9px; + min-height: 52px; + padding: 7px 9px; + color: var(--an-text-2); + background: var(--an-bg-soft); + border: 1px solid var(--an-border-soft); + border-radius: var(--an-radius-sm); + text-decoration: none; +} +.aksrt-verify-evidence a:hover { color: var(--an-primary); border-color: var(--an-primary); } +.aksrt-verify-evidence img { flex: none; width: 38px; height: 38px; object-fit: cover; border-radius: 5px; } +.aksrt-verify-evidence span { min-width: 0; overflow-wrap: anywhere; } +.aksrt-verify-evidence-empty { margin: 0; color: var(--an-text-2); } + +.aksrt-verify-review { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--an-border-soft); } +.aksrt-verify-decisions { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 16px; padding: 0; border: 0; } +.aksrt-verify-decisions > legend { width: 100%; margin-bottom: 8px; padding: 0; color: var(--an-text); font-weight: 600; } +.aksrt-verify-decisions label { position: relative; flex: 1 1 180px; cursor: pointer; } +.aksrt-verify-decisions input[type="radio"] { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); } +.aksrt-verify-decisions label > span { + display: flex; + align-items: center; + min-height: 42px; + padding: 9px 12px; + color: var(--an-text-2); + background: var(--an-card); + border: 1px solid var(--an-border); + border-radius: var(--an-radius-sm); +} +.aksrt-verify-decisions label:hover > span { border-color: var(--an-primary); } +.aksrt-verify-decisions input:checked + span { color: var(--an-primary); background: var(--an-primary-soft); border-color: var(--an-primary); font-weight: 600; } +.aksrt-verify-decisions input:focus-visible + span { outline: 2px solid var(--an-primary); outline-offset: 2px; } +.aksrt-verify-profile { + min-width: 0; + margin: 0 0 16px; + padding: 14px; + border: 1px solid var(--an-border); + border-radius: var(--an-radius-sm); +} +.aksrt-verify-profile > legend { padding: 0 5px; color: var(--an-text); font-weight: 600; } +.aksrt-verify-review .aksrt-native-field { margin-top: 13px; } +.aksrt-verify-review .aksrt-native-field > input, +.aksrt-verify-review .aksrt-native-field > textarea { width: 100%; max-width: none; } +.aksrt-verify-review textarea { resize: vertical; } + +@media (max-width: 782px) { + .aksrt-verify-toolbar { padding: 12px 14px; } + .aksrt-verify-toolbar__views { flex: 1 1 100%; justify-content: flex-start; } + .aksrt-verify-toolbar .aksrt-native-filters { width: 100%; } + .aksrt-verify-toolbar .aksrt-native-filters input[type="search"] { min-width: 0; flex: 1 1 220px; } +} +@media (max-width: 600px) { + .aksrt-verify-dialog { width: calc(100vw - 24px); max-height: calc(100dvh - 24px); } + .aksrt-verify-dialog__head, + .aksrt-verify-dialog__body, + .aksrt-verify-dialog__foot { padding-left: 16px; padding-right: 16px; } + .aksrt-verify-meta { grid-template-columns: 1fr; gap: 10px; } + .aksrt-verify-evidence { grid-template-columns: 1fr; } + .aksrt-verify-decisions { flex-direction: column; } + .aksrt-verify-decisions label { flex-basis: auto; } + .aksrt-verify-dialog__foot .button { flex: 1; } +} diff --git a/assets/js/admin-user-auth.js b/assets/js/admin-user-auth.js new file mode 100644 index 00000000..0104c101 --- /dev/null +++ b/assets/js/admin-user-auth.js @@ -0,0 +1,255 @@ +/** 认证记录审核详情与删除确认。仅在认证 native 后台页入队。 */ +(function () { + 'use strict'; + + var root = document.querySelector('.aksrt-verify-page'); + var dialog = document.getElementById('aksrt-verify-dialog'); + if (!root || !dialog || typeof dialog.showModal !== 'function') return; + + var opener = null; + var activeForm = null; + var submitting = false; + var pointerStartedOutside = false; + + function prevent(event) { + if (event && typeof event.preventDefault === 'function') event.preventDefault(); + } + + function valueOf(element, name, fallback) { + var value = element ? element.getAttribute(name) : null; + return value === null ? (fallback || '') : value; + } + + function lockScroll() { + document.documentElement.classList.toggle('aksrt-verify-modal-open', !!dialog.open); + } + + function closestAttribute(target, attribute, boundary) { + var node = target; + while (node) { + if (node.nodeType === 1 && node.hasAttribute(attribute)) return node; + if (node === boundary) break; + node = node.parentElement; + } + return null; + } + + function isOutside(event) { + var rect = dialog.getBoundingClientRect(); + return event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom; + } + + function selectedStatus(form) { + var radios = form.querySelectorAll('input[name="status"]'); + for (var index = 0; index < radios.length; index += 1) { + if (radios[index].checked) return radios[index].value; + } + return ''; + } + + function getProfileName(profile) { + return profile ? profile.querySelector('input[name="name"]') : null; + } + + function setTrimValidity(field, active) { + if (!field || typeof field.setCustomValidity !== 'function') return; + if (!active || String(field.value || '').trim() !== '') { + field.setCustomValidity(''); + return; + } + field.setCustomValidity(valueOf(field, 'data-required-error', '必填项不能为空')); + } + + function syncReview(form) { + var decision = selectedStatus(form); + var profile = form.querySelector('[data-aksrt-verify-profile]'); + var name = getProfileName(profile); + var note = form.querySelector('textarea[name="note"]'); + var noteLabel = form.querySelector('[data-aksrt-verify-note-label]'); + var submit = form.querySelector('[data-aksrt-verify-submit]'); + var rejected = decision === 'rejected'; + var approved = decision === 'verified'; + + if (profile) { + profile.hidden = !approved; + profile.disabled = !approved; + } + if (note) { + note.required = rejected; + setTrimValidity(note, rejected); + } + setTrimValidity(name, approved); + if (noteLabel) { + noteLabel.textContent = valueOf(noteLabel, rejected ? 'data-required' : 'data-optional'); + } + if (submit) { + var idleLabel = valueOf(submit, 'data-idle-label'); + submit.disabled = !decision; + submit.textContent = approved + ? valueOf(submit, 'data-approve-label', idleLabel) + : rejected + ? valueOf(submit, 'data-reject-label', idleLabel) + : idleLabel; + submit.classList.toggle('is-danger', rejected); + } + } + + function resetReview(form) { + var radios = form.querySelectorAll('input[name="status"]'); + radios.forEach(function (radio) { radio.checked = false; }); + + var note = form.querySelector('textarea[name="note"]'); + if (note) note.value = ''; + syncReview(form); + } + + function actionButtons() { + var buttons = []; + var submitButtons = dialog.querySelectorAll('[data-aksrt-verify-submit]'); + var closeButtons = dialog.querySelectorAll('[data-aksrt-dialog-close]'); + submitButtons.forEach(function (button) { buttons.push(button); }); + closeButtons.forEach(function (button) { + if (buttons.indexOf(button) === -1) buttons.push(button); + }); + return buttons; + } + + function setActionsDisabled(disabled) { + actionButtons().forEach(function (button) { button.disabled = disabled; }); + } + + function setBusyLabel(form) { + var submit = form.querySelector('[data-aksrt-verify-submit]'); + if (submit) submit.textContent = valueOf(submit, 'data-busy-label', submit.textContent); + } + + function syncReviewIfIdle(form) { + if (!submitting && form.dataset.submitting !== '1') syncReview(form); + } + + function setupForm(form) { + var review = form.hasAttribute('data-aksrt-verify-review'); + var deletion = form.hasAttribute('data-aksrt-verify-delete-form'); + var submit = form.querySelector('[data-aksrt-verify-submit]'); + if (!review && !deletion) return; + + if (review) { + resetReview(form); + form.addEventListener('change', function () { syncReviewIfIdle(form); }); + form.addEventListener('input', function () { syncReviewIfIdle(form); }); + } else if (submit) { + submit.disabled = false; + submit.textContent = valueOf(submit, 'data-idle-label', submit.textContent); + submit.classList.add('is-danger'); + } + + form.addEventListener('submit', function (event) { + if (submitting || form.dataset.submitting === '1') { + prevent(event); + return; + } + + if (review) syncReview(form); + var valid = typeof form.reportValidity !== 'function' || form.reportValidity(); + if (!valid || (review && !selectedStatus(form))) { + prevent(event); + return; + } + + submitting = true; + form.dataset.submitting = '1'; + setActionsDisabled(true); + setBusyLabel(form); + lockScroll(); + }); + } + + function openTemplate(button, prefix) { + if (submitting || dialog.open) return; + var id = button.getAttribute(prefix); + if (id === null || id === '') return; + var template = document.getElementById('aksrt-verify-' + (prefix === 'data-aksrt-verify-view' ? 'detail-' : 'delete-') + id); + if (!template || !template.content || typeof template.content.cloneNode !== 'function') return; + + pointerStartedOutside = false; + dialog.replaceChildren(template.content.cloneNode(true)); + opener = button; + activeForm = dialog.querySelector('form[data-aksrt-verify-review]') + || dialog.querySelector('form[data-aksrt-verify-delete-form]'); + if (activeForm) setupForm(activeForm); + + dialog.showModal(); + lockScroll(); + } + + root.querySelectorAll('[data-aksrt-verify-view]').forEach(function (button) { + button.addEventListener('click', function (event) { + prevent(event); + openTemplate(button, 'data-aksrt-verify-view'); + }); + }); + root.querySelectorAll('[data-aksrt-verify-delete]').forEach(function (button) { + button.addEventListener('click', function (event) { + prevent(event); + openTemplate(button, 'data-aksrt-verify-delete'); + }); + }); + + dialog.addEventListener('click', function (event) { + var closeButton = closestAttribute(event.target, 'data-aksrt-dialog-close', dialog); + if (closeButton) { + prevent(event); + if (!submitting) dialog.close(); + pointerStartedOutside = false; + return; + } + if (pointerStartedOutside && event.target === dialog && isOutside(event) && !submitting) dialog.close(); + pointerStartedOutside = false; + }); + + dialog.addEventListener('pointerdown', function (event) { + pointerStartedOutside = event.target === dialog && isOutside(event); + }); + + dialog.addEventListener('cancel', function (event) { + prevent(event); + if (!submitting) dialog.close(); + }); + + dialog.addEventListener('keydown', function (event) { + if (event.key === 'Escape' && submitting) { + prevent(event); + if (typeof event.stopPropagation === 'function') event.stopPropagation(); + } + }); + + dialog.addEventListener('close', function () { + dialog.replaceChildren(); + activeForm = null; + submitting = false; + pointerStartedOutside = false; + lockScroll(); + var previousOpener = opener; + opener = null; + if (previousOpener && previousOpener.isConnected) previousOpener.focus(); + }); + + window.addEventListener('pageshow', function () { + submitting = false; + if (activeForm) { + delete activeForm.dataset.submitting; + setActionsDisabled(false); + if (activeForm.hasAttribute('data-aksrt-verify-review')) { + syncReview(activeForm); + } else { + var submit = activeForm.querySelector('[data-aksrt-verify-submit]'); + if (submit) { + submit.disabled = false; + submit.textContent = valueOf(submit, 'data-idle-label', submit.textContent); + submit.classList.add('is-danger'); + } + } + } + lockScroll(); + }); +})(); diff --git a/src/Admin/Native/UsersMenus.php b/src/Admin/Native/UsersMenus.php index 74414d09..13a651ce 100644 --- a/src/Admin/Native/UsersMenus.php +++ b/src/Admin/Native/UsersMenus.php @@ -46,6 +46,7 @@ class UsersMenus { add_action('admin_enqueue_scripts', [$this, 'enqueue_ticket_assets']); add_action('admin_enqueue_scripts', [$this, 'enqueue_message_assets']); add_action('admin_enqueue_scripts', [$this, 'enqueue_invite_assets']); + add_action('admin_enqueue_scripts', [$this, 'enqueue_verify_assets']); add_action('admin_enqueue_scripts', [$this, 'enqueue_vip_assets']); } diff --git a/src/Admin/Native/UsersMenusVerify.php b/src/Admin/Native/UsersMenusVerify.php index 8cd46727..594f1b16 100644 --- a/src/Admin/Native/UsersMenusVerify.php +++ b/src/Admin/Native/UsersMenusVerify.php @@ -3,169 +3,218 @@ namespace AKSRT\Admin\Native; +use AKSRT\Identity\ApplicationRepository; +use AKSRT\Identity\EvidenceService; +use AKSRT\Identity\EventRepository; +use AKSRT\User\AuthApply; + defined('ABSPATH') || exit; trait UsersMenusVerify { + use UsersMenusVerifyDialogs; + + private const VERIFY_PER_PAGE = 20; + private const VERIFY_STATUSES = ['pending', 'approved', 'rejected', '']; + + public function enqueue_verify_assets(string $hook): void { + if ($hook !== 'users_page_aksrt_user_auth') return; + $css = '/assets/css/admin/user-auth.css'; + $js = '/assets/js/admin-user-auth.js'; + wp_enqueue_style('aksrt-admin-user-auth', AKSRT_URI . $css, [NativeUi::STYLE_HANDLE], (string) filemtime(AKSRT_DIR . $css)); + wp_enqueue_script('aksrt-admin-user-auth', AKSRT_URI . $js, [], (string) filemtime(AKSRT_DIR . $js), true); + } private function count_verify_status(string $status): int { global $wpdb; $table = \AKSRT\Core\DBMigrator::table('identity_application'); - $stored = $status === 'pending' ? 'manual_pending' : $status; return (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE type = %s AND channel = %s AND status = %s", - 'general', - 'manual', - $stored + 'general', 'manual', $status === 'pending' ? 'manual_pending' : $status )); } public function render_verify_page(): void { $this->ensure_cap(); - $filters = $this->get_verify_filters(); - $page = max(1, (int) ($_GET['paged'] ?? 1)); - $data = $this->query_verify_requests($filters, $page, 20); - $counts = $this->get_verify_status_counts(); - NativeUi::page_open(); - NativeUi::header(__('身份认证', 'aksrt'), '', 'dashicons-id'); + $filters = $this->get_verify_filters($_GET); + $data = $this->query_verify_requests($filters, max(1, (int) ($_GET['paged'] ?? 1))); + $counts = $this->get_verify_status_counts($filters); + NativeUi::page_open('aksrt-verify-page'); + NativeUi::header( + __('身份认证', 'aksrt'), + __('查看申请资料与证明材料,在审核弹窗中确认处理结果。', 'aksrt'), + 'dashicons-id', + [['label' => __('认证设置', 'aksrt'), 'url' => admin_url('admin.php?page=aksrt-settings#/users/verify'), 'icon' => 'dashicons-admin-settings']] + ); $this->flash(); - NativeUi::stats([ - ['label' => __('待审核', 'aksrt'), 'value' => $counts['pending'], 'icon' => 'dashicons-clock', 'tone' => 'warning'], - ['label' => __('已通过', 'aksrt'), 'value' => $counts['approved'], 'icon' => 'dashicons-yes-alt', 'tone' => 'success'], - ['label' => __('已拒绝', 'aksrt'), 'value' => $counts['rejected'], 'icon' => 'dashicons-dismiss', 'tone' => 'danger'], - ]); - NativeUi::card_open('', '', false, 'aksrt-native-toolbar'); - $this->render_verify_filters($filters); - NativeUi::card_close(); - NativeUi::table_card_open(__('认证申请', 'aksrt')); - $this->render_verify_table($data['items']); + NativeUi::card_open('', '', true, 'aksrt-verify-list-card'); + $this->render_verify_filters($filters, $counts); + echo '