feat(ux): shared ajax-list/remote-modal + order review modal 1:1

Add zibll-parity AjaxList/RemoteModal engines and replace order-center
prompt() review flow with multi-dim star modal, multi-image upload,
auto-praise countdown, and REST review-modal endpoint.
This commit is contained in:
2026-07-18 16:26:28 +08:00
parent 081da99528
commit e2587ca3a0
12 changed files with 1499 additions and 48 deletions
+6
View File
@@ -373,6 +373,12 @@ class ShopController extends ControllerBase {
'permission_callback' => [$this, 'check_logged_in_permission'],
]);
register_rest_route(self::API_NAMESPACE, '/shop/frontend/orders/(?P<order_number>[A-Za-z0-9]+)/review-modal', [
'methods' => 'GET',
'callback' => [$this, 'get_frontend_product_review_modal'],
'permission_callback' => [$this, 'check_logged_in_permission'],
]);
register_rest_route(self::API_NAMESPACE, '/shop/frontend/products/(?P<id>\d+)/favorite', [
'methods' => 'POST',
'callback' => [$this, 'toggle_frontend_product_favorite'],
@@ -533,11 +533,35 @@ trait ShopControllerFrontendFlowOperations {
return $this->error($result['message'] ?? __('评价失败', 'aksrt'));
}
return $this->success([
'message' => __('评价已提交', 'aksrt'),
'message' => __('评价成功', 'aksrt'),
'data' => $result,
], 201);
}
/**
* GET review modal HTML for an order (zibll order_comment_modal parity).
*/
public function get_frontend_product_review_modal(\WP_REST_Request $request): \WP_REST_Response {
if (!class_exists('\AKSRT\Shop\ProductReview')) {
return $this->error(__('评价服务不可用', 'aksrt'), 500);
}
$order_number = sanitize_text_field((string) $request->get_param('order_number'));
$result = \AKSRT\Shop\ProductReview::get_instance()->get_review_modal_html(
get_current_user_id(),
$order_number
);
if (empty($result['success'])) {
return $this->error((string) ($result['message'] ?? __('无法打开评价', 'aksrt')));
}
return $this->success([
'html' => (string) ($result['html'] ?? ''),
'product_id' => (int) ($result['product_id'] ?? 0),
'need_shipping' => !empty($result['need_shipping']),
'img_max' => (int) ($result['img_max'] ?? 0),
'remain_seconds' => (int) ($result['remain_seconds'] ?? 0),
]);
}
public function create_frontend_product(\WP_REST_Request $request): \WP_REST_Response {
$params = $this->get_json_params($request);
$params['author_id'] = get_current_user_id();
+2 -2
View File
@@ -117,7 +117,7 @@ trait ScriptsPart1b {
}
// 4a-b. 全局补充样式(骨架屏、空状态、分页、加载指示器)
foreach (['skeleton', 'empty-state', 'pagination', 'ajax-spinner'] as $slug) {
foreach (['skeleton', 'empty-state', 'pagination', 'ajax-spinner', 'remote-modal'] as $slug) {
$css_path = get_template_directory() . '/assets/css/frontend/' . $slug . '.css';
if (file_exists($css_path)) {
wp_enqueue_style(
@@ -419,7 +419,7 @@ trait ScriptsPart1b {
wp_enqueue_script(
'aksrt-order-center',
$base_uri . '/assets/js/order-center.js',
['aksrt-script', 'aksrt-weixin-share'],
['aksrt-script', 'aksrt-weixin-share', 'aksrt-core-remote-modal'],
file_exists(get_template_directory() . '/assets/js/order-center.js')
? (string) filemtime(get_template_directory() . '/assets/js/order-center.js')
: $version,
+3
View File
@@ -118,6 +118,9 @@ trait ScriptsPart1c {
'aksrt-core-comments' => 'comments.js',
'aksrt-core-comment-fixed' => 'comment-fixed-input.js',
'aksrt-core-comment-move' => 'comment-move-form.js',
// Shared zibll-parity list/modal engines (before pagination consumers).
'aksrt-core-ajax-list' => 'ajax-list.js',
'aksrt-core-remote-modal' => 'remote-modal.js',
'aksrt-core-pagination' => 'pagination.js',
'aksrt-core-archive' => 'archive.js',
'aksrt-core-footer-tabbar' => 'footer-tabbar.js',
+209 -7
View File
@@ -55,10 +55,12 @@ class ProductReview {
$images = [];
$max_images = $this->get_max_images();
foreach ((array) ($data['images'] ?? []) as $img) {
if (count($images) >= $max_images) break;
$url = esc_url_raw((string) $img);
if ($url !== '') {
$images[] = $url;
if (count($images) >= $max_images) {
break;
}
$stored = $this->normalize_review_image((string) $img, $user_id);
if ($stored !== '') {
$images[] = $stored;
}
}
@@ -301,9 +303,209 @@ class ProductReview {
}
}
private function get_max_images(): int {
if (!class_exists('\AKSRT\Settings\Settings')) return 9;
/**
* Remaining seconds until auto-positive review, or 0 if unknown/closed.
*/
public function get_auto_review_remaining_seconds(string $order_number): int {
if (!class_exists('\AKSRT\Payment\OrderRepository') || !class_exists('\AKSRT\Settings\Settings')) {
return 0;
}
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
if (!$order) {
return 0;
}
$anchor = (string) ($order['delivered_at'] ?? $order['completed_at'] ?? $order['updated_at'] ?? $order['created_at'] ?? '');
if ($anchor === '') {
return 0;
}
$shop = \AKSRT\Settings\Settings::get_shop_settings();
return max(1, min(20, (int) ($shop['timing']['comment_img_num'] ?? 9)));
$max_day = max(1, (int) ($shop['timing']['comment_max_day'] ?? 15));
$deadline = strtotime($anchor . ' +' . $max_day . ' days');
if (!$deadline) {
return 0;
}
$remain = $deadline - time();
return $remain > 0 ? (int) $remain : 0;
}
/**
* Whether shipping score row should be shown for this order.
*/
public function order_needs_shipping_score(array $order): bool {
$method = is_array($order['shipping_method'] ?? null) ? $order['shipping_method'] : [];
$value = strtolower((string) ($method['value'] ?? $method['type'] ?? $order['delivery_type'] ?? ''));
if ($value === '') {
// Prefer showing logistics when shipping records or fee exist.
if (!empty($order['shipping_records']) || !empty($order['shipping']['count'])) {
return true;
}
$fee = (float) ($order['shipping_fee'] ?? ($order['shipping_method']['fee'] ?? 0));
return $fee > 0;
}
if (in_array($value, ['virtual', 'auto', 'none', 'offline', 'self', 'pickup', 'no_shipping'], true)) {
return false;
}
return true;
}
/**
* Build order review modal HTML (zibll order_comment_modal parity).
*
* @return array{success:bool,html?:string,message?:string}
*/
public function get_review_modal_html(int $user_id, string $order_number): array {
if ($user_id <= 0) {
return ['success' => false, 'message' => __('请先登录', 'aksrt')];
}
if ($order_number === '' || !class_exists('\AKSRT\Payment\OrderRepository')) {
return ['success' => false, 'message' => __('订单不存在', 'aksrt')];
}
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
if (!$order || (int) ($order['user_id'] ?? 0) !== $user_id) {
return ['success' => false, 'message' => __('订单数据异常', 'aksrt')];
}
$product_id = 0;
$items = class_exists('\AKSRT\Shop\Shop')
? Shop::get_instance()->get_order_items($order_number)
: [];
if (!empty($items[0]['product_id'])) {
$product_id = (int) $items[0]['product_id'];
} elseif (!empty($order['product_id'])) {
$product_id = (int) $order['product_id'];
}
if ($product_id <= 0) {
return ['success' => false, 'message' => __('订单商品无效', 'aksrt')];
}
if ($this->has_reviewed($user_id, $order_number, $product_id)) {
return ['success' => false, 'message' => __('订单已评价', 'aksrt')];
}
if (!$this->can_review($user_id, $order_number, $product_id)) {
return ['success' => false, 'message' => __('当前订单不可评价', 'aksrt')];
}
$shop = class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_shop_settings()
: [];
$placeholder = (string) ($shop['timing']['comment_placeholder'] ?? '');
if ($placeholder === '') {
$placeholder = __('展开说说对商品的看法吧~', 'aksrt');
}
$img_num = max(0, min(9, (int) ($shop['timing']['comment_img_num'] ?? 3)));
$need_shipping = $this->order_needs_shipping_score($order);
$remain = $this->get_auto_review_remaining_seconds($order_number);
$score_html = '';
$score_html .= $this->render_score_row(__('商品评分', 'aksrt'), 'product');
if ($need_shipping) {
$score_html .= $this->render_score_row(__('物流服务', 'aksrt'), 'shipping');
}
$score_html .= $this->render_score_row(__('服务态度', 'aksrt'), 'service');
$img_html = '';
if ($img_num > 0) {
$img_html = '<div class="form-upload mb10">';
$img_html .= '<div class="em09 muted-2-color mb6">' . esc_html__('图片', 'aksrt') . '</div>';
$img_html .= '<div class="preview" data-aksrt-review-preview data-max="' . esc_attr((string) $img_num) . '">';
$img_html .= '<div class="add" data-aksrt-review-add role="button" tabindex="0" aria-label="' . esc_attr__('添加图片', 'aksrt') . '"></div>';
$img_html .= '</div>';
$img_html .= '<input class="hide" type="file" data-aksrt-review-file multiple accept="image/gif,image/jpeg,image/jpg,image/png,image/webp" />';
$img_html .= '</div>';
}
$title = '<b>' . esc_html__('评价订单', 'aksrt') . '</b>';
if ($remain > 0) {
$title .= '<span class="px12 muted-2-color ml6">'
. '<span data-aksrt-review-countdown="' . esc_attr((string) $remain) . '">' . esc_html($this->format_countdown($remain)) . '</span>'
. esc_html__('后自动好评', 'aksrt')
. '</span>';
}
$html = '<div class="touch border-title">' . $title . '</div>';
$html .= '<button type="button" class="close abs-close aksrt-remote-modal__close" data-aksrt-remote-modal-close aria-label="' . esc_attr__('关闭', 'aksrt') . '">&times;</button>';
$html .= '<form class="order-comment-from form-upload" data-aksrt-order-review-form data-order-number="' . esc_attr($order_number) . '" data-product-id="' . esc_attr((string) $product_id) . '" data-need-shipping="' . ($need_shipping ? '1' : '0') . '">';
$html .= '<div class="mb20">' . $score_html . '</div>';
$html .= '<div class="mb20"><textarea class="form-control" name="comment" rows="4" placeholder="' . esc_attr($placeholder) . '"></textarea></div>';
$html .= $img_html;
$html .= '<div class="review-error" data-aksrt-review-error hidden></div>';
$html .= '<div class="but-average modal-buts">';
$html .= '<button type="button" class="but" data-aksrt-remote-modal-close>' . esc_html__('取消', 'aksrt') . '</button>';
$html .= '<button type="submit" class="but c-blue btn-primary" data-aksrt-review-submit><i class="fa fa-check" aria-hidden="true"></i> ' . esc_html__('确认提交', 'aksrt') . '</button>';
$html .= '</div></form>';
return [
'success' => true,
'html' => $html,
'product_id' => $product_id,
'need_shipping' => $need_shipping,
'img_max' => $img_num,
'remain_seconds' => $remain,
];
}
private function render_score_row(string $label, string $key): string {
return '<div class="mb10 shop-score-box flex ac" data-aksrt-score-box data-score-key="' . esc_attr($key) . '" score-value="0">'
. '<div class="em09 muted-2-color score-title">' . esc_html($label) . '</div>'
. '<div class="score-icon em16 mr10 ml20" style="height:30px;"></div>'
. '<div class="score-text em09 muted-2-color"></div>'
. '<input class="score-input" type="hidden" name="score[' . esc_attr($key) . ']" value="0">'
. '</div>';
}
private function format_countdown(int $seconds): string {
$seconds = max(0, $seconds);
$d = intdiv($seconds, 86400);
$h = intdiv($seconds % 86400, 3600);
$m = intdiv($seconds % 3600, 60);
$s = $seconds % 60;
if ($d > 0) {
return sprintf('%d天%02d:%02d:%02d', $d, $h, $m, $s);
}
return sprintf('%02d:%02d:%02d', $h, $m, $s);
}
/**
* Accept https URL or data:image/*;base64,... and return a public URL.
*/
private function normalize_review_image(string $raw, int $user_id): string {
$raw = trim($raw);
if ($raw === '') {
return '';
}
if (preg_match('#^https?://#i', $raw)) {
return esc_url_raw($raw);
}
if (!preg_match('#^data:image/(png|jpe?g|gif|webp);base64,#i', $raw, $m)) {
return '';
}
$binary = base64_decode(substr($raw, strpos($raw, ',') + 1), true);
if ($binary === false || $binary === '') {
return '';
}
if (strlen($binary) > 5 * 1024 * 1024) {
return '';
}
$ext = strtolower($m[1]);
if ($ext === 'jpeg') {
$ext = 'jpg';
}
$filename = 'review-' . $user_id . '-' . wp_generate_password(8, false) . '.' . $ext;
$upload = wp_upload_bits($filename, null, $binary);
if (!empty($upload['error']) || empty($upload['url'])) {
return '';
}
return esc_url_raw((string) $upload['url']);
}
private function get_max_images(): int {
if (!class_exists('\AKSRT\Settings\Settings')) {
return 9;
}
$shop = \AKSRT\Settings\Settings::get_shop_settings();
return max(0, min(20, (int) ($shop['timing']['comment_img_num'] ?? 9)));
}
}