feat(ai): 打通 SEO 闭环与投稿写作辅助

对齐 Zibll 的 AI 生成 SEO 流程:统一 WP AI Client/主题 Provider 双后端、文章/分类 SEO 框与生成回填、投稿摘要/标签建议,并收敛扩展页为连接配置。
This commit is contained in:
kate522 committed 2026-07-12 15:36:04 +08:00
1 parent a4cd5276e2
commit b36186da65
14 files changed
+1243 -330

No files matched your search

+245
View File
@@ -0,0 +1,245 @@
<?php
/**
* AKSRT SEO Meta Box — 文章/页面独立 SEO + AI 生成按钮
*
* Meta keys 与 AKSRT_SEO_Head 读取一致:
* _aksrt_seo_title / _aksrt_seo_keywords / _aksrt_seo_description
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_SEO_Meta_Box {
private static $instance = null;
public const META_TITLE = '_aksrt_seo_title';
public const META_KEYWORDS = '_aksrt_seo_keywords';
public const META_DESCRIPTION = '_aksrt_seo_description';
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
add_action('add_meta_boxes', [$this, 'register_meta_box']);
add_action('save_post', [$this, 'save_meta_box'], 10, 2);
add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
// Term SEO (category / post_tag)
add_action('category_edit_form_fields', [$this, 'render_term_fields'], 20);
add_action('post_tag_edit_form_fields', [$this, 'render_term_fields'], 20);
add_action('edited_category', [$this, 'save_term_fields']);
add_action('edited_post_tag', [$this, 'save_term_fields']);
}
public function register_meta_box(): void {
foreach (['post', 'page'] as $screen) {
add_meta_box(
'aksrt_seo_meta',
__('独立 SEO', 'aksrt'),
[$this, 'render_meta_box'],
$screen,
'normal',
'high'
);
}
}
public function render_meta_box(WP_Post $post): void {
wp_nonce_field('aksrt_seo_meta_save', 'aksrt_seo_meta_nonce');
$title = (string) get_post_meta($post->ID, self::META_TITLE, true);
$keywords = (string) get_post_meta($post->ID, self::META_KEYWORDS, true);
$description = (string) get_post_meta($post->ID, self::META_DESCRIPTION, true);
$ai_btn = $this->render_ai_button('post', (int) $post->ID);
?>
<div class="aksrt-seo-meta-box">
<p>
<label for="aksrt_seo_title"><strong><?php esc_html_e('SEO 标题', 'aksrt'); ?></strong></label>
<input type="text" class="widefat" id="aksrt_seo_title" name="aksrt_seo_title" value="<?php echo esc_attr($title); ?>" />
</p>
<p>
<label for="aksrt_seo_keywords"><strong><?php esc_html_e('SEO 关键词', 'aksrt'); ?></strong></label>
<input type="text" class="widefat" id="aksrt_seo_keywords" name="aksrt_seo_keywords" value="<?php echo esc_attr($keywords); ?>" placeholder="<?php esc_attr_e('多个关键词用英文逗号分隔', 'aksrt'); ?>" />
</p>
<p>
<label for="aksrt_seo_description"><strong><?php esc_html_e('SEO 描述', 'aksrt'); ?></strong></label>
<textarea class="widefat" rows="3" id="aksrt_seo_description" name="aksrt_seo_description"><?php echo esc_textarea($description); ?></textarea>
<span class="description"><?php esc_html_e('建议 50–150 字。', 'aksrt'); ?><?php echo $ai_btn; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
</p>
</div>
<?php
}
public function save_meta_box(int $post_id, $post = null): void {
if (!isset($_POST['aksrt_seo_meta_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['aksrt_seo_meta_nonce'])), 'aksrt_seo_meta_save')) {
return;
}
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
return;
}
if (!current_user_can('edit_post', $post_id)) {
return;
}
if (wp_is_post_revision($post_id)) {
return;
}
$map = [
self::META_TITLE => 'aksrt_seo_title',
self::META_KEYWORDS => 'aksrt_seo_keywords',
self::META_DESCRIPTION => 'aksrt_seo_description',
];
foreach ($map as $meta_key => $field) {
if (!isset($_POST[$field])) {
continue;
}
$value = sanitize_text_field(wp_unslash((string) $_POST[$field]));
if ($value === '') {
delete_post_meta($post_id, $meta_key);
} else {
update_post_meta($post_id, $meta_key, $value);
}
}
}
/**
* @param WP_Term $term
*/
public function render_term_fields($term): void {
if (!$term instanceof WP_Term) {
return;
}
$title = (string) get_term_meta($term->term_id, self::META_TITLE, true);
$keywords = (string) get_term_meta($term->term_id, self::META_KEYWORDS, true);
$description = (string) get_term_meta($term->term_id, self::META_DESCRIPTION, true);
$ai_btn = $this->render_ai_button('term', (int) $term->term_id);
?>
<tr class="form-field">
<th scope="row"><label for="aksrt_seo_title"><?php esc_html_e('SEO 标题', 'aksrt'); ?></label></th>
<td>
<input type="text" name="aksrt_seo_title" id="aksrt_seo_title" class="aksrt-term-seo-title" value="<?php echo esc_attr($title); ?>" />
</td>
</tr>
<tr class="form-field">
<th scope="row"><label for="aksrt_seo_keywords"><?php esc_html_e('SEO 关键词', 'aksrt'); ?></label></th>
<td>
<input type="text" name="aksrt_seo_keywords" id="aksrt_seo_keywords" class="aksrt-term-seo-keywords" value="<?php echo esc_attr($keywords); ?>" />
</td>
</tr>
<tr class="form-field">
<th scope="row"><label for="aksrt_seo_description"><?php esc_html_e('SEO 描述', 'aksrt'); ?></label></th>
<td>
<textarea name="aksrt_seo_description" id="aksrt_seo_description" class="aksrt-term-seo-description" rows="4"><?php echo esc_textarea($description); ?></textarea>
<p class="description"><?php esc_html_e('主题默认会自动设置 SEO 内容,如需单独设置请在此输入。', 'aksrt'); ?><?php echo $ai_btn; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></p>
</td>
</tr>
<?php
}
public function save_term_fields(int $term_id): void {
if (!current_user_can('edit_term', $term_id)) {
return;
}
$map = [
self::META_TITLE => 'aksrt_seo_title',
self::META_KEYWORDS => 'aksrt_seo_keywords',
self::META_DESCRIPTION => 'aksrt_seo_description',
];
foreach ($map as $meta_key => $field) {
if (!isset($_POST[$field])) {
continue;
}
$value = sanitize_text_field(wp_unslash((string) $_POST[$field]));
if ($value === '') {
delete_term_meta($term_id, $meta_key);
} else {
update_term_meta($term_id, $meta_key, $value);
}
}
}
public function enqueue_assets(string $hook): void {
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
if (!$screen) {
return;
}
$ok = in_array($screen->base, ['post', 'term', 'edit-tags'], true);
if (!$ok) {
return;
}
$path = AKSRT_DIR . '/assets/js/admin-ai-seo.js';
if (!is_file($path)) {
return;
}
wp_enqueue_script(
'aksrt-admin-ai-seo',
AKSRT_URI . '/assets/js/admin-ai-seo.js',
[],
(string) filemtime($path),
true
);
$status = class_exists('AKSRT_AI') ? AKSRT_AI::get_instance()->get_status() : [];
$seo = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_seo_settings() : [];
wp_localize_script('aksrt-admin-ai-seo', 'aksrtAiSeo', [
'restUrl' => esc_url_raw(rest_url('aksrt/v1')),
'nonce' => wp_create_nonce('wp_rest'),
'ai' => [
'available' => !empty($status['available']),
'seo_ai_enabled' => !empty($seo['seo_ai_enabled']) && !empty($seo['seo_enabled']),
],
'i18n' => [
'generating' => __('生成中…', 'aksrt'),
'generate' => __('AI 生成 SEO', 'aksrt'),
'success' => __('已填入 SEO 字段,请检查后更新保存', 'aksrt'),
'failed' => __('生成失败', 'aksrt'),
'needSave' => __('请先保存后再生成 SEO', 'aksrt'),
'notAvailable' => __('AI 未配置或未启用', 'aksrt'),
],
]);
wp_add_inline_style('wp-admin', '
.aksrt-ai-seo-button{display:inline-block;margin-left:8px;vertical-align:middle}
.aksrt-ai-seo-generate{cursor:pointer}
.aksrt-ai-seo-generate.is-loading{opacity:.6;pointer-events:none}
.aksrt-ai-seo-msg{display:block;margin-top:6px;font-size:12px}
.aksrt-ai-seo-msg.is-error{color:#b32d2e}
.aksrt-ai-seo-msg.is-ok{color:#007017}
');
}
private function render_ai_button(string $type, int $id): string {
$seo = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_seo_settings() : [];
if (empty($seo['seo_enabled']) || empty($seo['seo_ai_enabled'])) {
return '';
}
$available = class_exists('AKSRT_AI') && AKSRT_AI::get_instance()->is_available();
$disabled = (!$available || $id <= 0) ? ' disabled' : '';
$title = !$available
? esc_attr__('请先配置 AI 连接(扩展 → AI 辅助 或 WP Connectors)', 'aksrt')
: ($id <= 0 ? esc_attr__('请先保存后再生成', 'aksrt') : '');
return sprintf(
'<span class="aksrt-ai-seo-button"><button type="button" class="button aksrt-ai-seo-generate"%s data-type="%s" data-id="%d" title="%s">%s</button></span>',
$disabled,
esc_attr($type),
$id,
$title,
esc_html__('AI 生成 SEO', 'aksrt')
);
}
}