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 '';
+ }
+
+ echo $links_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper returns escaped markup.
+
+ if (!empty($settings['content_s']) && ($settings['content_position'] ?? 'top') !== 'top') {
+ 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/)
+})