diff --git a/assets/admin/src/api/modules/global.js b/assets/admin/src/api/modules/global.js index 0c881bb4..296a38fb 100644 --- a/assets/admin/src/api/modules/global.js +++ b/assets/admin/src/api/modules/global.js @@ -12,11 +12,6 @@ export const globalApiMethods = { getFloatingButtons() { return this.request('/settings/floating-buttons') }, saveFloatingButtons(data) { return this.request('/settings/floating-buttons', { method: 'POST', body: JSON.stringify(data) }) }, - getFriendLinks() { return this.request('/friend-links') }, - addFriendLink(data) { return this.request('/friend-links', { method: 'POST', body: JSON.stringify(data) }) }, - deleteFriendLink(id) { return this.request(`/friend-links/${id}`, { method: 'DELETE' }) }, - reorderFriendLinks(ids) { return this.request('/friend-links/reorder', { method: 'POST', body: JSON.stringify({ ids }) }) }, - getCmsFeatures() { return this.request('/settings/cms-features') }, saveCmsFeatures(cms_features) { return this.request('/settings/cms-features', { method: 'POST', body: JSON.stringify({ cms_features }) }) }, } diff --git a/assets/admin/src/schema/sections/permissions/index.js b/assets/admin/src/schema/sections/permissions/index.js index 2c33f5b8..93d1be58 100644 --- a/assets/admin/src/schema/sections/permissions/index.js +++ b/assets/admin/src/schema/sections/permissions/index.js @@ -87,6 +87,7 @@ export const permissionsSchemas = { { label: '打赏用户', value: 'tip_user' }, { label: '提交工单', value: 'ticket_create' }, { label: '回复工单', value: 'ticket_reply' }, + { label: '友情链接申请', value: 'friend_link' }, ], }, ], diff --git a/assets/css/routes/friends.css b/assets/css/routes/friends.css deleted file mode 100644 index a4459b10..00000000 --- a/assets/css/routes/friends.css +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Jio Friends Page — 友情链接页样式 - * - * @package Jio - */ - -.friends-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - gap: var(--space-4); - margin-top: var(--space-6); -} - -.friend-card { - display: flex; - align-items: center; - gap: var(--space-4); - padding: var(--card-padding); - background: var(--surface-1); - border: 1px solid var(--color-line); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-sm); - text-decoration: none; - color: var(--color-ink-2); - transition: box-shadow var(--dur) var(--ease), border-color var(--dur) var(--ease), transform var(--dur) var(--ease); -} - -.friend-card:hover { - box-shadow: var(--shadow); - border-color: var(--color-primary-soft); - transform: none; -} - -.friend-card__avatar { - width: 48px; - height: 48px; - border-radius: var(--radius-avatar, 50%); - object-fit: cover; - flex-shrink: 0; - background: var(--surface-2); -} - -.friend-card__info { - flex: 1; - min-width: 0; -} - -.friend-card__name { - font-size: var(--text-base); - font-weight: 600; - color: var(--color-ink); - margin: 0 0 4px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.friend-card__desc { - font-size: var(--text-sm); - color: var(--color-ink-3); - margin: 0; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - line-height: var(--leading-snug); -} - -/* Responsive */ -@media (max-width: 640px) { - .friends-grid { - grid-template-columns: 1fr; - } -} diff --git a/assets/css/routes/links.css b/assets/css/routes/links.css index 7933a6a1..0df7d394 100644 --- a/assets/css/routes/links.css +++ b/assets/css/routes/links.css @@ -148,6 +148,17 @@ margin-bottom: 6px; } +.jio-links-submit-status { + margin: 10px 0; + font-size: 0.92em; +} + +.jio-links-logo-preview { + width: 88px; + height: 88px; + object-fit: cover; +} + /* ---- search cover ---- */ .link-page-search-cover.page-cover { position: relative; diff --git a/assets/js/links-submit.js b/assets/js/links-submit.js new file mode 100644 index 00000000..caccf4a3 --- /dev/null +++ b/assets/js/links-submit.js @@ -0,0 +1,87 @@ +/** + * Native link submission form. + * + * @package Jio + */ +(function () { + 'use strict'; + + var cfg = window.jioLinksSubmit || {}; + var i18n = cfg.i18n || {}; + var form = document.querySelector('.jio-links-submit-form'); + if (!form) return; + + var button = form.querySelector('.jio-links-submit-btn'); + var status = form.querySelector('.jio-links-submit-status'); + var logoInput = form.querySelector('.jio-links-logo-input'); + var preview = form.querySelector('.jio-links-logo-preview'); + + function setStatus(message, type) { + if (!status) return; + status.textContent = message || ''; + status.className = 'jio-links-submit-status' + (type ? ' text-' + type : ''); + } + + if (logoInput && preview) { + logoInput.addEventListener('change', function () { + var file = logoInput.files && logoInput.files[0]; + if (!file) return; + preview.src = URL.createObjectURL(file); + }); + } + + form.addEventListener('submit', function (event) { + event.preventDefault(); + if (button && button.disabled) return; + + var payload = new FormData(form); + var original = button ? button.innerHTML : ''; + if (button) { + button.disabled = true; + button.innerHTML = ' ' + (i18n.submitting || '正在提交…'); + } + setStatus(i18n.submitting || '正在提交…', 'muted'); + + var captcha = window.JioCaptcha; + var captchaPromise = captcha ? captcha.run('friend_link', form) : Promise.resolve({}); + captchaPromise.then(function (tokens) { + if (tokens === null) throw new Error(i18n.captcha || '请完成人机验证'); + Object.keys(tokens || {}).forEach(function (key) { + payload.set(key, tokens[key]); + }); + return fetch(cfg.ajaxUrl || '/wp-admin/admin-ajax.php', { + method: 'POST', + credentials: 'same-origin', + body: payload, + }); + }).then(function (response) { + return response.json().then(function (body) { + return { ok: response.ok, body: body }; + }); + }).then(function (result) { + var body = result.body || {}; + var data = body.data || {}; + if (!result.ok || !body.success) { + throw new Error(data.message || i18n.error || '提交失败,请重试'); + } + setStatus(data.message || i18n.success || '提交成功', 'success'); + form.reset(); + if (preview) preview.src = ''; + if (button) button.disabled = false; + if (button) button.innerHTML = original; + if (captcha) captcha.reset('friend_link', form); + window.setTimeout(function () { + var modal = document.getElementById('submit-links-modal'); + if (modal) modal.classList.remove('show', 'is-open'); + setStatus('', ''); + }, 1800); + }).catch(function (error) { + setStatus(error.message || i18n.error || '提交失败,请重试', 'danger'); + if (button) { + button.disabled = false; + button.innerHTML = original; + } + if (captcha) captcha.reset('friend_link', form); + }); + }); +})(); diff --git a/functions.php b/functions.php index 8dd51395..9c8c02f6 100644 --- a/functions.php +++ b/functions.php @@ -130,6 +130,15 @@ if (class_exists('\Jio\Admin\NavMenuOptions')) { if (class_exists('\Jio\Admin\PageConfigMetaBox')) { \Jio\Admin\PageConfigMetaBox::init(); } +if (class_exists('\Jio\Admin\PageLinksMetaBox')) { + \Jio\Admin\PageLinksMetaBox::init(); +} +if (class_exists('\Jio\Admin\LinkManager')) { + \Jio\Admin\LinkManager::init(); +} +if (class_exists('\Jio\Core\LinksSubmission')) { + \Jio\Core\LinksSubmission::init(); +} \Jio\Auth\OAuth::get_instance()->init(); if (class_exists('\Jio\Auth\WeixinGzh')) { \Jio\Auth\WeixinGzh::get_instance()->init(); diff --git a/inc/helpers/jio-links.php b/inc/helpers/jio-links.php index 5cb301de..8c7db6dc 100644 --- a/inc/helpers/jio-links.php +++ b/inc/helpers/jio-links.php @@ -84,14 +84,14 @@ if (!function_exists('jio_get_page_links_settings')) : 'order' => $order, 'go_s' => (bool) get_post_meta($post_id, 'page_links_go_s', true), 'blank_s' => (bool) get_post_meta($post_id, 'page_links_blank_s', true), - // Default nofollow ON (legacy default true). - 'nofollow_s' => get_post_meta($post_id, 'page_links_nofollow_s', true) === '' - ? true - : (bool) get_post_meta($post_id, 'page_links_nofollow_s', true), + // Default nofollow ON unless the page meta explicitly turns it off. + 'nofollow_s' => metadata_exists('post', $post_id, 'page_links_nofollow_s') + ? (bool) get_post_meta($post_id, 'page_links_nofollow_s', true) + : true, 'submit_s' => (bool) get_post_meta($post_id, 'page_links_submit_s', true), - 'submit_sign_s' => get_post_meta($post_id, 'page_links_submit_sign_s', true) === '' - ? true - : (bool) get_post_meta($post_id, 'page_links_submit_sign_s', true), + 'submit_sign_s' => metadata_exists('post', $post_id, 'page_links_submit_sign_s') + ? (bool) get_post_meta($post_id, 'page_links_submit_sign_s', true) + : true, 'submit_cats' => $submit_cats, 'submit_title' => (string) (get_post_meta($post_id, 'page_links_submit_title', true) ?: __('申请入驻', 'jio')), 'submit_dec' => (string) get_post_meta($post_id, 'page_links_submit_dec', true), @@ -393,11 +393,11 @@ endif; if (!function_exists('jio_submit_links_modal')) : /** - * Frontend submit modal markup (legacy). + * Frontend link submission modal. * * @param array{title?:string,dec?:string,sign?:bool,cats?:int[]} $args */ - function jio_submit_links_modal(array $args = []): string { + function jio_submit_links_modal(array $args = [], int $post_id = 0): string { $defaults = [ 'title' => __('申请入驻', 'jio'), 'dec' => '', @@ -405,6 +405,7 @@ if (!function_exists('jio_submit_links_modal')) : 'cats' => [], ]; $args = array_merge($defaults, $args); + $post_id = $post_id > 0 ? $post_id : (int) get_queried_object_id(); $title = (string) $args['title']; $header = $title !== '' ? '
'; + if (function_exists('jio_render_captcha')) { + ob_start(); + jio_render_captcha('friend_link'); + $body .= (string) ob_get_clean(); + } + $body .= ''; $body .= ''; - $body .= wp_nonce_field('jio_frontend_links_submit', '_wpnonce', false, false); + $body .= wp_nonce_field('jio_frontend_links_submit_' . $post_id, '_wpnonce', false, false); $body .= ''; + $body .= ''; $body .= ''; } diff --git a/inc/helpers/template-tags/media-brand.php b/inc/helpers/template-tags/media-brand.php index 5b72d3d4..3f4502e8 100644 --- a/inc/helpers/template-tags/media-brand.php +++ b/inc/helpers/template-tags/media-brand.php @@ -10,24 +10,6 @@ defined('ABSPATH') || exit; // 模板标签:友情链接 // ============================================================ -if (!function_exists('jio_get_friend_links')): -/** - * 获取友情链接列表(已激活的) - * - * @return array - */ -function jio_get_friend_links() { - if (class_exists('\Jio\Settings\Settings')) { - return \Jio\Settings\Settings::get_friend_links(); - } - - $links = get_option('jio_friend_links', array()); - return array_values(array_filter($links, function($l) { - return !empty($l['active']); - })); -} -endif; - // ============================================================ // 模板标签:站点 Logo / Favicon / About // ============================================================ diff --git a/inc/modules/captcha.php b/inc/modules/captcha.php index e0d92b8a..62c46ef6 100644 --- a/inc/modules/captcha.php +++ b/inc/modules/captcha.php @@ -377,6 +377,7 @@ function jio_captcha_scenes(): array { 'tip_user' => __('打赏用户', 'jio'), 'ticket_create' => __('提交工单', 'jio'), 'ticket_reply' => __('回复工单', 'jio'), + 'friend_link' => __('友情链接申请', 'jio'), ]; } diff --git a/src/Admin/GlobalController.php b/src/Admin/GlobalController.php index 2dc240e4..262f8d8e 100644 --- a/src/Admin/GlobalController.php +++ b/src/Admin/GlobalController.php @@ -48,23 +48,6 @@ class GlobalController extends ControllerBase { 'permission_callback' => [$this, 'check_permission'], ]); - // Friend Links - register_rest_route(self::API_NAMESPACE, '/friend-links', [ - 'methods' => ['GET', 'POST'], - 'callback' => [$this, 'rest_handle_friend_links'], - 'permission_callback' => [$this, 'check_permission'], - ]); - register_rest_route(self::API_NAMESPACE, '/friend-links/(?P\d+)', [ - 'methods' => 'DELETE', - 'callback' => [$this, 'rest_delete_friend_link'], - 'permission_callback' => [$this, 'check_permission'], - ]); - register_rest_route(self::API_NAMESPACE, '/friend-links/reorder', [ - 'methods' => 'POST', - 'callback' => [$this, 'rest_reorder_friend_links'], - 'permission_callback' => [$this, 'check_permission'], - ]); - // CMS Features register_rest_route(self::API_NAMESPACE, '/settings/cms-features', [ 'methods' => ['GET', 'POST'], @@ -167,63 +150,6 @@ class GlobalController extends ControllerBase { return $this->success(['data' => $data]); } - public function rest_handle_friend_links(\WP_REST_Request $request): \WP_REST_Response { - if ($request->get_method() === 'GET') { - $links = jio_get_friend_links(); - return rest_ensure_response(['code' => 'success', 'data' => $links]); - } - - $params = $this->get_json_params($request); - $links = get_option('jio_friend_links', []); - $new_link = [ - 'id' => uniqid('fl_'), - 'name' => sanitize_text_field($params['name'] ?? ''), - 'url' => esc_url_raw($params['url'] ?? ''), - 'logo' => esc_url_raw($params['logo'] ?? ''), - 'description' => sanitize_textarea_field($params['description'] ?? ''), - 'sort_order' => (int) ($params['sort_order'] ?? 0), - 'active' => 1, - ]; - $links[] = $new_link; - usort($links, function ($a, $b) { - return ($a['sort_order'] ?? 0) <=> ($b['sort_order'] ?? 0); - }); - update_option('jio_friend_links', $links); - do_action('jio_friend_link_submitted', $new_link); - - return rest_ensure_response(['code' => 'success', 'data' => $new_link, 'message' => __('Friend link added.', 'jio')]); - } - - public function rest_delete_friend_link(\WP_REST_Request $request): \WP_REST_Response { - $id = $request->get_param('id'); - $links = get_option('jio_friend_links', []); - $links = array_filter($links, function ($link) use ($id) { - return ($link['id'] ?? '') !== $id; - }); - $links = array_values($links); - update_option('jio_friend_links', $links); - return rest_ensure_response(['code' => 'success', 'message' => __('Friend link deleted.', 'jio')]); - } - - public function rest_reorder_friend_links(\WP_REST_Request $request): \WP_REST_Response { - $ordered_ids = $request->get_param('ids'); - if (!is_array($ordered_ids)) { - return new \WP_REST_Response(['code' => 'error', 'message' => __('参数错误', 'jio')], 400); - } - $links = get_option('jio_friend_links', []); - $order_map = array_flip($ordered_ids); - usort($links, function ($a, $b) use ($order_map) { - $idx_a = $order_map[($a['id'] ?? '')] ?? 999; - $idx_b = $order_map[($b['id'] ?? '')] ?? 999; - return $idx_a <=> $idx_b; - }); - foreach ($links as $index => &$link) { - $link['sort_order'] = $index; - } - update_option('jio_friend_links', $links); - return rest_ensure_response(['code' => 'success', 'message' => __('Order saved.', 'jio')]); - } - public function rest_handle_footer_tabbar(\WP_REST_Request $request): \WP_REST_Response { if ($request->get_method() === 'GET') { $settings = class_exists('\Jio\Settings\Settings') diff --git a/src/Admin/LinkManager.php b/src/Admin/LinkManager.php new file mode 100644 index 00000000..867b7e83 --- /dev/null +++ b/src/Admin/LinkManager.php @@ -0,0 +1,89 @@ +' . ($count > 0 ? (string) $count : ''); + $admin_bar->add_node([ + 'id' => 'jio-links', + 'title' => $title, + 'href' => admin_url('link-manager.php' . ($count > 0 ? '?orderby=visible&order=asc' : '')), + 'meta' => ['title' => $count > 0 ? sprintf(__('有 %d 个链接待审核', 'jio'), $count) : __('链接管理', 'jio')], + ]); + } + + /** @param array $columns @return array */ + public static function filter_columns(array $columns): array { + $columns['visible'] = sprintf( + __('可见性(待审 %d)', 'jio'), + self::pending_count() + ); + unset($columns['rel']); + $columns['link_description'] = __('简介', 'jio'); + $columns['link_image'] = __('Logo', 'jio'); + return $columns; + } + + public static function render_column(string $column, int $link_id): void { + $link = get_bookmark($link_id); + if (!$link) { + return; + } + switch ($column) { + case 'visible': + echo !empty($link->link_visible) + ? esc_html__('已公开', 'jio') + : '' . esc_html__('待审核', 'jio') . ''; + break; + case 'link_description': + echo esc_html((string) $link->link_description); + break; + case 'link_image': + $image = esc_url((string) $link->link_image); + if ($image !== '') { + printf('', $image); + } + break; + } + } + + public static function add_edit_help(): void { + add_meta_box( + 'jio-link-fields-help', + __('链接资料说明', 'jio'), + static function (): void { + echo '

' . esc_html__('链接图像、RSS、备注和评分可在链接编辑页的“高级”区域设置;将链接设为私密即可保持待审核状态。', 'jio') . '

'; + }, + 'link', + 'side', + 'high' + ); + } + + private static function pending_count(): int { + global $wpdb; + return (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->links} WHERE link_visible != %s", + 'Y' + )); + } +} diff --git a/src/Admin/PageLinksMetaBox.php b/src/Admin/PageLinksMetaBox.php new file mode 100644 index 00000000..6237730f --- /dev/null +++ b/src/Admin/PageLinksMetaBox.php @@ -0,0 +1,203 @@ +ID) + : []; + if ($settings === [] && !metadata_exists('post', (int) $post->ID, 'page_links_nofollow_s')) { + $settings['nofollow_s'] = true; + $settings['submit_sign_s'] = true; + } + wp_nonce_field(self::NONCE_ACTION, self::NONCE_NAME); + $terms = get_terms(['taxonomy' => 'link_category', 'hide_empty' => false]); + $terms = is_wp_error($terms) ? [] : $terms; + $selected_categories = array_map('absint', (array) ($settings['category'] ?? [])); + $submit_categories = array_map('absint', (array) ($settings['submit_cats'] ?? [])); + $search_types = (array) ($settings['search_types'] ?? []); + ?> + + + + + + + + + + + + + + + + + + + + + + + + + !empty($raw['page_links_content_s']), + 'page_links_content_position' => in_array($position, ['top', 'bottom'], true) ? $position : 'top', + 'page_links_search_s' => !empty($raw['page_links_search_s']), + 'page_links_search_types' => $search_types, + 'page_links_category' => $term_ids, + 'page_links_style' => in_array($style, ['card', 'bigcard', 'image'], true) ? $style : 'card', + 'page_links_limit' => max(0, min(500, (int) ($raw['page_links_limit'] ?? 0))), + 'page_links_orderby' => in_array($orderby, ['name', 'updated', 'rating', 'rand'], true) ? $orderby : 'name', + 'page_links_order' => in_array($order, ['ASC', 'DESC'], true) ? $order : 'ASC', + 'page_links_go_s' => !empty($raw['page_links_go_s']), + 'page_links_blank_s' => !empty($raw['page_links_blank_s']), + 'page_links_nofollow_s' => !empty($raw['page_links_nofollow_s']), + 'page_links_submit_s' => !empty($raw['page_links_submit_s']), + 'page_links_submit_sign_s' => !empty($raw['page_links_submit_sign_s']), + 'page_links_submit_cats' => $submit_term_ids, + 'page_links_submit_title' => sanitize_text_field((string) ($raw['page_links_submit_title'] ?? '')), + 'page_links_submit_dec' => wp_kses_post((string) ($raw['page_links_submit_dec'] ?? '')), + ]; + + foreach ($values as $key => $value) { + update_post_meta($post_id, $key, $value); + } + } + + private static function is_links_page(\WP_Post $post): bool { + return $post->post_name === 'friends' || get_page_template_slug($post->ID) === 'templates/pages/page-friends.php'; + } + + /** @return array */ + private static function search_types(): array { + return [ + 'self' => __('站内', 'jio'), + 'baidu' => __('百度', 'jio'), + 'google' => __('谷歌', 'jio'), + 'bing' => __('必应', 'jio'), + 'sogou' => __('搜狗', 'jio'), + '360' => '360', + ]; + } + + /** @param mixed $raw @return list */ + private static function sanitize_term_ids($raw): array { + $ids = array_values(array_unique(array_filter(array_map('absint', (array) $raw)))); + return array_values(array_filter($ids, static function (int $id): bool { + $term = get_term($id, 'link_category'); + return $term && !is_wp_error($term); + })); + } +} diff --git a/src/Admin/Schema/SchemaPermissions.php b/src/Admin/Schema/SchemaPermissions.php index 4080715f..dba626e5 100644 --- a/src/Admin/Schema/SchemaPermissions.php +++ b/src/Admin/Schema/SchemaPermissions.php @@ -83,6 +83,7 @@ class SchemaPermissions { ['label' => '打赏用户', 'value' => 'tip_user'], ['label' => '提交工单', 'value' => 'ticket_create'], ['label' => '回复工单', 'value' => 'ticket_reply'], + ['label' => '友情链接申请', 'value' => 'friend_link'], ]], ]], ['key' => 'providers', 'title' => '服务商配置', 'dependsOn' => ['key' => 'enabled', 'value' => true], 'fields' => $provider_fields], diff --git a/src/Core/LinksSubmission.php b/src/Core/LinksSubmission.php new file mode 100644 index 00000000..c6d639f9 --- /dev/null +++ b/src/Core/LinksSubmission.php @@ -0,0 +1,214 @@ +post_status !== 'publish') { + self::fail(__('申请页面无效,请刷新后重试。', 'jio'), 400); + } + if (!isset($_POST['_wpnonce']) || !wp_verify_nonce( + sanitize_text_field(wp_unslash((string) $_POST['_wpnonce'])), + self::NONCE_ACTION_PREFIX . $page_id + )) { + self::fail(__('安全验证失败,请刷新页面后重试。', 'jio'), 403); + } + + $settings = jio_get_page_links_settings($page_id); + if (empty($settings['submit_s'])) { + self::fail(__('当前页面未开放链接申请。', 'jio'), 403); + } + if (!empty($settings['submit_sign_s']) && !is_user_logged_in()) { + self::fail(__('请先登录后再申请。', 'jio'), 401); + } + + $payload = wp_unslash($_POST); + if (function_exists('jio_require_captcha')) { + $captcha = jio_require_captcha('friend_link', is_array($payload) ? $payload : []); + if (is_wp_error($captcha)) { + self::fail($captcha->get_error_message(), 400); + } + } + + $ip = function_exists('jio_get_client_ip') + ? (string) jio_get_client_ip() + : (string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'); + if (function_exists('jio_captcha_rate_limited') && jio_captcha_rate_limited('friend-link-submit', $ip, 1, self::RATE_LIMIT_SECONDS)) { + self::fail(__('操作过于频繁,请稍候再试。', 'jio'), 429); + } + + $name = sanitize_text_field((string) ($payload['link_name'] ?? '')); + $name = function_exists('mb_substr') ? mb_substr($name, 0, 255) : substr($name, 0, 255); + $description = sanitize_text_field((string) ($payload['link_description'] ?? '')); + $description = function_exists('mb_substr') ? mb_substr($description, 0, 255) : substr($description, 0, 255); + $url = self::sanitize_url((string) ($payload['link_url'] ?? '')); if ($name === '') { + self::fail(__('请填写网站名称。', 'jio')); + } + if ($url === '') { + self::fail(__('请填写有效的网站地址。', 'jio')); + } + + $category_ids = self::allowed_categories($settings); + $submitted_category = absint($payload['link_category'] ?? 0); + $categories = []; + if ($category_ids === [] && $submitted_category > 0) { + self::fail(__('请选择有效的网站分类。', 'jio')); + } + if ($category_ids !== []) { + if ($submitted_category <= 0 || !in_array($submitted_category, $category_ids, true)) { + self::fail(__('请选择有效的网站分类。', 'jio')); + } + $categories[] = $submitted_category; + } + if (self::duplicate_url($url)) { + self::fail(__('该链接已存在,请勿重复提交。', 'jio'), 409); + } + + $image_url = self::upload_image(); + if (is_wp_error($image_url)) { + self::fail($image_url->get_error_message()); + } + + if (!function_exists('wp_insert_link')) { + require_once ABSPATH . 'wp-admin/includes/bookmark.php'; + } + $link_data = [ + 'link_name' => $name, + 'link_url' => $url, + 'link_description' => $description, + 'link_image' => $image_url, + 'link_visible' => is_super_admin() ? 'Y' : 'N', + 'link_category' => $categories, + ]; + $link_id = wp_insert_link($link_data, true); + if (is_wp_error($link_id) || (int) $link_id <= 0) { + self::fail(__('链接保存失败,请稍后重试。', 'jio'), 500); + } + + if ($link_data['link_visible'] !== 'Y') { + do_action('jio_friend_link_submitted', array_merge($link_data, [ + 'link_id' => (int) $link_id, + 'username' => is_user_logged_in() ? wp_get_current_user()->display_name : __('访客', 'jio'), + ])); + } + wp_send_json_success([ + 'message' => $link_data['link_visible'] === 'Y' + ? __('链接提交成功。', 'jio') + : __('链接提交成功,等待管理员审核。', 'jio'), + ]); + } + + private static function is_links_page(\WP_Post $page): bool { + return $page->post_type === 'page' + && ($page->post_name === 'friends' || get_page_template_slug($page->ID) === 'templates/pages/page-friends.php'); + } + + /** @param array $settings @return list */ + private static function allowed_categories(array $settings): array { + $configured = array_values(array_filter(array_map('absint', (array) ($settings['submit_cats'] ?? [])))); + if ($configured !== []) { + $ids = $configured; + } else { + $terms = get_terms(['taxonomy' => 'link_category', 'hide_empty' => false, 'fields' => 'ids']); + $ids = is_wp_error($terms) ? [] : array_map('absint', (array) $terms); + } + $ids = array_values(array_unique($ids)); + return array_values(array_filter($ids, static function (int $id): bool { + $term = get_term($id, 'link_category'); + return $term && !is_wp_error($term); + })); + } + + private static function sanitize_url(string $raw): string { + $url = esc_url_raw(trim($raw), ['http', 'https']); + $parts = wp_parse_url($url); + if (!is_array($parts) || empty($parts['host']) || !in_array(strtolower((string) ($parts['scheme'] ?? '')), ['http', 'https'], true)) { + return ''; + } + if (isset($parts['user']) || isset($parts['pass'])) { + return ''; + } + return $url; + } + + private static function duplicate_url(string $url): bool { + global $wpdb; + $target = self::normalize_url($url); + $urls = $wpdb->get_col($wpdb->prepare("SELECT link_url FROM {$wpdb->links} WHERE link_url <> %s", '')); + foreach ((array) $urls as $existing) { + if (self::normalize_url((string) $existing) === $target) { + return true; + } + } + return false; + } + + private static function normalize_url(string $url): string { + $parts = wp_parse_url($url); + if (!is_array($parts) || empty($parts['host'])) { + return strtolower(trim($url)); + } + $scheme = strtolower((string) ($parts['scheme'] ?? 'http')); + $host = strtolower((string) $parts['host']); + $port = isset($parts['port']) && !in_array([(string) $scheme, (int) $parts['port']], [['http', 80], ['https', 443]], true) + ? ':' . (int) $parts['port'] + : ''; + $path = (string) ($parts['path'] ?? '/'); + $path = $path === '/' ? '/' : rtrim($path, '/'); + $query = isset($parts['query']) ? '?' . $parts['query'] : ''; + return $scheme . '://' . $host . $port . $path . $query; + } + + /** @return string|\WP_Error */ + private static function upload_image() { + $file = $_FILES['image'] ?? null; + if (!is_array($file) || (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) { + return ''; + } + if ((int) ($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) { + return new \WP_Error('jio_link_upload', __('Logo 上传失败,请重试。', 'jio')); + } + if ((int) ($file['size'] ?? 0) > 3 * 1024 * 1024) { + return new \WP_Error('jio_link_upload', __('Logo 图片不能超过 3MB。', 'jio')); + } + + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/image.php'; + require_once ABSPATH . 'wp-admin/includes/media.php'; + $attachment_id = media_handle_upload('image', 0, [], [ + 'test_form' => false, + 'mimes' => [ + 'jpg|jpeg|jpe' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + ], + ]); + if (is_wp_error($attachment_id)) { + return new \WP_Error('jio_link_upload', __('Logo 上传失败,请检查图片格式和大小。', 'jio')); + } + $image_url = wp_get_attachment_image_url((int) $attachment_id, 'medium'); + return $image_url ? esc_url_raw($image_url) : new \WP_Error('jio_link_upload', __('Logo 读取失败,请重试。', 'jio')); + } + + private static function fail(string $message, int $status = 400): void { + wp_send_json_error(['message' => $message], $status); + } +} diff --git a/src/Core/ScriptsPart1b.php b/src/Core/ScriptsPart1b.php index b0c3536f..b378f6cd 100644 --- a/src/Core/ScriptsPart1b.php +++ b/src/Core/ScriptsPart1b.php @@ -536,20 +536,39 @@ trait ScriptsPart1b { ); } - // 友情链接页:路由专属 CSS + // 友情链接页:原生链接列表 + 申请表单 if (is_page_template('templates/pages/page-friends.php')) { - wp_enqueue_style( - 'jio-route-friends', - $base_uri . '/assets/css/routes/friends.css', - ['jio-public'], - file_exists(get_template_directory() . '/assets/css/routes/friends.css') - ? (string) filemtime(get_template_directory() . '/assets/css/routes/friends.css') - : $version - ); + $links_css = get_template_directory() . '/assets/css/routes/links.css'; + if (file_exists($links_css)) { + wp_enqueue_style( + 'jio-route-links', + $base_uri . '/assets/css/routes/links.css', + ['jio-public'], + (string) filemtime($links_css) + ); + } + $links_js = get_template_directory() . '/assets/js/links-submit.js'; + if (file_exists($links_js)) { + wp_enqueue_script( + 'jio-links-submit', + $base_uri . '/assets/js/links-submit.js', + ['loader_js'], + (string) filemtime($links_js), + true + ); + wp_localize_script('jio-links-submit', 'jioLinksSubmit', [ + 'ajaxUrl' => admin_url('admin-ajax.php'), + 'i18n' => [ + 'submitting' => __('正在提交…', 'jio'), + 'success' => __('提交成功', 'jio'), + 'error' => __('提交失败,请重试', 'jio'), + 'captcha' => __('请完成人机验证', 'jio'), + ], + ]); + } } - // 网址导航页(legacy links.php) - // 文章导航页(legacy postsnavs.php) + // 文章导航页(postsnavs.php) if (is_page_template('templates/pages/page-posts-navs.php')) { $page_navs_js = get_template_directory() . '/assets/js/page-navs.js'; if (file_exists($page_navs_js)) { diff --git a/src/Core/Setup.php b/src/Core/Setup.php index ff170c2d..d4247599 100644 --- a/src/Core/Setup.php +++ b/src/Core/Setup.php @@ -26,6 +26,7 @@ class Setup { add_action('after_setup_theme', [$this, 'load_textdomain'], 1); add_action('after_setup_theme', [$this, 'setup_theme']); add_filter('dynamic_sidebar_params', [$this, 'filter_sidebar_widget_params']); + add_filter('pre_option_link_manager_enabled', '__return_true'); add_action('init', [$this, 'register_nav_menus'], 0); add_action('init', [$this, 'register_post_types'], 1); add_action('init', [$this, 'register_meta_fields'], 5); diff --git a/src/Settings/DisplayPageGroups.php b/src/Settings/DisplayPageGroups.php index 262436ff..0a5b4572 100644 --- a/src/Settings/DisplayPageGroups.php +++ b/src/Settings/DisplayPageGroups.php @@ -189,7 +189,6 @@ trait DisplayPageGroups { 'tabbar' => self::get_footer_tabbar_settings(), 'floating_buttons' => self::get_floating_buttons_settings(), 'header_theme' => self::get_header_theme_config(), - 'friend_links' => self::get_friend_links(), 'cms_features' => self::get_cms_features(), 'page_display' => self::get_page_display_settings(), 'footer_theme' => self::get_footer_theme_config(), diff --git a/src/Settings/DisplayRuntime.php b/src/Settings/DisplayRuntime.php deleted file mode 100644 index 252e9f37..00000000 --- a/src/Settings/DisplayRuntime.php +++ /dev/null @@ -1,26 +0,0 @@ - (string) ($link['name'] ?? ''), 'username' => __('访客', 'jio'), 'url' => (string) ($link['url'] ?? '')]); + $url = admin_url('link-manager.php?orderby=visible&order=asc'); + self::send_admin('links_submit_admin', [ + 'title' => (string) ($link['link_name'] ?? $link['name'] ?? ''), + 'username' => (string) ($link['username'] ?? __('访客', 'jio')), + 'url' => (string) ($link['link_url'] ?? $link['url'] ?? $url), + 'review_url' => $url, + ]); + + if (!class_exists('\Jio\Messages\Message') || !class_exists('\Jio\Settings\Integration') || !\Jio\Settings\Integration::message_enabled()) { + return; + } + $title = sprintf(__('新的链接待审核:%s', 'jio'), (string) ($link['link_name'] ?? $link['name'] ?? '')); + $content = sprintf( + __('网站有新的链接提交:%1$s(%2$s),提交人:%3$s。', 'jio'), + (string) ($link['link_name'] ?? $link['name'] ?? ''), + (string) ($link['link_url'] ?? $link['url'] ?? ''), + (string) ($link['username'] ?? __('访客', 'jio')) + ); + foreach (get_users(['role' => 'administrator', 'fields' => 'ID', 'number' => 20]) as $admin_id) { + \Jio\Messages\Message::get_instance()->send((int) $admin_id, 'system', $content, [ + 'title' => $title, + 'target_url' => $url, + ]); + } } /** @param array $vars */ diff --git a/src/Settings/MailTemplates.php b/src/Settings/MailTemplates.php index 4efaa41f..1800cf51 100644 --- a/src/Settings/MailTemplates.php +++ b/src/Settings/MailTemplates.php @@ -13,7 +13,7 @@ final class MailTemplates { /** @return array> */ public static function definitions(): array { return [ - 'links_submit_admin' => self::event('友情链接提交通知', '新友情链接提交:{title}\n提交人:{username}\n链接:{url}\n请登录后台审核。', ['title', 'username', 'url']), + 'links_submit_admin' => self::event('友情链接提交通知', '新友情链接提交:{title}\n提交人:{username}\n链接:{url}\n请登录后台审核:{review_url}', ['title', 'username', 'url', 'review_url']), 'order_paid_admin' => self::event('新订单通知(管理员)', '收到一笔新订单。\n订单号:{order_number}\n用户:{username}\n商品:{product}\n金额:{amount} {currency}\n支付方式:{payment_method}', ['order_number', 'username', 'product', 'amount', 'currency', 'payment_method']), 'withdraw_submitted_admin' => self::event('提现申请通知(管理员)', '用户 {username} 提交了提现申请。\n订单号:{withdrawal_number}\n金额:{amount}\n方式:{method}', ['username', 'withdrawal_number', 'amount', 'method']), 'post_pending_admin' => self::event('投稿待审核通知', '用户 {username} 提交了待审核内容:{title}\n查看地址:{url}', ['username', 'title', 'url']), diff --git a/src/Settings/Settings.php b/src/Settings/Settings.php index 8e10dc6d..86c31a3a 100644 --- a/src/Settings/Settings.php +++ b/src/Settings/Settings.php @@ -40,7 +40,6 @@ class Settings { 'jio_floating_buttons', 'jio_credit_settings', 'jio_notification_settings', - 'jio_friend_links', 'jio_category_recommend', ]; @@ -705,21 +704,6 @@ class Settings { public static function apply_list_per_page($query): void { PostDisplay::apply_list_per_page($query); } - - - - /** 获取友链列表 */ - public static function get_friend_links(): array { - return DisplayRuntime::get_friend_links(); - } - - - - /** 保存友链 */ - public static function save_friend_links(array $links): void { - DisplayRuntime::save_friend_links($links); - } - /** 获取微信公众号登录完整配置。 */ public static function get_weixingzh_settings(): array { return Integration::get_weixingzh_settings(); diff --git a/templates/pages/page-friends.php b/templates/pages/page-friends.php index baa42301..e5683b11 100644 --- a/templates/pages/page-friends.php +++ b/templates/pages/page-friends.php @@ -10,7 +10,10 @@ defined('ABSPATH') || exit; get_header(); -$links = function_exists('jio_get_friend_links') ? jio_get_friend_links() : []; +$post_id = (int) get_queried_object_id(); +$settings = function_exists('jio_get_page_links_settings') ? jio_get_page_links_settings($post_id) : []; +$links_html = function_exists('jio_page_links_html') ? jio_page_links_html($post_id) : ''; +$search_html = function_exists('jio_link_get_search_cover') ? jio_link_get_search_cover($post_id) : ''; if (function_exists('jio_archive_open_shell')) { jio_archive_open_shell([ @@ -26,39 +29,40 @@ if (function_exists('jio_archive_open_shell')) { 'title' => esc_html__('友情链接', 'jio'), 'desc' => __('与这些优秀的站点交换友链', 'jio'), ]); + echo $search_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper returns escaped markup. + + if (!empty($settings['content_s']) && ($settings['content_position'] ?? 'top') === 'top') { + echo '
'; + the_content(); + echo '
'; + } + + echo $links_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper returns escaped markup. + + if (!empty($settings['content_s']) && ($settings['content_position'] ?? 'top') !== 'top') { + echo '
'; + the_content(); + echo '
'; + } + + if (!empty($settings['submit_s']) && !empty($settings['submit_title'])) { + printf( + '

%s

', + esc_html((string) $settings['submit_title']) + ); + } ?> - - - - -
-

-
- - -

- -

(string) ($settings['submit_title'] ?? ''), + 'dec' => (string) ($settings['submit_dec'] ?? ''), + 'sign' => !empty($settings['submit_sign_s']), + 'cats' => (array) ($settings['submit_cats'] ?? []), + ], $post_id); +} + if (function_exists('jio_archive_close_shell')) { jio_archive_close_shell(['has_sidebar' => false]); } diff --git a/tests/widget-system/frontend-avatar-shape-contract.test.cjs b/tests/widget-system/frontend-avatar-shape-contract.test.cjs index 079ac3ec..79dd427f 100644 --- a/tests/widget-system/frontend-avatar-shape-contract.test.cjs +++ b/tests/widget-system/frontend-avatar-shape-contract.test.cjs @@ -53,11 +53,11 @@ test('the user center and public user home keep their legacy rounded-square avat assert.doesNotMatch(userHome, /\.jio-author-hero__avatar img\s*\{[^}]*--radius-avatar/s); }); -test('every other frontend avatar frame is circular', () => { - const friends = read('assets/css/routes/friends.css'); +test('link page cards keep their circular logo frames', () => { + const links = read('assets/css/routes/links.css'); - assert.match(friends, /\.friend-card__avatar\s*\{[^}]*border-radius:\s*var\(--radius-avatar,\s*50%\)/s); - assert.doesNotMatch(friends, /\.friend-card__avatar\s*\{[^}]*border-radius:\s*var\(--radius\)/s); + assert.match(links, /\.links-card\.big-card \.link-img img\s*\{[^}]*border-radius:\s*100px/s); + assert.match(links, /\.link-img\s*\{[^}]*width:\s*var\(--this-size\)/s); }); test('the user card name row can shrink once the VIP icon joins the badge row', () => { diff --git a/tests/widget-system/links-page-contract.test.cjs b/tests/widget-system/links-page-contract.test.cjs new file mode 100644 index 00000000..d2035cfc --- /dev/null +++ b/tests/widget-system/links-page-contract.test.cjs @@ -0,0 +1,62 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') + +const root = path.resolve(__dirname, '..', '..') +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8') + +test('native links are the only friend-link store', () => { + const setup = read('src/Core/Setup.php') + const submissions = read('src/Core/LinksSubmission.php') + const template = read('templates/pages/page-friends.php') + const helper = read('inc/helpers/jio-links.php') + + assert.match(setup, /pre_option_link_manager_enabled/) + assert.match(submissions, /ACTION = 'jio_frontend_links_submit'/) + assert.match(submissions, /add_action\('wp_ajax_' \. self::ACTION/) + assert.match(submissions, /add_action\('wp_ajax_nopriv_' \. self::ACTION/) + assert.match(submissions, /wp_insert_link\(/) + assert.match(submissions, /jio_require_captcha\('friend_link'/) + assert.match(submissions, /jio_captcha_rate_limited\('friend-link-submit'/) + assert.match(template, /jio_page_links_html\(/) + assert.match(template, /jio_submit_links_modal\(/) + assert.match(helper, /get_bookmarks\(/) +}) + +test('the retired friend-link option and admin REST surface stay gone', () => { + for (const file of [ + 'src/Settings/DisplayRuntime.php', + 'assets/css/routes/friends.css', + ]) { + assert.equal(fs.existsSync(path.join(root, file)), false, file) + } + for (const file of [ + 'src/Admin/GlobalController.php', + 'src/Settings/Settings.php', + 'src/Settings/DisplayPageGroups.php', + 'inc/helpers/template-tags/media-brand.php', + 'assets/admin/src/api/modules/global.js', + ]) { + const source = read(file) + assert.doesNotMatch(source, /jio_friend_links|friend-links|get_friend_links|save_friend_links/, file) + } +}) + +test('page options and submission wiring point at the links template', () => { + const metaBox = read('src/Admin/PageLinksMetaBox.php') + const scripts = read('src/Core/ScriptsPart1b.php') + const captcha = read('inc/modules/captcha.php') + const notifications = read('src/Settings/MailNotifications.php') + const templates = read('src/Settings/MailTemplates.php') + + assert.match(metaBox, /templates\/pages\/page-friends\.php/) + assert.match(metaBox, /wp_verify_nonce/) + assert.match(metaBox, /current_user_can\('edit_page'/) + assert.match(metaBox, /page_links_submit_s/) + assert.match(scripts, /links-submit\.js/) + assert.match(captcha, /'friend_link'\s*=>/) + assert.match(notifications, /links_submit_admin/) + assert.match(notifications, /Message::get_instance\(\)->send/) + assert.match(templates, /review_url/) +})