Replace the inline <details> compose card on users.php?page=aksrt_messages with a primary header button that opens a native <dialog>. NativeUi::header() gains dialog-type actions via NativeUi::button_dialog(). The dialog reopens automatically after a failed send. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
264 lines
12 KiB
PHP
264 lines
12 KiB
PHP
<?php
|
||
/**
|
||
* 原生后台运营页共享 UI。
|
||
*
|
||
* 支付中心(aksrt_pay 菜单)与用户子菜单(users.php)的运营页统一使用这里的
|
||
* 页面外壳、页头、统计卡、卡片、徽标与空状态。样式在 assets/css/admin/native-ui.css,
|
||
* 只在 SLUGS 列出的页面入队,规则全部限定在 .aksrt-native 下。
|
||
*
|
||
* 输出方法直接 echo,参数在内部转义;$html 形参约定为调用方已转义的片段。
|
||
*
|
||
* @package AKSRT
|
||
*/
|
||
|
||
namespace AKSRT\Admin\Native;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
final class NativeUi {
|
||
|
||
/** 使用共享 UI 的 admin page 参数。 */
|
||
public const SLUGS = [
|
||
'aksrt_pay',
|
||
'aksrt_orders',
|
||
'aksrt_pay_products',
|
||
'aksrt_coupons',
|
||
'aksrt_charge_cards',
|
||
'aksrt_income',
|
||
'aksrt_withdraw',
|
||
'aksrt_invite_codes',
|
||
'aksrt_messages',
|
||
'aksrt_user_vip',
|
||
'aksrt_user_auth',
|
||
'aksrt_user_ban',
|
||
'aksrt_tickets',
|
||
];
|
||
|
||
public const STYLE_HANDLE = 'aksrt-admin-native-ui';
|
||
|
||
private const TONES = ['success', 'info', 'warning', 'danger', 'neutral', 'primary'];
|
||
|
||
private static $booted = false;
|
||
|
||
/** 由 PayMenus / UsersMenus 的 init() 调用,重复调用无副作用。 */
|
||
public static function boot(): void {
|
||
if (self::$booted) {
|
||
return;
|
||
}
|
||
self::$booted = true;
|
||
add_action('admin_enqueue_scripts', [self::class, 'enqueue']);
|
||
}
|
||
|
||
public static function is_native_page(): bool {
|
||
$page = isset($_GET['page']) ? sanitize_key((string) wp_unslash($_GET['page'])) : '';
|
||
return in_array($page, self::SLUGS, true);
|
||
}
|
||
|
||
public static function enqueue(): void {
|
||
if (!self::is_native_page()) {
|
||
return;
|
||
}
|
||
$css = '/assets/css/admin/native-ui.css';
|
||
if (is_file(AKSRT_DIR . $css)) {
|
||
wp_enqueue_style(self::STYLE_HANDLE, AKSRT_URI . $css, [], (string) filemtime(AKSRT_DIR . $css));
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------------------
|
||
* 页面外壳
|
||
* ----------------------------------------------------------------*/
|
||
|
||
/** 打开 .wrap 外壳。$modifier 追加到 class(如页面专属根类)。 */
|
||
public static function page_open(string $modifier = ''): void {
|
||
$class = trim('wrap aksrt-native ' . $modifier);
|
||
echo '<div class="' . esc_attr($class) . '">';
|
||
}
|
||
|
||
public static function page_close(): void {
|
||
echo '</div>';
|
||
}
|
||
|
||
/**
|
||
* 页头:图标 + 标题 + 说明 + 右侧操作。
|
||
*
|
||
* 带 dialog 键的动作渲染为打开对应 <dialog id> 的按钮(由页面脚本绑定),否则渲染为链接。
|
||
*
|
||
* @param array<int, array{label:string,url?:string,dialog?:string,primary?:bool,icon?:string}> $actions
|
||
*/
|
||
public static function header(string $title, string $description = '', string $icon = '', array $actions = []): void {
|
||
echo '<div class="aksrt-native-header"><div class="aksrt-native-header__main">';
|
||
// h1 让 WP 把 admin_notices 挪到标题之后。
|
||
echo '<h1 class="aksrt-native-header__title">';
|
||
if ($icon !== '') {
|
||
echo '<span class="aksrt-native-header__icon dashicons ' . esc_attr($icon) . '" aria-hidden="true"></span>';
|
||
}
|
||
echo esc_html($title) . '</h1>';
|
||
if ($description !== '') {
|
||
echo '<p class="aksrt-native-header__desc">' . esc_html($description) . '</p>';
|
||
}
|
||
echo '</div>';
|
||
if ($actions !== []) {
|
||
echo '<div class="aksrt-native-header__actions">';
|
||
foreach ($actions as $action) {
|
||
echo isset($action['dialog'])
|
||
? self::button_dialog($action['label'], (string) $action['dialog'], !empty($action['primary']), (string) ($action['icon'] ?? ''))
|
||
: self::button_link($action['label'], (string) $action['url'], !empty($action['primary']), (string) ($action['icon'] ?? ''));
|
||
}
|
||
echo '</div>';
|
||
}
|
||
echo '</div>';
|
||
// WP 会把 .notice 插在首个 .wrap h1 之后;这里留一个固定落点。
|
||
echo '<hr class="wp-header-end">';
|
||
}
|
||
|
||
public static function button_link(string $label, string $url, bool $primary = false, string $icon = ''): string {
|
||
$class = $primary ? 'button button-primary' : 'button';
|
||
$icon_html = $icon !== '' ? '<span class="dashicons ' . esc_attr($icon) . '" aria-hidden="true"></span>' : '';
|
||
return '<a class="' . esc_attr($class) . '" href="' . esc_url($url) . '">' . $icon_html . esc_html($label) . '</a>';
|
||
}
|
||
|
||
/** 打开页面内 <dialog id="$dialog_id"> 的按钮;点击行为由页面脚本按 data-aksrt-dialog-open 绑定。 */
|
||
public static function button_dialog(string $label, string $dialog_id, bool $primary = false, string $icon = ''): string {
|
||
$class = $primary ? 'button button-primary' : 'button';
|
||
$icon_html = $icon !== '' ? '<span class="dashicons ' . esc_attr($icon) . '" aria-hidden="true"></span>' : '';
|
||
return '<button type="button" class="' . esc_attr($class) . '" data-aksrt-dialog-open="' . esc_attr($dialog_id) . '" aria-haspopup="dialog" aria-controls="' . esc_attr($dialog_id) . '">'
|
||
. $icon_html . esc_html($label) . '</button>';
|
||
}
|
||
|
||
/* ------------------------------------------------------------------
|
||
* 统计卡
|
||
* ----------------------------------------------------------------*/
|
||
|
||
/**
|
||
* @param array<int, array{label:string,value:string|int|float,url?:string,tone?:string,icon?:string,hint?:string,unit?:string}> $items
|
||
*/
|
||
public static function stats(array $items): void {
|
||
if ($items === []) {
|
||
return;
|
||
}
|
||
echo '<div class="aksrt-native-stats">';
|
||
foreach ($items as $item) {
|
||
self::stat($item);
|
||
}
|
||
echo '</div>';
|
||
}
|
||
|
||
/** @param array{label:string,value:string|int|float,url?:string,tone?:string,icon?:string,hint?:string,unit?:string} $item */
|
||
public static function stat(array $item): void {
|
||
$tone = self::tone((string) ($item['tone'] ?? 'success'));
|
||
$url = (string) ($item['url'] ?? '');
|
||
$tag = $url !== '' ? 'a' : 'div';
|
||
$attr = $url !== '' ? ' href="' . esc_url($url) . '"' : '';
|
||
|
||
echo '<' . $tag . ' class="aksrt-native-stat aksrt-native-stat--' . esc_attr($tone) . '"' . $attr . '>';
|
||
echo '<span class="aksrt-native-stat__label">';
|
||
if (!empty($item['icon'])) {
|
||
echo '<span class="dashicons ' . esc_attr((string) $item['icon']) . '" aria-hidden="true"></span>';
|
||
}
|
||
echo esc_html($item['label']) . '</span>';
|
||
echo '<span class="aksrt-native-stat__value">';
|
||
if (!empty($item['unit'])) {
|
||
echo '<span class="aksrt-native-stat__unit">' . esc_html((string) $item['unit']) . '</span>';
|
||
}
|
||
echo esc_html((string) $item['value']) . '</span>';
|
||
if (!empty($item['hint'])) {
|
||
echo '<span class="aksrt-native-stat__hint">' . esc_html((string) $item['hint']) . '</span>';
|
||
}
|
||
echo '</' . $tag . '>';
|
||
}
|
||
|
||
/* ------------------------------------------------------------------
|
||
* 卡片
|
||
* ----------------------------------------------------------------*/
|
||
|
||
/**
|
||
* 打开卡片。$actions_html 为调用方已转义的右侧操作区片段。
|
||
* $flush=true 时正文无内边距(承载整表)。
|
||
*/
|
||
public static function card_open(string $title = '', string $actions_html = '', bool $flush = false, string $modifier = ''): void {
|
||
$class = 'aksrt-native-card' . ($flush ? ' aksrt-native-card--flush' : '') . ($modifier !== '' ? ' ' . $modifier : '');
|
||
echo '<section class="' . esc_attr($class) . '">';
|
||
if ($title !== '' || $actions_html !== '') {
|
||
echo '<div class="aksrt-native-card__head">';
|
||
if ($title !== '') {
|
||
echo '<h2 class="aksrt-native-card__title">' . esc_html($title) . '</h2>';
|
||
}
|
||
if ($actions_html !== '') {
|
||
echo '<div class="aksrt-native-card__actions">' . $actions_html . '</div>';
|
||
}
|
||
echo '</div>';
|
||
}
|
||
echo '<div class="aksrt-native-card__body">';
|
||
}
|
||
|
||
public static function card_close(): void {
|
||
echo '</div></section>';
|
||
}
|
||
|
||
/** 表格卡:整表铺满卡片,带横向滚动容器。 */
|
||
public static function table_card_open(string $title = '', string $actions_html = ''): void {
|
||
self::card_open($title, $actions_html, true);
|
||
echo '<div class="aksrt-native-table-wrap">';
|
||
}
|
||
|
||
/** 关闭表格卡;分页条应在 table-wrap 关闭后、卡片关闭前输出,故拆成两步。 */
|
||
public static function table_wrap_close(): void {
|
||
echo '</div>';
|
||
}
|
||
|
||
/* ------------------------------------------------------------------
|
||
* 徽标 / 空状态
|
||
* ----------------------------------------------------------------*/
|
||
|
||
public static function badge(string $label, string $tone = 'neutral', bool $dot = true): string {
|
||
$class = 'aksrt-native-badge aksrt-native-badge--' . self::tone($tone) . ($dot ? ' has-dot' : '');
|
||
return '<span class="' . esc_attr($class) . '">' . esc_html($label) . '</span>';
|
||
}
|
||
|
||
/**
|
||
* 按状态键取徽标。$map: 状态键 => [文案, 色调];未命中时原样输出灰色徽标。
|
||
*
|
||
* @param array<string, array{0:string,1:string}> $map
|
||
*/
|
||
public static function status_badge(string $status, array $map): string {
|
||
if (isset($map[$status])) {
|
||
return self::badge($map[$status][0], $map[$status][1]);
|
||
}
|
||
return self::badge($status !== '' ? $status : '—', 'neutral');
|
||
}
|
||
|
||
public static function empty_state(string $title, string $hint = '', string $icon = 'dashicons-portfolio'): string {
|
||
$html = '<div class="aksrt-native-empty">';
|
||
$html .= '<span class="dashicons ' . esc_attr($icon) . '" aria-hidden="true"></span>';
|
||
$html .= '<span class="aksrt-native-empty__title">' . esc_html($title) . '</span>';
|
||
if ($hint !== '') {
|
||
$html .= '<span>' . esc_html($hint) . '</span>';
|
||
}
|
||
return $html . '</div>';
|
||
}
|
||
|
||
/** 表格内的空状态整行。 */
|
||
public static function empty_row(int $colspan, string $title, string $hint = ''): string {
|
||
return '<tr class="no-items"><td class="aksrt-native-empty-cell" colspan="' . esc_attr((string) max(1, $colspan)) . '">'
|
||
. self::empty_state($title, $hint)
|
||
. '</td></tr>';
|
||
}
|
||
|
||
/** 用户单元格:头像 + 名称 + #ID。 */
|
||
public static function user_cell(int $user_id, string $fallback = ''): string {
|
||
$user = $user_id > 0 ? get_userdata($user_id) : false;
|
||
if (!$user) {
|
||
return '<span class="aksrt-native-muted">' . esc_html($fallback !== '' ? $fallback : ($user_id > 0 ? '#' . $user_id : '—')) . '</span>';
|
||
}
|
||
$avatar = function_exists('aksrt_get_avatar_url') ? (string) aksrt_get_avatar_url($user_id, 28) : (string) get_avatar_url($user_id, ['size' => 28]);
|
||
return '<a class="aksrt-native-user" href="' . esc_url(get_edit_user_link($user_id)) . '">'
|
||
. ($avatar !== '' ? '<img src="' . esc_url($avatar) . '" width="28" height="28" alt="">' : '')
|
||
. '<span><span class="aksrt-native-user__name">' . esc_html($user->display_name) . '</span>'
|
||
. '<span class="aksrt-native-user__meta">#' . esc_html((string) $user_id) . '</span></span></a>';
|
||
}
|
||
|
||
private static function tone(string $tone): string {
|
||
return in_array($tone, self::TONES, true) ? $tone : 'neutral';
|
||
}
|
||
}
|