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:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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') . '">×</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)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user