feat: 市场调研落地——趋势AI人话解读(plainExplain/takeAway,采集自动生成+SSR+小程序卡)+ 首页今日知识卡

This commit is contained in:
2026-09-13 19:18:01 +08:00
parent 7210ca4393
commit dec8ab2749
5 changed files with 151 additions and 1 deletions
+77
View File
@@ -82,6 +82,20 @@
</view> </view>
</view> </view>
<!-- 今日知识卡:每天一条,读了答对就点亮 -->
<view class="kcard" v-if="todayKnowledge" @tap="goTodayKnowledge">
<view class="kcard-head">
<AppIcon name="book" :size="14" />
<text class="kcard-label">今日知识卡</text>
<text class="kcard-dim">{{ todayDimName }}</text>
</view>
<text class="kcard-title">{{ todayKnowledge.title }}</text>
<text class="kcard-desc">{{ todayKnowledge.content }}</text>
<view class="kcard-foot">
<text class="kcard-cta">去读一读 · 答对点亮 →</text>
</view>
</view>
<!-- 今日快讯 --> <!-- 今日快讯 -->
<view class="section-header"> <view class="section-header">
<text class="section-title">今日快讯</text> <text class="section-title">今日快讯</text>
@@ -114,6 +128,18 @@ const dimensions = ref(DIMENSIONS)
const newsList = ref([]) const newsList = ref([])
const byDim = ref({}) // 各星域知识总数 const byDim = ref({}) // 各星域知识总数
const todayKnowledge = ref<any>(null)
const todayDimName = computed(() => {
const d = DIMENSIONS.find(x => x.id === todayKnowledge.value?.dim)
return d ? d.name : ''
})
function goTodayKnowledge() {
if (todayKnowledge.value?._id) {
uni.navigateTo({ url: `/pages/detail/detail?id=${todayKnowledge.value._id}` })
}
}
const userStats = ref(null) // { totalLit, litByDim, streak } const userStats = ref(null) // { totalLit, litByDim, streak }
const lastRead = ref(null) const lastRead = ref(null)
@@ -128,9 +154,21 @@ const todayLit = computed(() => !!uni.getStorageSync('lit_today'))
onShow(() => { onShow(() => {
loadStats() loadStats()
loadNews() loadNews()
loadTodayKnowledge()
lastRead.value = uni.getStorageSync('last_read') || null lastRead.value = uni.getStorageSync('last_read') || null
}) })
// 今日知识卡:按日期取当日第 N 条(同一天所有人看到同一条,形成"每日一词"共识感)
async function loadTodayKnowledge() {
try {
const data = await knowledgeApi.list({ limit: 50, page: 1 })
const items = (data.items || []).filter((x) => x.content)
if (!items.length) return
const dayIndex = Math.floor(Date.now() / 86400000)
todayKnowledge.value = items[dayIndex % items.length]
} catch (e) { /* 静默 */ }
}
onPullDownRefresh(async () => { onPullDownRefresh(async () => {
await Promise.all([loadStats(), loadNews()]) await Promise.all([loadStats(), loadNews()])
uni.stopPullDownRefresh() uni.stopPullDownRefresh()
@@ -468,3 +506,42 @@ onShareTimeline(() => ({
.bottom-spacer { height: 30px; } .bottom-spacer { height: 30px; }
</style> </style>
<style scoped>
.kcard {
background: linear-gradient(135deg, rgba(91, 95, 248, 0.08), rgba(139, 92, 246, 0.05));
border: 1px solid var(--glass-hover-border);
border-radius: var(--radius-lg);
padding: 14px 16px;
margin-bottom: 12px;
}
.kcard-head { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
.kcard-label { font-size: 12px; font-weight: 700; color: var(--color-brand); }
.kcard-dim {
margin-left: auto;
font-size: 10px;
color: var(--text-tertiary);
background: var(--glass-bg);
border-radius: 999px;
padding: 2px 8px;
}
.kcard-title {
display: block;
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.5;
margin-bottom: 6px;
}
.kcard-desc {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.6;
}
.kcard-foot { margin-top: 8px; }
.kcard-cta { font-size: 11px; font-weight: 600; color: var(--color-brand); }
</style>
+38
View File
@@ -41,6 +41,18 @@
{{ item.summary }} {{ item.summary }}
</text> </text>
<!-- 人话解读:一句话说清 + 对你意味着什么 -->
<view class="card-plain" v-if="item.plainExplain">
<view class="plain-line">
<text class="plain-tag">人话</text>
<text class="plain-text">{{ item.plainExplain }}</text>
</view>
<view class="plain-line" v-if="item.takeAway">
<text class="plain-tag alt">对你</text>
<text class="plain-text">{{ item.takeAway }}</text>
</view>
</view>
<!-- 底部信息 --> <!-- 底部信息 -->
<view class="card-footer"> <view class="card-footer">
<text class="card-date">{{ formatDate(item.newsDate) }}</text> <text class="card-date">{{ formatDate(item.newsDate) }}</text>
@@ -403,3 +415,29 @@ onShareAppMessage(() => {
display: block; display: block;
} }
</style> </style>
<style scoped>
.card-plain {
margin-top: 8px;
background: var(--color-brand-bg);
border: 1px solid var(--glass-hover-border);
border-radius: 10px;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 5px;
}
.plain-line { display: flex; align-items: flex-start; gap: 6px; }
.plain-tag {
flex-shrink: 0;
font-size: 10px;
font-weight: 700;
color: #fff;
background: var(--color-brand);
border-radius: 5px;
padding: 1px 6px;
margin-top: 1px;
}
.plain-tag.alt { background: var(--dim3-color); }
.plain-text { font-size: 12px; color: var(--text-primary); line-height: 1.6; }
</style>
+3
View File
@@ -14,6 +14,9 @@ const TrendSchema = new Schema({
}, },
tags: [String], tags: [String],
hot: { type: Boolean, default: false }, hot: { type: Boolean, default: false },
// 人话解读(AI 生成,治"看不懂AI新闻"的焦虑)
plainExplain: { type: String, default: '' }, // 一句话说清这是什么
takeAway: { type: String, default: '' }, // 对普通人意味着什么
picked: { type: Boolean, default: false }, picked: { type: Boolean, default: false },
newsDate: { type: Date, default: Date.now, index: true }, newsDate: { type: Date, default: Date.now, index: true },
viewCount: { type: Number, default: 0 }, viewCount: { type: Number, default: 0 },
+3 -1
View File
@@ -66,7 +66,7 @@ router.get('/sitemap.xml', async (req, res) => {
// ===== SSR 趋势页(SEO/GEO:真实内容直出,搜索引擎可收录) ===== // ===== SSR 趋势页(SEO/GEO:真实内容直出,搜索引擎可收录) =====
const TREND_PAGE_CSS = 'body{font-family:-apple-system,"PingFang SC",sans-serif;color:#1f2333;max-width:720px;margin:0 auto;padding:24px 18px;line-height:1.7}h1{font-size:22px}.meta{color:#9aa0b4;font-size:12px;margin:4px 0 20px}a{color:#5b5ff8;text-decoration:none}.item{padding:14px 0;border-bottom:1px solid #eef0f6}.item h2{font-size:16px;margin:0 0 6px}.item p{margin:0;font-size:13px;color:#6b7080}.item .m{font-size:11px;color:#a6abbb;margin-top:4px}.tag{display:inline-block;background:rgba(91,95,248,.1);color:#5b5ff8;border-radius:99px;padding:1px 8px;font-size:11px;margin-right:6px}.foot{margin-top:28px;color:#b6bac9;font-size:12px}' const TREND_PAGE_CSS = 'body{font-family:-apple-system,"PingFang SC",sans-serif;color:#1f2333;max-width:720px;margin:0 auto;padding:24px 18px;line-height:1.7}h1{font-size:22px}.meta{color:#9aa0b4;font-size:12px;margin:4px 0 20px}a{color:#5b5ff8;text-decoration:none}.item{padding:14px 0;border-bottom:1px solid #eef0f6}.item h2{font-size:16px;margin:0 0 6px}.item p{margin:0;font-size:13px;color:#6b7080}.item .m{font-size:11px;color:#a6abbb;margin-top:4px}.tag{display:inline-block;background:rgba(91,95,248,.1);color:#5b5ff8;border-radius:99px;padding:1px 8px;font-size:11px;margin-right:6px}.foot{margin-top:28px;color:#b6bac9;font-size:12px}.plain{background:#f5f5ff;border-left:3px solid #5b5ff8;padding:8px 12px;margin:8px 0;font-size:14px;color:#3a3f55}.plainbox{background:#f5f5ff;border-left:3px solid #5b5ff8;padding:10px 14px;border-radius:0 8px 8px 0;margin:14px 0}.plainbox p{margin:4px 0;font-size:14px;color:#3a3f55}'
router.get('/trends', async (req, res) => { router.get('/trends', async (req, res) => {
const o = origin(req) const o = origin(req)
@@ -76,6 +76,7 @@ router.get('/trends', async (req, res) => {
<div class="item"> <div class="item">
<h2><a href="${o}/trends/${t._id}">${esc(t.title)}</a></h2> <h2><a href="${o}/trends/${t._id}">${esc(t.title)}</a></h2>
${t.summary ? `<p>${esc(String(t.summary).slice(0, 120))}</p>` : ''} ${t.summary ? `<p>${esc(String(t.summary).slice(0, 120))}</p>` : ''}
${t.plainExplain ? `<p class="plain"><b>人话:</b>${esc(t.plainExplain)}${t.takeAway ? ` <b>对你:</b>${esc(t.takeAway)}` : ''}</p>` : ''}
<div class="m"><span class="tag">${esc(t.category || '资讯')}</span>${esc(t.source || '')} · ${(t.newsDate || t.createdAt || '').toString().slice(0, 10)}</div> <div class="m"><span class="tag">${esc(t.category || '资讯')}</span>${esc(t.source || '')} · ${(t.newsDate || t.createdAt || '').toString().slice(0, 10)}</div>
</div>`).join('') </div>`).join('')
res.type('html').send(`<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"> res.type('html').send(`<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
@@ -104,6 +105,7 @@ router.get('/trends/:id', async (req, res) => {
<h1>${esc(t.title)}</h1> <h1>${esc(t.title)}</h1>
<div class="meta">${esc(t.source || '')} · ${(t.newsDate || t.createdAt || '').toString().slice(0, 10)}</div> <div class="meta">${esc(t.source || '')} · ${(t.newsDate || t.createdAt || '').toString().slice(0, 10)}</div>
${t.summary ? `<p>${esc(t.summary)}</p>` : ''} ${t.summary ? `<p>${esc(t.summary)}</p>` : ''}
${t.plainExplain ? `<div class="plainbox"><p><b>💡 一句话说清:</b>${esc(t.plainExplain)}</p>${t.takeAway ? `<p><b>🎯 对你意味着:</b>${esc(t.takeAway)}</p>` : ''}</div>` : ''}
${t.body ? `<div>${esc(t.body).split('\n').map((p) => `<p>${p}</p>`).join('')}</div>` : ''} ${t.body ? `<div>${esc(t.body).split('\n').map((p) => `<p>${p}</p>`).join('')}</div>` : ''}
${t.sourceUrl ? `<p><a href="${esc(t.sourceUrl)}" rel="noopener nofollow" target="_blank">阅读原文 →</a></p>` : ''} ${t.sourceUrl ? `<p><a href="${esc(t.sourceUrl)}" rel="noopener nofollow" target="_blank">阅读原文 →</a></p>` : ''}
<div class="foot"><a href="/trends">← 更多 AI 趋势</a> · © 宇之然</div> <div class="foot"><a href="/trends">← 更多 AI 趋势</a> · © 宇之然</div>
+30
View File
@@ -189,6 +189,33 @@ async function callAI(messages, maxTokens = 1024) {
return ''; return '';
} }
/** 人话解读:每条趋势生成"一句话说清 + 对普通人意味着什么"(治 AI 焦虑的核心价值层) */
async function explainItems(items) {
const targets = items.filter(i => i.title && !i.plainExplain).slice(0, 14);
if (targets.length === 0) return items;
console.log(` → 生成人话解读 ${targets.length} 条...`);
for (const item of targets) {
const prompt = `你是"维度精灵",帮普通上班族看懂 AI 新闻。基于下面这条资讯,输出 JSON(只输出 JSON):
{"plainExplain":"用一句大白话说清这条资讯讲的是什么,30字内,不用术语","takeAway":"这对普通人意味着什么/该注意什么,一句行动向的话,30字内"}
资讯标题:${item.title}
${item.summary ? '摘要:' + String(item.summary).slice(0, 200) : ''}`;
const out = await callAI([{ role: 'user', content: prompt }], 300);
const m = out.match(/\{[\s\S]*?\}/);
if (m) {
try {
const j = JSON.parse(m[0]);
if (j.plainExplain) item.plainExplain = String(j.plainExplain).slice(0, 60);
if (j.takeAway) item.takeAway = String(j.takeAway).slice(0, 60);
} catch (e) { /* 解析失败留空,前端回退只显示标题 */ }
}
await new Promise(r => setTimeout(r, 300));
}
const ok = items.filter(i => i.plainExplain).length;
console.log(` ✓ 人话解读完成 ${ok}/${targets.length}`);
return items;
}
/** 翻译英文内容到中文 */ /** 翻译英文内容到中文 */
async function translateItems(items) { async function translateItems(items) {
const needTranslate = items.filter(i => !hasChinese(i.title)); const needTranslate = items.filter(i => !hasChinese(i.title));
@@ -342,6 +369,9 @@ async function main() {
console.log(` ✓ 中文保底补充: ${need} 条`); console.log(` ✓ 中文保底补充: ${need} 条`);
} }
// AI 人话解读(失败不阻断采集,字段留空前端自动回退)
try { await explainItems(items); } catch (e) { console.log(' ⚠ 人话解读失败:', e.message); }
// 全量刷新:清空旧数据(避免残留英文 / 测试数据) // 全量刷新:清空旧数据(避免残留英文 / 测试数据)
await Trend.deleteMany({}); await Trend.deleteMany({});