feat: v2.5 社媒分享功能 + 面包屑优化

新增功能:
- 社媒分享:一键生成多平台分享文案
  - 支持8个平台:通用、小红书、抖音、B站、微信公众号、朋友圈、X (Twitter)、LinkedIn
  - 智能生成适配各平台特点的文案(字数限制、风格、内容丰富度)
  - 集成DeepSeek AI自动生成 + 规则模板回退
  - "分享"按钮支持直接跳转到平台发布后台
  - X (Twitter)支持预填充文案
  - 自动复制文案到剪贴板

优化改进:
- 面包屑导航:修复折行问题,确保始终单行显示
  - 添加 white-space: nowrap 和 flex-shrink: 0
  - 长标题自动省略显示
- 社媒分享按钮文案:从"一键生成社媒分享"改为"分享"
- 更新Twitter为X,使用x.com域名

技术实现:
- 前端:detail_new.html 新增分享弹窗和交互逻辑
- 后端:app.py 新增 /api/generate-social-share 接口
- 支持按平台定制文案内容和格式

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jowe
2026-01-10 18:04:42 +08:00
parent 8011e5bd4a
commit e71230ca96
2 changed files with 612 additions and 2 deletions

198
app.py
View File

@@ -239,6 +239,204 @@ def create_app(config_name='default'):
return render_template('detail_new.html', site=site, news_list=news_list, recommended_sites=recommended_sites) return render_template('detail_new.html', site=site, news_list=news_list, recommended_sites=recommended_sites)
# ========== 社媒营销路由 (v2.5新增) ==========
@app.route('/api/generate-social-share', methods=['POST'])
def generate_social_share():
"""一键生成社媒分享文案(前台可访问)"""
try:
data = request.get_json() or {}
site_code = (data.get('site_code') or '').strip()
platforms = data.get('platforms') or []
if not site_code:
return jsonify({'success': False, 'message': '请提供网站编码'}), 400
site = Site.query.filter_by(code=site_code, is_active=True).first()
if not site:
return jsonify({'success': False, 'message': '网站不存在或未启用'}), 404
# 允许的平台列表(前端也会限制,这里再做一次兜底)
allowed_platforms = {
'universal',
'xiaohongshu',
'douyin',
'bilibili',
'wechat',
'moments',
'x',
'linkedin'
}
if not isinstance(platforms, list):
platforms = []
platforms = [p for p in platforms if isinstance(p, str)]
platforms = [p.strip().lower() for p in platforms if p.strip()]
platforms = [p for p in platforms if p in allowed_platforms]
# 默认生成通用模板
if not platforms:
platforms = ['universal']
# 组织站点信息
site_info = {
'name': site.name,
'url': site.url,
'short_desc': site.short_desc or '',
'description': (site.description or '')[:1200],
'features': (site.features or '')[:1200],
'tags': [t.name for t in (site.tags or [])]
}
# 尝试使用PromptTemplate + DeepSeek生成
from utils.tag_generator import TagGenerator
generator = TagGenerator()
prompt = PromptTemplate.query.filter_by(key='social_share', is_active=True).first()
generated = {}
if prompt and generator.client:
# 构造提示词变量
tags_text = ','.join(site_info['tags'])
user_prompt = prompt.user_prompt_template.format(
name=site_info['name'],
url=site_info['url'],
short_desc=site_info['short_desc'],
description=site_info['description'],
features=site_info['features'],
tags=tags_text,
platforms=','.join(platforms)
)
try:
resp = generator.client.chat.completions.create(
model='deepseek-chat',
messages=[
{'role': 'system', 'content': prompt.system_prompt},
{'role': 'user', 'content': user_prompt}
],
temperature=0.7,
max_tokens=1200
)
text = (resp.choices[0].message.content or '').strip()
# 约定模型返回JSON字符串若不是JSON则回退到纯文本放到universal
import json as _json
try:
parsed = _json.loads(text)
if isinstance(parsed, dict):
for k, v in parsed.items():
if isinstance(k, str) and isinstance(v, str) and k in allowed_platforms:
generated[k] = v.strip()
except Exception:
generated['universal'] = text
except Exception as e:
# AI失败不影响整体回退模板
print(f"社媒文案AI生成失败{str(e)}")
# 回退:规则模板(根据各平台字数限制优化)
def build_fallback(p):
name = site_info['name']
url = site_info['url']
short_desc = site_info['short_desc'] or ''
full_desc = site_info['description'] or ''
features = site_info['features'] or ''
tags = site_info['tags'][:6]
tags_line = ' ' + ' '.join([f"#{t}" for t in tags]) if tags else ''
# X (Twitter) - 280字符限制简洁为主
if p == 'x':
desc = (short_desc or full_desc)[:100]
base = f"🔥 {name}\n\n{desc}\n\n🔗 {url}{tags_line}"
return base[:280]
# LinkedIn - 3000字限制专业风格
if p == 'linkedin':
desc = (full_desc or short_desc)[:500]
content = f"💡 推荐一个实用工具:{name}\n\n📝 简介:\n{desc}\n\n"
if features:
features_list = features.split('\n')[:3]
content += f"✨ 主要功能:\n" + '\n'.join([f"{f.strip()}" for f in features_list if f.strip()][:3]) + "\n\n"
content += f"🔗 官网:{url}\n\n"
if tags:
content += f"📌 适用场景:{', '.join(tags)}"
return content[:3000]
# 小红书 - 2000字限制图文风格
if p == 'xiaohongshu':
desc = (short_desc or full_desc)[:300]
content = f"{name} | 我最近在用的宝藏工具\n\n"
content += f"📌 一句话介绍:\n{desc}\n\n"
if features:
features_list = features.split('\n')[:5]
content += f"🎯 核心功能:\n" + '\n'.join([f"{f.strip()}" for f in features_list if f.strip()][:5]) + "\n\n"
content += f"🔗 体验入口:{url}\n\n{tags_line}"
return content[:2000]
# 抖音 - 2000字限制短视频文案风格
if p == 'douyin':
desc = (short_desc or full_desc)[:200]
content = f"🔥 发现一个超好用的工具:{name}\n\n"
content += f"💡 {desc}\n\n"
if features:
features_list = features.split('\n')[:3]
content += "✨ 亮点功能:\n" + '\n'.join([f"👉 {f.strip()}" for f in features_list if f.strip()][:3]) + "\n\n"
content += f"🔗 想试试戳:{url}\n\n{tags_line}"
return content[:2000]
# B站 - 20000字限制专栏风格
if p == 'bilibili':
desc = (full_desc or short_desc)[:800]
content = f"【分享】{name} - 值得一试的优质工具\n\n"
content += f"## 📖 工具简介\n{desc}\n\n"
if features:
content += f"## ✨ 主要功能\n{features}\n\n"
content += f"## 🔗 体验地址\n{url}\n\n"
if tags:
content += f"## 🏷️ 相关标签\n{' / '.join(tags)}"
return content[:20000]
# 微信公众号 - 基本无限制,正式风格
if p == 'wechat':
desc = (full_desc or short_desc)[:600]
content = f"今天给大家分享一个实用工具:{name}\n\n"
content += f"📝 工具介绍\n{desc}\n\n"
if features:
content += f"✨ 核心功能\n{features}\n\n"
content += f"🔗 官方网站\n{url}\n\n"
if tags:
content += f"如果你也在关注「{' · '.join(tags)}」相关的工具,不妨收藏一下这个实用的选择。"
return content
# 朋友圈 - 简短为主
if p == 'moments':
desc = (short_desc or full_desc)[:80]
return f"💡 {name}\n{desc}\n🔗 {url}{tags_line}".strip()
# 通用模板
desc = (full_desc or short_desc)[:300]
return f"{name}\n\n{desc}\n\n{url}{tags_line}".strip()
results = {}
for p in platforms:
results[p] = generated.get(p) or build_fallback(p)
# 统一补充一个通用版本
if 'universal' not in results:
results['universal'] = generated.get('universal') or build_fallback('universal')
return jsonify({
'success': True,
'site': {
'code': site.code,
'name': site.name,
'url': site.url
},
'platforms': platforms,
'content': results
})
except Exception as e:
return jsonify({'success': False, 'message': f'生成失败: {str(e)}'}), 500
# ========== 后台登录路由 ========== # ========== 后台登录路由 ==========
@app.route('/admin/login', methods=['GET', 'POST']) @app.route('/admin/login', methods=['GET', 'POST'])
def admin_login(): def admin_login():

