From 8fe708bf1e23d04e7d481b5fbcb54253322bcabc Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 12 Jul 2026 15:41:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E5=90=8E=E5=8F=B0=E6=91=98?= =?UTF-8?q?=E8=A6=81=E7=94=9F=E6=88=90=E4=B8=8E=20SEO=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E6=96=87=E6=A1=88=E8=A1=A5=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 独立 SEO 框增加 AI 生成摘要写入 excerpt;全局 SEO schema 补充 AI 开关说明与提示词占位。 --- .../admin/src/schema/sections/global/seo.js | 57 ++++- assets/js/admin-ai-seo.js | 200 +++++++++++++++--- .../class-aksrt-admin-schema-global.php | 14 +- inc/classes/class-aksrt-seo-meta-box.php | 56 ++++- 4 files changed, 267 insertions(+), 60 deletions(-) diff --git a/assets/admin/src/schema/sections/global/seo.js b/assets/admin/src/schema/sections/global/seo.js index 6caaede1..c11fb852 100644 --- a/assets/admin/src/schema/sections/global/seo.js +++ b/assets/admin/src/schema/sections/global/seo.js @@ -2,7 +2,7 @@ export const seoSchema = { key: 'global.seo', title: 'SEO 优化', icon: 'fas fa-search', - description: '站点标题、描述、关键词、Sitemap 等搜索引擎优化设置。', + description: '站点标题、描述、关键词与 AI SEO。AI 生成入口在文章「独立 SEO」框;连接配置在「扩展 → AI 连接配置」。优先使用 WordPress AI Connectors。', defaults: { seo_enabled: true, seo_ai_enabled: false, @@ -36,9 +36,22 @@ export const seoSchema = { title: 'SEO 基础', icon: 'fas fa-globe', fields: [ - { key: 'seo_enabled', label: '启用核心 SEO 优化', type: 'switch', default: true }, - { key: 'seo_ai_enabled', label: '启用 AI SEO 生成', type: 'switch', default: false }, - { key: 'seo_connector', label: '标题连接符', type: 'input', default: '-', placeholder: '例如:- 或 |' }, + { + key: 'seo_enabled', + label: '启用核心 SEO 优化', + type: 'switch', + default: true, + help: '关闭后主题不再输出 SEO title/description/keywords 等 head 标签。', + }, + { + key: 'seo_ai_enabled', + label: '启用 AI SEO 生成', + type: 'switch', + default: false, + help: '开启后文章/分类「独立 SEO」框显示「AI 生成 SEO」按钮。需先配置 AI 连接(WP Connectors 或扩展 → AI 连接配置)。', + dependsOn: { key: 'seo_enabled', value: true }, + }, + { key: 'seo_connector', label: '标题连接符', type: 'input', default: '-', placeholder: '例如:- 或 |', help: 'AI 生成标题时会用此连接符追加站点名。' }, { key: 'seo_title', label: '站点 SEO 标题', type: 'textarea', rows: 2, default: '' }, { key: 'seo_keywords', label: '站点 SEO 关键词', type: 'textarea', rows: 3, default: '' }, { key: 'seo_description', label: '站点 SEO 描述', type: 'textarea', rows: 4, default: '' }, @@ -46,12 +59,40 @@ export const seoSchema = { }, { key: 'ai-seo', - title: 'AI SEO', + title: 'AI SEO 提示词', icon: 'fas fa-robot', + description: '留空则使用内置 Zibll 风格默认提示词。填写后作为 system instruction 覆盖默认。', fields: [ - { key: 'ai_seo_title_prompt', label: '标题提示词', type: 'textarea', rows: 3, default: '' }, - { key: 'ai_seo_keywords_prompt', label: '关键词提示词', type: 'textarea', rows: 3, default: '' }, - { key: 'ai_seo_description_prompt', label: '描述提示词', type: 'textarea', rows: 3, default: '' }, + { + key: 'ai_seo_title_prompt', + label: '标题提示词', + type: 'textarea', + rows: 5, + default: '', + dependsOn: { key: 'seo_ai_enabled', value: true }, + placeholder: '你是一名资深的网站 SEO 编辑……直接输出标题文本本身,不要任何前缀、引号或解释。', + help: '控制 AI 如何生成 SEO 标题。留空使用主题内置默认。', + }, + { + key: 'ai_seo_keywords_prompt', + label: '关键词提示词', + type: 'textarea', + rows: 5, + default: '', + dependsOn: { key: 'seo_ai_enabled', value: true }, + placeholder: '你是一名资深 SEO 编辑……使用英文逗号分隔,直接输出关键词列表。', + help: '控制 AI 如何生成 meta keywords。留空使用主题内置默认。', + }, + { + key: 'ai_seo_description_prompt', + label: '描述提示词', + type: 'textarea', + rows: 5, + default: '', + dependsOn: { key: 'seo_ai_enabled', value: true }, + placeholder: '你是一名资深的中文 SEO 编辑……字数控制在 80 至 150 个汉字之间。', + help: '控制 AI 如何生成 meta description。留空使用主题内置默认。', + }, ], }, { diff --git a/assets/js/admin-ai-seo.js b/assets/js/admin-ai-seo.js index f358bc70..b587ec0d 100644 --- a/assets/js/admin-ai-seo.js +++ b/assets/js/admin-ai-seo.js @@ -1,5 +1,5 @@ /** - * AKSRT Admin AI SEO — generate button for post/term SEO fields + * AKSRT Admin AI SEO — SEO generate + summary → excerpt */ (function () { 'use strict'; @@ -12,7 +12,7 @@ var I18N = CFG.i18n || {}; function notify($btn, msg, type) { - var wrap = $btn.closest('.aksrt-seo-meta-box, td, .form-field, p') || $btn.parentNode; + var wrap = $btn.closest('.aksrt-seo-meta-box, td, .form-field, p, .aksrt-ai-summary-row') || $btn.parentNode; if (!wrap) return; var old = wrap.querySelector('.aksrt-ai-seo-msg'); if (old) old.remove(); @@ -61,36 +61,174 @@ return errors; } + function getEditorTitle() { + var titleInput = document.getElementById('title'); + if (titleInput && titleInput.value) return titleInput.value.trim(); + // Block editor + try { + if (window.wp && wp.data && wp.data.select) { + var ed = wp.data.select('core/editor'); + if (ed && ed.getEditedPostAttribute) { + return String(ed.getEditedPostAttribute('title') || '').trim(); + } + } + } catch (e) { /* ignore */ } + return ''; + } + + function getEditorContent() { + var content = ''; + var ta = document.getElementById('content'); + if (ta && ta.value) content = ta.value; + try { + if (window.wp && wp.data && wp.data.select) { + var ed = wp.data.select('core/editor'); + if (ed && ed.getEditedPostContent) { + var blockContent = ed.getEditedPostContent(); + if (blockContent) content = blockContent; + } + } + } catch (e) { /* ignore */ } + // Strip tags for length check / API + var div = document.createElement('div'); + div.innerHTML = content || ''; + return (div.textContent || div.innerText || content || '').trim(); + } + + function setExcerptValue(summary) { + if (!summary) return false; + + // Classic: #excerpt + var classic = document.getElementById('excerpt'); + if (classic) { + classic.value = summary; + classic.dispatchEvent(new Event('input', { bubbles: true })); + classic.dispatchEvent(new Event('change', { bubbles: true })); + return true; + } + + // Block editor excerpt attribute + try { + if (window.wp && wp.data && wp.data.dispatch) { + var d = wp.data.dispatch('core/editor'); + if (d && d.editPost) { + d.editPost({ excerpt: summary }); + return true; + } + } + } catch (e) { /* ignore */ } + + // Fallback name selector + var named = document.querySelector('textarea[name="excerpt"]'); + if (named) { + named.value = summary; + named.dispatchEvent(new Event('input', { bubbles: true })); + return true; + } + return false; + } + + function setLoading(btn, on, original) { + if (on) { + btn.classList.add('is-loading'); + btn.textContent = I18N.generating || '生成中…'; + } else { + btn.classList.remove('is-loading'); + btn.textContent = original; + } + } + document.addEventListener('click', function (e) { - var btn = e.target.closest('.aksrt-ai-seo-generate'); - if (!btn) return; + // SEO generate + var seoBtn = e.target.closest('.aksrt-ai-seo-generate'); + if (seoBtn) { + e.preventDefault(); + if (seoBtn.classList.contains('is-loading') || seoBtn.disabled) return; + + var type = seoBtn.getAttribute('data-type') || 'post'; + var id = parseInt(seoBtn.getAttribute('data-id') || '0', 10); + if (!id) { + notify(seoBtn, I18N.needSave || '请先保存后再生成 SEO', 'error'); + return; + } + if (CFG.ai && CFG.ai.available === false) { + notify(seoBtn, I18N.notAvailable || 'AI 未配置或未启用', 'error'); + return; + } + + var seoOriginal = seoBtn.textContent; + setLoading(seoBtn, true, seoOriginal); + + fetch(REST + '/ai/seo-generate', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': NONCE, + }, + body: JSON.stringify({ type: type, id: id }), + }) + .then(function (r) { + return r.json().then(function (json) { + return { ok: r.ok, json: json }; + }); + }) + .then(function (res) { + setLoading(seoBtn, false, seoOriginal); + if (!res.ok || !res.json || res.json.success === false) { + var msg = + (res.json && (res.json.message || (res.json.data && res.json.data.message))) || + I18N.failed || + '生成失败'; + notify(seoBtn, msg, 'error'); + return; + } + var data = res.json.data || {}; + if (type === 'term') fillTerm(data); + else fillPost(data); + var errors = collectErrors(data); + if (errors.length) notify(seoBtn, errors.join(';'), 'error'); + else notify(seoBtn, I18N.success || '已填入 SEO 字段,请检查后更新保存', 'ok'); + }) + .catch(function () { + setLoading(seoBtn, false, seoOriginal); + notify(seoBtn, I18N.failed || '生成失败', 'error'); + }); + return; + } + + // Summary → excerpt + var sumBtn = e.target.closest('.aksrt-ai-summary-generate'); + if (!sumBtn) return; e.preventDefault(); + if (sumBtn.classList.contains('is-loading') || sumBtn.disabled) return; - if (btn.classList.contains('is-loading') || btn.disabled) return; - - var type = btn.getAttribute('data-type') || 'post'; - var id = parseInt(btn.getAttribute('data-id') || '0', 10); - if (!id) { - notify(btn, I18N.needSave || '请先保存后再生成 SEO', 'error'); - return; - } if (CFG.ai && CFG.ai.available === false) { - notify(btn, I18N.notAvailable || 'AI 未配置或未启用', 'error'); + notify(sumBtn, I18N.notAvailable || 'AI 未配置或未启用', 'error'); return; } - var original = btn.textContent; - btn.classList.add('is-loading'); - btn.textContent = I18N.generating || '生成中…'; + var content = getEditorContent(); + if (!content) { + notify(sumBtn, I18N.needContent || '请先填写正文内容', 'error'); + return; + } - fetch(REST + '/ai/seo-generate', { + var sumOriginal = sumBtn.textContent; + setLoading(sumBtn, true, sumOriginal); + + fetch(REST + '/ai/summary-suggest', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': NONCE, }, - body: JSON.stringify({ type: type, id: id }), + body: JSON.stringify({ + title: getEditorTitle(), + content: content, + max_length: 200, + }), }) .then(function (r) { return r.json().then(function (json) { @@ -98,33 +236,25 @@ }); }) .then(function (res) { - btn.classList.remove('is-loading'); - btn.textContent = original; - + setLoading(sumBtn, false, sumOriginal); if (!res.ok || !res.json || res.json.success === false) { var msg = (res.json && (res.json.message || (res.json.data && res.json.data.message))) || I18N.failed || '生成失败'; - notify(btn, msg, 'error'); + notify(sumBtn, msg, 'error'); return; } - - var data = res.json.data || {}; - if (type === 'term') fillTerm(data); - else fillPost(data); - - var errors = collectErrors(data); - if (errors.length) { - notify(btn, errors.join(';'), 'error'); - } else { - notify(btn, I18N.success || '已填入 SEO 字段,请检查后更新保存', 'ok'); + var summary = (res.json.data && res.json.data.summary) || ''; + if (!setExcerptValue(summary)) { + notify(sumBtn, I18N.noExcerptField || '未找到摘要字段,请展开「摘要」面板', 'error'); + return; } + notify(sumBtn, I18N.summarySuccess || '已填入摘要字段,请检查后更新保存', 'ok'); }) .catch(function () { - btn.classList.remove('is-loading'); - btn.textContent = original; - notify(btn, I18N.failed || '生成失败', 'error'); + setLoading(sumBtn, false, sumOriginal); + notify(sumBtn, I18N.failed || '生成失败', 'error'); }); }); })(); diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-global.php b/inc/classes/admin/schema/class-aksrt-admin-schema-global.php index 86083a36..ee4967c4 100644 --- a/inc/classes/admin/schema/class-aksrt-admin-schema-global.php +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-global.php @@ -64,17 +64,17 @@ class AKSRT_Admin_Schema_Global { 'option_key' => 'aksrt_settings', 'groups' => [ ['key' => 'basic', 'title' => 'SEO基础', 'fields' => [ - ['key' => 'seo_enabled', 'label' => '启用核心SEO优化', 'type' => 'switch', 'default' => true], - ['key' => 'seo_ai_enabled', 'label' => 'AI生成SEO内容', 'type' => 'switch', 'default' => false], - ['key' => 'seo_connector', 'label' => 'SEO连接符', 'type' => 'input', 'default' => '-'], + ['key' => 'seo_enabled', 'label' => '启用核心SEO优化', 'type' => 'switch', 'default' => true, 'help' => '关闭后主题不再输出 SEO head 标签。'], + ['key' => 'seo_ai_enabled', 'label' => 'AI生成SEO内容', 'type' => 'switch', 'default' => false, 'help' => '开启后文章/分类独立 SEO 框显示 AI 生成按钮;需配置 AI 连接(WP Connectors 或扩展 → AI 连接配置)。', 'dependsOn' => ['key' => 'seo_enabled', 'value' => true]], + ['key' => 'seo_connector', 'label' => 'SEO连接符', 'type' => 'input', 'default' => '-', 'help' => 'AI 生成标题时用于追加站点名。'], ['key' => 'seo_title', 'label' => 'SEO标题(title)', 'type' => 'textarea', 'default' => ''], ['key' => 'seo_keywords', 'label' => 'SEO关键字(keywords)', 'type' => 'textarea', 'default' => ''], ['key' => 'seo_description', 'label' => 'SEO描述(description)', 'type' => 'textarea', 'default' => ''], ]], - ['key' => 'ai-seo', 'title' => 'AI SEO', 'fields' => [ - ['key' => 'ai_seo_title_prompt', 'label' => '标题提示词', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'seo_ai_enabled', 'value' => true]], - ['key' => 'ai_seo_keywords_prompt', 'label' => '关键词提示词', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'seo_ai_enabled', 'value' => true]], - ['key' => 'ai_seo_description_prompt', 'label' => '描述提示词', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'seo_ai_enabled', 'value' => true]], + ['key' => 'ai-seo', 'title' => 'AI SEO 提示词', 'fields' => [ + ['key' => 'ai_seo_title_prompt', 'label' => '标题提示词', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'seo_ai_enabled', 'value' => true], 'help' => '留空使用内置 Zibll 风格默认提示词。'], + ['key' => 'ai_seo_keywords_prompt', 'label' => '关键词提示词', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'seo_ai_enabled', 'value' => true], 'help' => '留空使用内置默认。'], + ['key' => 'ai_seo_description_prompt', 'label' => '描述提示词', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'seo_ai_enabled', 'value' => true], 'help' => '留空使用内置默认。'], ]], ['key' => 'baidu', 'title' => '百度SEO', 'fields' => [ ['key' => 'seo_baidu_push', 'label' => '百度链接自动提交', 'type' => 'switch', 'default' => false], diff --git a/inc/classes/class-aksrt-seo-meta-box.php b/inc/classes/class-aksrt-seo-meta-box.php index 9843cb65..516cb427 100644 --- a/inc/classes/class-aksrt-seo-meta-box.php +++ b/inc/classes/class-aksrt-seo-meta-box.php @@ -57,7 +57,8 @@ class AKSRT_SEO_Meta_Box { $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); + $ai_btn = $this->render_ai_button('post', (int) $post->ID); + $summary_btn = $this->render_summary_button((int) $post->ID); ?>

@@ -73,6 +74,12 @@ class AKSRT_SEO_Meta_Box {

+ +

+ + +

+
[ 'available' => !empty($status['available']), 'seo_ai_enabled' => !empty($seo['seo_ai_enabled']) && !empty($seo['seo_enabled']), + 'write_assist' => !empty($status['available']), ], 'i18n' => [ - 'generating' => __('生成中…', 'aksrt'), - 'generate' => __('AI 生成 SEO', 'aksrt'), - 'success' => __('已填入 SEO 字段,请检查后更新保存', 'aksrt'), - 'failed' => __('生成失败', 'aksrt'), - 'needSave' => __('请先保存后再生成 SEO', 'aksrt'), - 'notAvailable' => __('AI 未配置或未启用', 'aksrt'), + 'generating' => __('生成中…', 'aksrt'), + 'generate' => __('AI 生成 SEO', 'aksrt'), + 'generateSummary' => __('AI 生成摘要', 'aksrt'), + 'success' => __('已填入 SEO 字段,请检查后更新保存', 'aksrt'), + 'summarySuccess' => __('已填入摘要字段,请检查后更新保存', 'aksrt'), + 'failed' => __('生成失败', 'aksrt'), + 'needSave' => __('请先保存后再生成 SEO', 'aksrt'), + 'needContent' => __('请先填写正文内容', 'aksrt'), + 'notAvailable' => __('AI 未配置或未启用', 'aksrt'), + 'noExcerptField' => __('未找到摘要字段,请展开「摘要」面板', '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-generate,.aksrt-ai-summary-generate{cursor:pointer} + .aksrt-ai-seo-generate.is-loading,.aksrt-ai-summary-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} + .aksrt-ai-summary-row{margin-top:8px;padding-top:8px;border-top:1px solid #eee} '); } @@ -230,7 +243,7 @@ class AKSRT_SEO_Meta_Box { $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') + ? esc_attr__('请先配置 AI 连接(扩展 → AI 连接配置 或 WP Connectors)', 'aksrt') : ($id <= 0 ? esc_attr__('请先保存后再生成', 'aksrt') : ''); return sprintf( @@ -242,4 +255,27 @@ class AKSRT_SEO_Meta_Box { esc_html__('AI 生成 SEO', 'aksrt') ); } + + /** + * 后台「AI 生成摘要」→ 写入 WP 摘要字段(#excerpt / 块编辑器)。 + */ + private function render_summary_button(int $post_id): string { + $available = class_exists('AKSRT_AI') && AKSRT_AI::get_instance()->is_available(); + if (!$available) { + return ''; + } + + $disabled = $post_id <= 0 ? ' disabled' : ''; + $title = $post_id <= 0 + ? esc_attr__('请先保存后再生成', 'aksrt') + : esc_attr__('根据正文生成摘要,填入摘要框', 'aksrt'); + + return sprintf( + '', + $disabled, + $post_id, + $title, + esc_html__('AI 生成摘要', 'aksrt') + ); + } }