Files
wordpress-theme-aksrt/src/Admin/Native/NativeUi.php
T
kate522andClaude Fable 5 6195508481 feat(admin): move message compose into a header-button dialog
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>
2026-10-30 16:42:56 +08:00

264 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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';
}
}