View File

@@ -80,19 +80,25 @@
padding: 12px 0; padding: 12px 0;
font-size: 14px; font-size: 14px;
color: var(--text-secondary); color: var(--text-secondary);
flex-wrap: wrap; flex-wrap: nowrap;
white-space: nowrap;
overflow-x: auto;
overflow-y: hidden;
} }
.breadcrumb-item { .breadcrumb-item {
display: flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex-shrink: 0;
white-space: nowrap;
} }
.breadcrumb-item a { .breadcrumb-item a {
color: var(--text-secondary); color: var(--text-secondary);
text-decoration: none; text-decoration: none;
transition: color 0.2s; transition: color 0.2s;
white-space: nowrap;
} }
.breadcrumb-item a:hover { .breadcrumb-item a:hover {
@@ -102,11 +108,16 @@
.breadcrumb-item.active { .breadcrumb-item.active {
color: var(--text-primary); color: var(--text-primary);
font-weight: 500; font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
} }
.breadcrumb-separator { .breadcrumb-separator {
color: var(--text-muted); color: var(--text-muted);
user-select: none; user-select: none;
flex-shrink: 0;
} }
/* 返回链接 */ /* 返回链接 */
@@ -715,8 +726,41 @@
访问网站 访问网站
<span></span> <span></span>
</a> </a>
<!-- v2.5新增:社媒分享入口 -->
<button type="button" class="share-btn" onclick="openShareModal()">
分享
<span></span>
</button>
<p class="visit-hint">在新标签页打开 • {{ site.url.split('/')[2] if site.url else '' }}</p> <p class="visit-hint">在新标签页打开 • {{ site.url.split('/')[2] if site.url else '' }}</p>
</div> </div>
<!-- v2.5新增:社媒分享弹窗 -->
<div id="shareModal" class="share-modal" style="display:none;">
<div class="share-modal-backdrop" onclick="closeShareModal()"></div>
<div class="share-modal-card" role="dialog" aria-modal="true" aria-label="社媒分享文案">
<div class="share-modal-header">
<div>
<div class="share-modal-title">社媒分享文案</div>
<div class="share-modal-subtitle">{{ site.name }} · 一键生成/一键复制</div>
</div>
<button type="button" class="share-modal-close" onclick="closeShareModal()">×</button>
</div>
<div class="share-platform-tabs" id="shareTabs"></div>
<div class="share-content">
<textarea id="shareText" class="share-textarea" rows="10" readonly placeholder="点击生成后显示内容..."></textarea>
<div class="share-actions">
<button type="button" id="shareGenerateBtn" class="share-action-primary" onclick="generateShare()">生成文案</button>
<button type="button" class="share-action-share" onclick="shareToplatform()">分享</button>
<button type="button" class="share-action-secondary" onclick="copyShare()">复制</button>
</div>
<div id="shareStatus" class="share-status" style="display:none;"></div>
</div>
</div>
</div>
</div> </div>
<!-- 内容布局 --> <!-- 内容布局 -->
@@ -952,4 +996,372 @@ function refreshNews(siteCode) {
} }
</script> </script>
<style>
.share-btn {
margin-top: 12px;
width: 100%;
padding: 12px;
border-radius: var(--radius-md);
border: 1px solid rgba(14, 165, 233, 0.35);
background: rgba(14, 165, 233, 0.08);
color: var(--primary-dark);
font-weight: 700;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s;
}
.share-btn:hover {
background: rgba(14, 165, 233, 0.12);
transform: translateY(-1px);
}
.share-modal {
position: fixed;
inset: 0;
z-index: 2000;
}
.share-modal-backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.55);
}
.share-modal-card {
position: relative;
width: min(720px, calc(100vw - 32px));
margin: 72px auto 0;
background: var(--bg-white);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.share-modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 18px 20px;
border-bottom: 1px solid var(--border-color);
background: linear-gradient(135deg, rgba(14, 165, 233, 0.08) 0%, rgba(139, 92, 246, 0.08) 100%);
}
.share-modal-title {
font-size: 16px;
font-weight: 800;
color: var(--text-primary);
}
.share-modal-subtitle {
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
}
.share-modal-close {
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-white);
cursor: pointer;
font-size: 18px;
line-height: 1;
color: var(--text-secondary);
}
.share-platform-tabs {
display: flex;
gap: 8px;
padding: 14px 20px 0;
flex-wrap: wrap;
}
.share-tab {
padding: 8px 12px;
border-radius: 999px;
border: 1px solid var(--border-color);
background: #f8fafc;
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.share-tab.active {
background: rgba(14, 165, 233, 0.12);
border-color: rgba(14, 165, 233, 0.35);
color: var(--primary-dark);
font-weight: 700;
}
.share-content {
padding: 14px 20px 20px;
}
.share-textarea {
width: 100%;
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 12px;
font-size: 14px;
line-height: 1.6;
resize: vertical;
min-height: 180px;
background: var(--bg-page);
}
.share-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 12px;
}
.share-action-primary,
.share-action-share,
.share-action-secondary {
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--border-color);
cursor: pointer;
font-weight: 700;
transition: all 0.2s;
}
.share-action-primary {
background: var(--primary-blue);
border-color: rgba(14, 165, 233, 0.6);
color: white;
}
.share-action-primary:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.share-action-share {
background: linear-gradient(135deg, #8b5cf6 0%, #ec4899 100%);
border-color: rgba(139, 92, 246, 0.6);
color: white;
}
.share-action-share:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
}
.share-action-secondary {
background: var(--bg-white);
color: var(--text-primary);
}
.share-status {
margin-top: 10px;
font-size: 13px;
color: var(--text-secondary);
}
.share-status.success { color: #16a34a; }
.share-status.error { color: #dc2626; }
@media (max-width: 520px) {
.share-modal-card { margin-top: 56px; }
.share-actions { flex-direction: column; }
.share-action-primary,
.share-action-share,
.share-action-secondary { width: 100%; }
}
</style>
<script>
const SHARE_PLATFORMS = [
{ key: 'universal', label: '通用', publishUrl: null },
{ key: 'xiaohongshu', label: '小红书', publishUrl: 'https://creator.xiaohongshu.com/publish/publish' },
{ key: 'douyin', label: '抖音', publishUrl: 'https://creator.douyin.com/creator-micro/content/publish' },
{ key: 'bilibili', label: 'B站', publishUrl: 'https://member.bilibili.com/platform/upload/text/edit' },
{ key: 'wechat', label: '公众号', publishUrl: 'https://mp.weixin.qq.com' },
{ key: 'moments', label: '朋友圈', publishUrl: null },
{ key: 'x', label: 'X (Twitter)', publishUrl: 'https://x.com/intent/tweet' },
{ key: 'linkedin', label: 'LinkedIn', publishUrl: 'https://www.linkedin.com/feed/' },
];
let shareActivePlatform = 'universal';
let shareContentCache = {};
function openShareModal() {
const modal = document.getElementById('shareModal');
if (!modal) return;
modal.style.display = 'block';
renderShareTabs();
setSharePlatform(shareActivePlatform);
document.addEventListener('keydown', onShareEsc);
}
function closeShareModal() {
const modal = document.getElementById('shareModal');
if (!modal) return;
modal.style.display = 'none';
document.removeEventListener('keydown', onShareEsc);
}
function onShareEsc(e) {
if (e.key === 'Escape') closeShareModal();
}
function renderShareTabs() {
const tabs = document.getElementById('shareTabs');
if (!tabs) return;
tabs.innerHTML = '';
SHARE_PLATFORMS.forEach(p => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'share-tab' + (p.key === shareActivePlatform ? ' active' : '');
btn.textContent = p.label;
btn.onclick = () => setSharePlatform(p.key);
tabs.appendChild(btn);
});
}
function setSharePlatform(platformKey) {
shareActivePlatform = platformKey;
renderShareTabs();
const textarea = document.getElementById('shareText');
if (!textarea) return;
textarea.value = shareContentCache[platformKey] || '';
const status = document.getElementById('shareStatus');
if (status) status.style.display = 'none';
}
function setShareStatus(type, message) {
const el = document.getElementById('shareStatus');
if (!el) return;
el.className = 'share-status' + (type ? ' ' + type : '');
el.textContent = message;
el.style.display = 'block';
}
function generateShare() {
const btn = document.getElementById('shareGenerateBtn');
if (btn) {
btn.disabled = true;
btn.textContent = '生成中...';
}
setShareStatus('', '正在生成文案,请稍候...');
const payload = {
site_code: '{{ site.code }}',
platforms: SHARE_PLATFORMS.map(p => p.key)
};
fetch('/api/generate-social-share', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(r => r.json())
.then(data => {
if (!data || !data.success) {
throw new Error((data && data.message) ? data.message : '生成失败');
}
shareContentCache = data.content || {};
setSharePlatform(shareActivePlatform);
setShareStatus('success', '已生成,可直接复制使用。');
})
.catch(err => {
setShareStatus('error', err.message || '网络请求失败');
})
.finally(() => {
if (btn) {
btn.disabled = false;
btn.textContent = '生成文案';
}
});
}
function copyShare() {
const textarea = document.getElementById('shareText');
if (!textarea) return;
const text = textarea.value || '';
if (!text.trim()) {
setShareStatus('error', '没有可复制的内容,请先生成。');
return;
}
const doFallback = () => {
textarea.focus();
textarea.select();
try {
document.execCommand('copy');
setShareStatus('success', '已复制到剪贴板');
} catch (e) {
setShareStatus('error', '复制失败,请手动选择复制');
}
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text)
.then(() => setShareStatus('success', '已复制到剪贴板'))
.catch(() => doFallback());
} else {
doFallback();
}
}
function shareToplatform() {
const textarea = document.getElementById('shareText');
if (!textarea) return;
const text = textarea.value || '';
if (!text.trim()) {
setShareStatus('error', '请先生成文案后再分享');
return;
}
// 查找当前平台配置
const platform = SHARE_PLATFORMS.find(p => p.key === shareActivePlatform);
if (!platform) {
setShareStatus('error', '未知平台');
return;
}
// 如果平台没有发布URL提示用户
if (!platform.publishUrl) {
setShareStatus('error', `${platform.label}暂不支持直接跳转,请手动复制后前往平台发布`);
return;
}
// 根据平台处理跳转
let shareUrl = platform.publishUrl;
// X (Twitter) 支持预填充文本
if (platform.key === 'x') {
const encodedText = encodeURIComponent(text);
shareUrl = `${platform.publishUrl}?text=${encodedText}`;
}
// 打开发布页面
window.open(shareUrl, '_blank');
setShareStatus('success', `正在跳转到${platform.label}发布页面...`);
// 自动复制文案到剪贴板
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(() => {});
}
}
</script>
{% endblock %} {% endblock %}