mirror of
https://github.com/joeseesun/wechat-radar.git
synced 2026-09-08 03:08:29 +09:00
Publish updated WeChat Radar
This commit is contained in:
@@ -10,3 +10,9 @@ WECHAT_RADAR_DEMO=0
|
||||
|
||||
# Optional model name used by Codex CLI based topic/link summarization.
|
||||
WECHAT_RADAR_CODEX_MODEL=
|
||||
|
||||
# Optional. Tune Codex CLI aggregation.
|
||||
WECHAT_RADAR_TOPIC_CHUNK_SIZE=250
|
||||
WECHAT_RADAR_CODEX_TIMEOUT_MS=300000
|
||||
WECHAT_RADAR_LINK_CODEX_TIMEOUT_MS=180000
|
||||
WECHAT_RADAR_AUTO_TOPIC_DAYS=31
|
||||
|
||||
@@ -1,120 +1,186 @@
|
||||
# 微信雷达(WeChat Radar)
|
||||
# WeChat Radar
|
||||
|
||||
本地优先的微信群聊情报看板。它从本机 `wx-cli` 或 demo 数据中提取趋势、关键链接、行动机会、人物雷达和内容选题,帮助你从大量微信群消息里发现真正有用的信号。
|
||||
> 群太多,真正有价值的消息却总是被淹没。
|
||||
> WeChat Radar turns noisy WeChat groups into a local-first intelligence dashboard.
|
||||
|
||||
## 特性
|
||||
[](https://github.com/joeseesun/wechat-radar/stargazers)
|
||||
[](https://github.com/joeseesun/wechat-radar/network/members)
|
||||
[](https://github.com/joeseesun/wechat-radar/issues)
|
||||
[](https://github.com/joeseesun/wechat-radar/commits/main)
|
||||
[](LICENSE)
|
||||
|
||||
- 首页情报工作台:今日值得出手、趋势升温、异常信号、链接精选、人物雷达、内容选题。
|
||||
- 链接情报:聚合最近一天出现的文章、工具和资源,按重复度和跨群扩散排序。
|
||||
- 话题雷达:按日期构建跨群话题,并查看相关原始消息。
|
||||
- 群列表与群详情:查看群活跃度、每日趋势、Top 发言人和完整消息。
|
||||
- 本地 SQLite:默认数据目录 `~/.wechat-radar`。
|
||||
- 首次启动向导:配置你的微信名、检查 `wx-cli`、确认隐私、可一键使用示例数据。
|
||||

|
||||
|
||||
## 运行要求
|
||||
**[中文](#中文) | [English](#english)**
|
||||
|
||||
- macOS
|
||||
- Node.js 20+
|
||||
- pnpm
|
||||
- 可选:[`wx-cli`](https://github.com/jackwener/wx-cli),用于读取本机微信数据
|
||||
---
|
||||
|
||||
没有 `wx-cli` 也可以使用 demo 模式预览界面。
|
||||
<a name="中文"></a>
|
||||
|
||||
## 中文
|
||||
|
||||
WeChat Radar 是一个本地优先的微信群聊情报看板。它把群消息、话题、链接、@我的消息和高信号人物聚合成一个可按日期查看的工作台。
|
||||
|
||||
你得到的不是“聊天记录列表”,而是每天可以直接处理的情报:
|
||||
|
||||
- 今日优先看:消息、文章、工具、异动分区展示
|
||||
- 话题雷达:用 Codex CLI 按天聚合跨群话题
|
||||
- 链接情报:文章/工具资源去重,生成可读标题
|
||||
- 群日报:每天活跃群可生成摘要报告,方便复制给 AI 继续处理
|
||||
- 本地存储:聊天数据落到你自己的 SQLite,不上传到第三方服务
|
||||
- 明暗主题:默认奶白色浅色主题,也支持深色模式
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
git clone https://github.com/joeseesun/wechat-radar.git
|
||||
cd wechat-radar
|
||||
pnpm install
|
||||
pnpm rebuild better-sqlite3
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
访问 [http://localhost:3000](http://localhost:3000)。首次访问会进入 `/setup`。
|
||||
打开 [http://localhost:3000](http://localhost:3000)。首次进入会跳到 `/setup`,按页面提示填写你的微信名、确认隐私说明,也可以先启用 demo 数据体验。
|
||||
|
||||
## Demo 模式
|
||||
## 前置条件
|
||||
|
||||
如果你还没有配置 `wx-cli`,可以在 `/setup` 勾选“使用示例数据体验”。也可以命令行生成示例数据:
|
||||
- [ ] macOS,且已登录微信 4.x
|
||||
- [ ] Node.js 20+:`node --version`
|
||||
- [ ] pnpm:`corepack enable && pnpm --version`
|
||||
- [ ] wx-cli:`wx --version`
|
||||
- [ ] wx daemon 正在运行:`wx daemon status`
|
||||
- [ ] 如果要让话题聚合更好,安装并登录 Codex CLI:`codex --version`
|
||||
|
||||
```bash
|
||||
pnpm demo:seed
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
示例数据会写入 `~/.wechat-radar/radar.db`。
|
||||
wx-cli 可参考原项目安装与初始化:[jackwener/wx-cli](https://github.com/jackwener/wx-cli)。
|
||||
|
||||
## 配置
|
||||
|
||||
可以通过 `.env.local` 配置:
|
||||
默认数据目录是 `~/.wechat-radar/`,不会写进项目目录。
|
||||
|
||||
```env
|
||||
你可以用环境变量覆盖:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
常用配置:
|
||||
|
||||
```bash
|
||||
WECHAT_RADAR_DATA_DIR=~/.wechat-radar
|
||||
WECHAT_RADAR_MY_NAMES=你的微信名,你的群昵称
|
||||
WECHAT_RADAR_MY_NAMES=张三,San Zhang,zhangsan
|
||||
WECHAT_RADAR_DEMO=0
|
||||
WECHAT_RADAR_CODEX_MODEL=
|
||||
```
|
||||
|
||||
也可以在首次启动向导中配置,配置会保存在:
|
||||
也可以直接在 `/setup` 页面配置。配置会写入 `~/.wechat-radar/config.json`。
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 进入首页,选择日期或时间范围。
|
||||
2. 点击“重扫”同步当前范围消息。
|
||||
3. 点击“全量同步”拉取更长历史。
|
||||
4. 打开“话题雷达”查看跨群主题。
|
||||
5. 打开“链接情报”查看文章和工具资源。
|
||||
6. 在活跃群列表点击“日报”查看单群日报。
|
||||
|
||||
你可以这样和 AI 配合:
|
||||
|
||||
- “把今天所有 Codex 相关话题整理成一篇博客大纲。”
|
||||
- “复制这个群日报,帮我提炼值得回复的机会。”
|
||||
- “把链接情报里的工具做成一张试用优先级表。”
|
||||
|
||||
## 数据与隐私
|
||||
|
||||
WeChat Radar 默认只在本机读写数据:
|
||||
|
||||
- `~/.wechat-radar/radar.db`:SQLite 主数据库
|
||||
- `~/.wechat-radar/config.json`:本地配置
|
||||
- `~/.wechat-radar/backups/`:可选备份
|
||||
|
||||
安全设计:
|
||||
|
||||
- wx-cli 调用使用 `child_process.execFile` 参数数组,不拼 shell
|
||||
- SQLite 使用 prepared statements
|
||||
- 页面只以 React 文本节点渲染聊天内容
|
||||
- 不把微信密钥、会话、数据库、模型缓存提交进仓库
|
||||
|
||||
重要提醒:这个项目会读取你本机微信数据。请确认你的使用方式符合微信客户端规则、当地法律、群成员隐私预期和你所在组织的合规要求。不要把包含真实聊天内容的数据库或截图上传到公开仓库。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
~/.wechat-radar/config.json
|
||||
app/ Next.js App Router 页面与 API
|
||||
components/ 看板、侧边栏、图表、消息渲染组件
|
||||
lib/ wx-cli 封装、SQLite、话题/链接聚合逻辑
|
||||
scripts/ 本地维护脚本
|
||||
docs/assets/ README 图片与公开素材
|
||||
```
|
||||
|
||||
关键配置:
|
||||
## 常见问题
|
||||
|
||||
| 配置 | 说明 |
|
||||
| 问题 | 解决方法 |
|
||||
| --- | --- |
|
||||
| `WECHAT_RADAR_DATA_DIR` | 本地数据目录,默认 `~/.wechat-radar` |
|
||||
| `WECHAT_RADAR_MY_NAMES` | 用于识别 @我的多个昵称,逗号分隔 |
|
||||
| `WECHAT_RADAR_DEMO` | 设置为 `1` 时启用 demo 模式 |
|
||||
| `WECHAT_RADAR_CODEX_MODEL` | 可选,Codex CLI 话题/链接整理使用 |
|
||||
| `wx daemon 未运行` | 先运行 `wx daemon start`,再刷新页面。 |
|
||||
| `better-sqlite3` native 模块报错 | 运行 `pnpm rebuild better-sqlite3`。 |
|
||||
| 首页没有数据 | 先完成 `/setup`,确认 `wx sessions --json` 有输出,然后点击“重扫”。 |
|
||||
| 话题雷达为空 | 打开对应日期会自动构建;也可以点击“构建话题”。需要本机可运行 `codex`。 |
|
||||
| 不想读取真实微信 | 在 `/setup` 勾选 demo 模式,或设置 `WECHAT_RADAR_DEMO=1`。 |
|
||||
|
||||
## wx-cli 接入
|
||||
## 致谢
|
||||
|
||||
确认 `wx` 命令可用:
|
||||
- [jackwener/wx-cli](https://github.com/jackwener/wx-cli):本项目依赖它读取本机微信数据。
|
||||
- [Next.js](https://nextjs.org/)、[ECharts](https://echarts.apache.org/)、[better-sqlite3](https://github.com/WiseLibs/better-sqlite3)。
|
||||
|
||||
---
|
||||
|
||||
<a name="english"></a>
|
||||
|
||||
## English
|
||||
|
||||
WeChat Radar is a local-first intelligence dashboard for WeChat groups. It turns noisy group chats into daily briefings, cross-group topics, link intelligence, mentions, and per-group reports.
|
||||
|
||||
### Features
|
||||
|
||||
- Daily dashboard for messages, links, tools, anomalies, and people
|
||||
- Codex CLI powered topic clustering by date
|
||||
- Link intelligence with generated titles and deduplication
|
||||
- Per-group daily reports with copy-friendly output
|
||||
- Local SQLite storage by default
|
||||
- Light and dark themes
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
wx --version
|
||||
wx daemon status
|
||||
wx sessions -n 10 --json
|
||||
git clone https://github.com/joeseesun/wechat-radar.git
|
||||
cd wechat-radar
|
||||
pnpm install
|
||||
pnpm rebuild better-sqlite3
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
如果 daemon 没运行,请按你的 `wx-cli` 文档启动或修复。
|
||||
Open [http://localhost:3000](http://localhost:3000). The first run redirects to `/setup`, where you can configure your WeChat display names and privacy confirmation, or enable demo mode.
|
||||
|
||||
## 数据存储
|
||||
### Requirements
|
||||
|
||||
默认数据目录:
|
||||
- [ ] macOS with WeChat 4.x logged in
|
||||
- [ ] Node.js 20+
|
||||
- [ ] pnpm
|
||||
- [ ] wx-cli initialized and running
|
||||
- [ ] Optional: Codex CLI for better topic/link summaries
|
||||
|
||||
```text
|
||||
~/.wechat-radar/
|
||||
├── radar.db
|
||||
└── config.json
|
||||
```
|
||||
### Privacy
|
||||
|
||||
数据库包含:
|
||||
By default, runtime data is stored locally under `~/.wechat-radar/`. The app does not upload your chat database. You are responsible for using it in a way that respects WeChat rules, local laws, group privacy expectations, and organizational compliance.
|
||||
|
||||
- `messages`:同步后的本地消息。
|
||||
- `daily_stats`:每日聚合统计。
|
||||
- `mentions`:@我的索引。
|
||||
- `groups` / `group_tags`:本地分组。
|
||||
- `topics` / `topic_messages`:话题雷达结果。
|
||||
- `link_intelligence_cache`:链接情报缓存。
|
||||
### Troubleshooting
|
||||
|
||||
不要把 `radar.db`、`.env.local` 或日志提交到 Git。
|
||||
| Problem | Fix |
|
||||
| --- | --- |
|
||||
| wx daemon is not running | Run `wx daemon start`. |
|
||||
| better-sqlite3 fails to load | Run `pnpm rebuild better-sqlite3`. |
|
||||
| No dashboard data | Finish `/setup`, confirm `wx sessions --json` works, then click rescan. |
|
||||
| Topic radar is empty | Open the date or click build topics; make sure `codex` is available. |
|
||||
|
||||
## 常用命令
|
||||
## License
|
||||
|
||||
```bash
|
||||
pnpm dev # 本地开发
|
||||
pnpm build # 生产构建
|
||||
pnpm lint # 代码检查
|
||||
pnpm demo:seed # 写入示例数据
|
||||
```
|
||||
|
||||
## 隐私说明
|
||||
|
||||
微信雷达默认只读本机数据,并把处理结果写入本地 SQLite。项目本身不提供云端服务,也不会自动上传聊天记录。
|
||||
|
||||
你需要自行确认读取、保存、处理聊天数据符合当地法律、平台规则和群成员预期。更多见 [PRIVACY.md](./PRIVACY.md)。
|
||||
|
||||
## 开源协议
|
||||
|
||||
MIT,见 [LICENSE](./LICENSE)。
|
||||
MIT
|
||||
|
||||
@@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { wxSessions } from '@/lib/wx';
|
||||
import { listGroups, listAllTags, tagGroup } from '@/lib/groups';
|
||||
import { classifyGroupHeuristic } from '@/lib/group-classifier';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -16,6 +15,166 @@ interface Suggestion {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
function classifyHeuristic(name: string, summary: string, groups: ReturnType<typeof listGroups>) {
|
||||
const lookup = (target: string) => groups.find((g) => g.name.includes(target));
|
||||
|
||||
// 顺序很重要 — 高优先规则先匹配
|
||||
// 1. 蝗虫团(最强信号)
|
||||
if (/蝗虫团|huangchong/i.test(name)) {
|
||||
const t = lookup('蝗虫');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '蝗虫团系列' };
|
||||
}
|
||||
|
||||
// 2. 自营/读者群
|
||||
if (
|
||||
/自营|用户群|粉丝群|公众号读者|读者群/.test(name) ||
|
||||
/自营|用户群|粉丝群|公众号读者|读者群/.test(summary)
|
||||
) {
|
||||
const t = lookup('自营/读者群');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '自营 / 读者群' };
|
||||
}
|
||||
|
||||
// 3. WaytoAGI
|
||||
if (/waytoagi|通往agi|通往ai|通往 ai/i.test(name)) {
|
||||
const t = lookup('WaytoAGI');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: 'WaytoAGI 系列' };
|
||||
}
|
||||
|
||||
// 4. HowOneAI
|
||||
if (/howoneai|howone/i.test(name)) {
|
||||
const t = lookup('HowOneAI');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: 'HowOneAI 系列' };
|
||||
}
|
||||
|
||||
// 5. Vibe Coding / 编程
|
||||
if (
|
||||
/vibe.?coding|vibecoding|vibe first|cherry studio|cli|claude.?skills|clawdbot|codepilot|mcp|cola|geoflow|refly|camel|eigent|thinkinai|skills|all in cli/i.test(
|
||||
name,
|
||||
)
|
||||
) {
|
||||
const t = lookup('Vibe Coding');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '编程 / Skills / CLI' };
|
||||
}
|
||||
|
||||
// 6. AI 学术 / 论文 / 未来硅世界
|
||||
if (
|
||||
/学术|论文|paper|未来硅世界|研究室|nixy|simonlin|博文视点|《|knowledge|灵枭/i.test(
|
||||
name,
|
||||
)
|
||||
) {
|
||||
const t = lookup('AI 学术');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '学术 / 论文' };
|
||||
}
|
||||
|
||||
// 7. AI 商业 / 营销
|
||||
if (
|
||||
/seo|geo|商业化|营销|kol|gaidn|adg|vip|生财|appsail|tutti|商业|broker|出版|2026 共读群|阅读\d|收付款|社交新品|社群/i.test(
|
||||
name,
|
||||
)
|
||||
) {
|
||||
const t = lookup('AI 商业');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '商业 / 营销 / KOL' };
|
||||
}
|
||||
|
||||
// 8. AIGC / 内容创作(视频、音乐、图、媒体、AIGC)
|
||||
if (
|
||||
/aigc|图|视频|音乐|spy|拍我ai|ai媒体|ai音视频|创意|graceful|创作|graphic|listenhub|notetomp|youmind|完全ai生成|羊毛|社区|molthuman|aiwriter|短视频|歌|video|music|illustrat|ai春晚|nettalk|dolphin/i.test(
|
||||
name,
|
||||
)
|
||||
) {
|
||||
const t = lookup('AIGC');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: 'AIGC / 内容创作' };
|
||||
}
|
||||
|
||||
// 9. 付费社区
|
||||
if (
|
||||
/vip|烟花|修饼|传术师|生财有术|沃垠|兔子ai|ai领导力|hicool|早鸟|内测|种子用户|订阅用户|车友|豪车|私董|学员|拍我ai|hosi|hosi.ai|api 渠道|大白|一人公司|小鱼名人|pec/i.test(
|
||||
name,
|
||||
)
|
||||
) {
|
||||
const t = lookup('付费社区');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '付费 / 内测 / VIP' };
|
||||
}
|
||||
|
||||
// 10. AI 工具用户群(产品周边)
|
||||
if (
|
||||
/用户群|用户中文|内测群|jackywine|mindcode|remio|listenhub|notetomp|cherry|camel|refly|cola|geoflow|hosi|aigocode|appsail|tutti|爱贝壳|内容同步|api/i.test(
|
||||
name,
|
||||
)
|
||||
) {
|
||||
const t = lookup('AI 工具用户群');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '工具用户群' };
|
||||
}
|
||||
|
||||
// 11. AI 圈社交(散群、神的孩子、明人明言、agi bar、先行者、智能体成精了)
|
||||
if (
|
||||
/神的孩子|明人明言|先行者|agi bar|智能体成精|life hacker|超级玩家|love.*death.*agent|未来趋势|agent橘|新物种|创造营|不息为体|未知书社|新互联网/i.test(
|
||||
name,
|
||||
)
|
||||
) {
|
||||
const t = lookup('AI 圈社交');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: 'AI 圈社交' };
|
||||
}
|
||||
|
||||
// 12. 大佬 / 媒体圈(自媒体、大佬、对接群、媒体)
|
||||
if (
|
||||
/donews|何夕|辛亥|对接群|百度世界|央馆|火山方舟|43talks|tgo|商务|《ai营销/i.test(name)
|
||||
) {
|
||||
const t = lookup('大佬');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '大佬 / 媒体圈' };
|
||||
}
|
||||
|
||||
// 13. 行业活动(一次性活动群)
|
||||
if (
|
||||
/活动现场|聚餐|筹备组|聚会|开播|直播|线下|大会|分享会|一年五班|落户/i.test(name)
|
||||
) {
|
||||
const t = lookup('行业活动');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '一次性活动群' };
|
||||
}
|
||||
|
||||
// 14. 生活 / 兴趣(钓友、邻居、果粉、班级)
|
||||
if (
|
||||
/钓友|路亚|果粉|大家庭|班级|班·班|喜相逢|邻里|苑|🏘|楼|班|小区|羽毛球|健身|跑步|徒步|阅读|共读|英语|班级群|6年级|六年级|恩小|对接|车友|校友|曲|歌友|羽毛|篮球/i.test(
|
||||
name,
|
||||
)
|
||||
) {
|
||||
const t = lookup('生活');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '生活 / 兴趣' };
|
||||
}
|
||||
|
||||
// 15. 粉丝团 / 读者群 → AI 圈社交(除非已经被自营/读者群匹配)
|
||||
if (/粉丝|fans|读者/i.test(name)) {
|
||||
const t = lookup('AI 圈社交');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '粉丝团 / 读者群' };
|
||||
}
|
||||
|
||||
// 16. 财经 / 投资类
|
||||
if (/财经|股票|投资|基金|币圈|crypto|trade/i.test(name)) {
|
||||
const t = lookup('AI 商业');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '财经 / 投资' };
|
||||
}
|
||||
|
||||
// 17. X / 推特相关
|
||||
if (/x boost|twitter|推特|x kol/i.test(name)) {
|
||||
const t = lookup('AIGC');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: 'X / 推特运营' };
|
||||
}
|
||||
|
||||
// 18. 课程 / 训练营
|
||||
if (/课群|训练营|实训|训练|内训|课程|早鸟|2026共创|日历/i.test(name)) {
|
||||
const t = lookup('付费社区');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '课程 / 训练营' };
|
||||
}
|
||||
|
||||
// 兜底:含 AI / Agent / Coding 关键词 → AI 圈社交
|
||||
if (/ai|agent|gpt|claude|llm|coding|开源/i.test(name)) {
|
||||
const t = lookup('AI 圈社交');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '通用 AI(兜底)' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const ApplySchema = z.object({
|
||||
picks: z.array(
|
||||
z.object({
|
||||
@@ -41,7 +200,7 @@ export async function GET() {
|
||||
.filter((g) => !tagged.has(g.username))
|
||||
.slice(0, 200)
|
||||
.map((g) => {
|
||||
const guess = classifyGroupHeuristic(g.chat, g.summary, groups);
|
||||
const guess = classifyHeuristic(g.chat, g.summary, groups);
|
||||
return {
|
||||
chatroom_id: g.username,
|
||||
name: g.chat,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
const rows = db()
|
||||
.prepare(
|
||||
`SELECT date, COUNT(*) AS count
|
||||
FROM messages
|
||||
GROUP BY date
|
||||
ORDER BY date DESC
|
||||
LIMIT 90`,
|
||||
)
|
||||
.all() as Array<{ date: string; count: number }>;
|
||||
|
||||
return NextResponse.json({ ok: true, dates: rows });
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { homedir } from 'node:os';
|
||||
import { DATA_DIR } from '@/lib/config';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
const dataDir = process.env.WECHAT_RADAR_DATA_DIR ?? join(homedir(), '.wechat-radar');
|
||||
const dataDir = DATA_DIR;
|
||||
const dbPath = join(dataDir, 'radar.db');
|
||||
const dbSize = existsSync(dbPath) ? statSync(dbPath).size : 0;
|
||||
const counts = {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { backfillMessageLinks } from '@/lib/message-links';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300;
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = (await req.json().catch(() => ({}))) as {
|
||||
since?: string;
|
||||
until?: string;
|
||||
};
|
||||
|
||||
if (body.since && !DATE_RE.test(body.since)) {
|
||||
return NextResponse.json({ ok: false, error: 'invalid since' }, { status: 400 });
|
||||
}
|
||||
if (body.until && !DATE_RE.test(body.until)) {
|
||||
return NextResponse.json({ ok: false, error: 'invalid until' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = backfillMessageLinks(body.since, body.until);
|
||||
return NextResponse.json({ ok: true, ...result });
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { wxSessions } from '@/lib/wx';
|
||||
import { todayStr } from '@/lib/range';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type RawLinkRow = {
|
||||
chatroom_id: string;
|
||||
local_id: number;
|
||||
sender: string;
|
||||
time: string;
|
||||
url: string;
|
||||
canonical_url: string;
|
||||
title: string | null;
|
||||
domain: string;
|
||||
source: string;
|
||||
raw_kind: string;
|
||||
};
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const date = new URL(req.url).searchParams.get('date') ?? todayStr();
|
||||
const names = await groupNames();
|
||||
const rows = db()
|
||||
.prepare(
|
||||
`SELECT chatroom_id, local_id, sender, time, url, canonical_url, title, domain, source, raw_kind
|
||||
FROM message_links
|
||||
WHERE date = ?
|
||||
AND canonical_url LIKE '%://mp.weixin.qq.com/%'
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 200`,
|
||||
)
|
||||
.all(date) as RawLinkRow[];
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
date,
|
||||
links: rows.map((row) => ({
|
||||
...row,
|
||||
chat_name: names.get(row.chatroom_id) ?? row.chatroom_id,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
async function groupNames() {
|
||||
const names = new Map<string, string>();
|
||||
try {
|
||||
const sessions = await wxSessions(500);
|
||||
for (const s of sessions) names.set(s.username, s.chat);
|
||||
} catch {}
|
||||
return names;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { upsertResolvedLinkForMessage } from '@/lib/message-links';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = (await req.json().catch(() => ({}))) as {
|
||||
chatroom_id?: string;
|
||||
local_id?: number;
|
||||
url?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
source?: 'public_search' | 'manual';
|
||||
confidence?: number;
|
||||
};
|
||||
|
||||
if (!body.chatroom_id || !Number.isInteger(body.local_id) || !body.url) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: 'chatroom_id, local_id and url are required' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const localId = body.local_id;
|
||||
if (localId === undefined) {
|
||||
return NextResponse.json({ ok: false, error: 'local_id is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const source = body.source ?? 'manual';
|
||||
if (source !== 'manual' && source !== 'public_search') {
|
||||
return NextResponse.json({ ok: false, error: 'invalid source' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = upsertResolvedLinkForMessage({
|
||||
chatroom_id: body.chatroom_id,
|
||||
local_id: localId,
|
||||
url: body.url,
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
source,
|
||||
confidence: body.confidence,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(result, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { DATA_DIR } from '@/lib/config';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST() {
|
||||
const dataDir = process.env.WECHAT_RADAR_DATA_DIR ?? join(homedir(), '.wechat-radar');
|
||||
const dataDir = DATA_DIR;
|
||||
const dest = join(dataDir, 'radar-recovered.db');
|
||||
try {
|
||||
db().pragma('wal_checkpoint(TRUNCATE)');
|
||||
|
||||
@@ -4,6 +4,7 @@ import { syncFullHistory } from '@/lib/stats-aggregator';
|
||||
import { normalizeDate, normalizeRangeKey, rangeToWindow, type RangeKey } from '@/lib/range';
|
||||
import { readConfig } from '@/lib/config';
|
||||
import { cache, CK } from '@/lib/cache';
|
||||
import { buildTopicsForDate } from '@/lib/topics';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 1800; // 30 min
|
||||
@@ -64,6 +65,14 @@ export async function POST(req: NextRequest) {
|
||||
concurrency,
|
||||
onProgress: (p) => send(p),
|
||||
});
|
||||
|
||||
const topicDates = datesBetween(since, until).slice(-autoTopicDays(body.full));
|
||||
send({ type: 'topics_start', dates: topicDates.length });
|
||||
for (const date of topicDates) {
|
||||
send({ type: 'topics_date', date, message: '开始构建话题…' });
|
||||
await buildTopicsForDate(date, (p) => send({ ...p, type: `topics_${p.type}`, date }));
|
||||
}
|
||||
|
||||
cache.del(CK.sessions());
|
||||
send({
|
||||
type: 'finished',
|
||||
@@ -87,3 +96,27 @@ export async function POST(req: NextRequest) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function datesBetween(since: string, until: string): string[] {
|
||||
const out: string[] = [];
|
||||
const start = parseLocalDate(since);
|
||||
const end = parseLocalDate(until);
|
||||
for (const d = start; d.getTime() <= end.getTime(); d.setDate(d.getDate() + 1)) {
|
||||
out.push(formatLocalDate(d));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseLocalDate(date: string): Date {
|
||||
const [y, m, d] = date.split('-').map(Number);
|
||||
return new Date(y, (m || 1) - 1, d || 1);
|
||||
}
|
||||
|
||||
function formatLocalDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function autoTopicDays(full?: boolean): number {
|
||||
const configured = Number(process.env.WECHAT_RADAR_AUTO_TOPIC_DAYS ?? (full ? 14 : 31));
|
||||
return Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : 31;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { wxSessions } from '@/lib/wx';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type SearchResult = {
|
||||
id: string;
|
||||
type: 'group' | 'topic' | 'person' | 'message' | 'link';
|
||||
title: string;
|
||||
subtitle: string;
|
||||
href: string;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
type MessageRow = {
|
||||
chatroom_id: string;
|
||||
sender: string;
|
||||
content: string;
|
||||
date: string;
|
||||
time: string;
|
||||
};
|
||||
|
||||
type TopicRow = {
|
||||
id: number;
|
||||
date: string;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
message_count: number;
|
||||
group_count: number;
|
||||
};
|
||||
|
||||
type LinkRow = {
|
||||
canonical_url: string;
|
||||
title: string | null;
|
||||
domain: string;
|
||||
date: string;
|
||||
};
|
||||
|
||||
type PersonRow = {
|
||||
sender: string;
|
||||
hits: number;
|
||||
groups: number;
|
||||
latest: string;
|
||||
};
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const q = (new URL(req.url).searchParams.get('q') ?? '').trim();
|
||||
if (q.length < 2) return NextResponse.json({ ok: true, results: [] });
|
||||
|
||||
const like = `%${q}%`;
|
||||
const nameMap = await loadGroupNames();
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
for (const [chatroomId, name] of nameMap) {
|
||||
if (!name.toLowerCase().includes(q.toLowerCase())) continue;
|
||||
results.push({
|
||||
id: `group:${chatroomId}`,
|
||||
type: 'group',
|
||||
title: name,
|
||||
subtitle: chatroomId,
|
||||
href: `/groups/${encodeURIComponent(chatroomId)}`,
|
||||
});
|
||||
if (results.length >= 8) break;
|
||||
}
|
||||
|
||||
const topics = db()
|
||||
.prepare(
|
||||
`SELECT id, date, title, summary, message_count, group_count
|
||||
FROM topics
|
||||
WHERE title LIKE ? OR COALESCE(summary, '') LIKE ?
|
||||
ORDER BY date DESC, message_count DESC
|
||||
LIMIT 8`,
|
||||
)
|
||||
.all(like, like) as TopicRow[];
|
||||
for (const t of topics) {
|
||||
results.push({
|
||||
id: `topic:${t.id}`,
|
||||
type: 'topic',
|
||||
title: t.title,
|
||||
subtitle: `${t.date} · ${t.message_count} 条 · ${t.group_count} 群${t.summary ? ` · ${t.summary}` : ''}`,
|
||||
href: `/topics?date=${t.date}`,
|
||||
});
|
||||
}
|
||||
|
||||
const people = db()
|
||||
.prepare(
|
||||
`SELECT sender, COUNT(*) AS hits, COUNT(DISTINCT chatroom_id) AS groups, MAX(date) AS latest
|
||||
FROM messages
|
||||
WHERE sender LIKE ?
|
||||
GROUP BY sender
|
||||
ORDER BY hits DESC
|
||||
LIMIT 8`,
|
||||
)
|
||||
.all(like) as PersonRow[];
|
||||
for (const p of people) {
|
||||
results.push({
|
||||
id: `person:${p.sender}`,
|
||||
type: 'person',
|
||||
title: p.sender,
|
||||
subtitle: `${p.hits} 条消息 · ${p.groups} 个群 · 最近 ${p.latest}`,
|
||||
href: `/signals?q=${encodeURIComponent(p.sender)}`,
|
||||
});
|
||||
}
|
||||
|
||||
const messages = db()
|
||||
.prepare(
|
||||
`SELECT chatroom_id, sender, content, date, time
|
||||
FROM messages
|
||||
WHERE content LIKE ? OR sender LIKE ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 10`,
|
||||
)
|
||||
.all(like, like) as MessageRow[];
|
||||
for (const m of messages) {
|
||||
results.push({
|
||||
id: `message:${m.chatroom_id}:${m.time}:${m.sender}`,
|
||||
type: 'message',
|
||||
title: compact(m.content || m.sender, 80),
|
||||
subtitle: `${nameMap.get(m.chatroom_id) ?? m.chatroom_id} · ${m.sender} · ${m.time}`,
|
||||
href: `/groups/${encodeURIComponent(m.chatroom_id)}?date=${m.date}`,
|
||||
});
|
||||
}
|
||||
|
||||
const links = db()
|
||||
.prepare(
|
||||
`SELECT canonical_url, title, domain, MAX(date) AS date
|
||||
FROM message_links
|
||||
WHERE canonical_url LIKE ? OR COALESCE(title, '') LIKE ? OR domain LIKE ?
|
||||
GROUP BY canonical_url
|
||||
ORDER BY MAX(timestamp) DESC
|
||||
LIMIT 8`,
|
||||
)
|
||||
.all(like, like, like) as LinkRow[];
|
||||
for (const l of links) {
|
||||
results.push({
|
||||
id: `link:${l.canonical_url}`,
|
||||
type: 'link',
|
||||
title: l.title || l.canonical_url,
|
||||
subtitle: `${l.domain} · ${l.date}`,
|
||||
href: l.canonical_url,
|
||||
external: true,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, results: results.slice(0, 32) });
|
||||
}
|
||||
|
||||
async function loadGroupNames(): Promise<Map<string, string>> {
|
||||
const names = new Map<string, string>();
|
||||
try {
|
||||
const sessions = await wxSessions(500);
|
||||
for (const s of sessions) {
|
||||
if (s.is_group) names.set(s.username, s.chat);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const local = db()
|
||||
.prepare(
|
||||
`SELECT chatroom_id, COUNT(*) AS n
|
||||
FROM messages
|
||||
GROUP BY chatroom_id
|
||||
ORDER BY n DESC
|
||||
LIMIT 500`,
|
||||
)
|
||||
.all() as Array<{ chatroom_id: string }>;
|
||||
for (const row of local) {
|
||||
if (!names.has(row.chatroom_id)) names.set(row.chatroom_id, row.chatroom_id);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function compact(s: string, max: number): string {
|
||||
const text = s.replace(/\s+/g, ' ').trim();
|
||||
if (text.length <= max) return text;
|
||||
return `${text.slice(0, max - 1)}…`;
|
||||
}
|
||||
@@ -70,7 +70,6 @@ export async function GET() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function loadSessionsSafe(limit: number): Promise<WxSession[]> {
|
||||
if (readConfig().demoMode) return listLocalSessionsFallback(limit);
|
||||
const cached = cache.get(CK.sessions()) as WxSession[] | undefined;
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function GET(req: NextRequest) {
|
||||
return new Response('image not found in wx cache', { status: 404 });
|
||||
}
|
||||
|
||||
const buf = await readFile(/*turbopackIgnore: true*/ found.path);
|
||||
const buf = await readFile(found.path);
|
||||
return new Response(new Uint8Array(buf), {
|
||||
headers: {
|
||||
'Content-Type': mimeFor(found.format),
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function ClassifyPage() {
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
|
||||
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[var(--chrome-bg)] px-6 py-3 backdrop-blur">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/" className="text-[var(--text-3)] hover:text-[var(--text)]">
|
||||
<ArrowLeft size={16} />
|
||||
|
||||
+100
-23
@@ -1,16 +1,41 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--bg: #0a0d0b;
|
||||
--bg-2: #10110e;
|
||||
--surface: #141713;
|
||||
--surface-2: #1a1e19;
|
||||
--surface-3: #24281f;
|
||||
--border: #33382f;
|
||||
--border-soft: rgba(184, 176, 145, 0.16);
|
||||
--text: #edf1e8;
|
||||
--text-2: #b0b3a8;
|
||||
--text-3: #7d8177;
|
||||
--bg: #f1efea;
|
||||
--bg-2: #e8e5de;
|
||||
--surface: #faf9f6;
|
||||
--surface-2: #f1eee8;
|
||||
--surface-3: #e5dfd6;
|
||||
--border: #d8d2c8;
|
||||
--border-soft: rgba(46, 42, 34, 0.12);
|
||||
--text: #171814;
|
||||
--text-2: #5e6459;
|
||||
--text-3: #8b8b80;
|
||||
--accent: #28745b;
|
||||
--accent-2: #3a9d71;
|
||||
--accent-soft: rgba(40, 116, 91, 0.12);
|
||||
--warn: #9c6728;
|
||||
--warn-soft: rgba(156, 103, 40, 0.12);
|
||||
--danger: #b34242;
|
||||
--danger-soft: rgba(179, 66, 66, 0.12);
|
||||
--shadow: 0 18px 55px rgba(55, 47, 34, 0.09);
|
||||
--chrome-bg: rgba(250, 249, 246, 0.86);
|
||||
--sidebar-bg: rgba(248, 246, 239, 0.92);
|
||||
--control-bg: rgba(250, 249, 246, 0.78);
|
||||
--input-scheme: light;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--bg: #080b0a;
|
||||
--bg-2: #0d1110;
|
||||
--surface: #111511;
|
||||
--surface-2: #171c17;
|
||||
--surface-3: #20261f;
|
||||
--border: #293029;
|
||||
--border-soft: rgba(175, 190, 167, 0.13);
|
||||
--text: #eef2eb;
|
||||
--text-2: #b6bdb1;
|
||||
--text-3: #80887d;
|
||||
--accent: #7dd3a8;
|
||||
--accent-2: #46b978;
|
||||
--accent-soft: rgba(125, 211, 168, 0.13);
|
||||
@@ -18,7 +43,11 @@
|
||||
--warn-soft: rgba(213, 162, 83, 0.14);
|
||||
--danger: #df6b6b;
|
||||
--danger-soft: rgba(223, 107, 107, 0.14);
|
||||
--shadow: 0 18px 50px rgba(0, 0, 0, 0.28);
|
||||
--shadow: 0 18px 48px rgba(0, 0, 0, 0.34);
|
||||
--chrome-bg: rgba(8, 11, 10, 0.84);
|
||||
--sidebar-bg: rgba(8, 11, 10, 0.9);
|
||||
--control-bg: rgba(17, 22, 18, 0.86);
|
||||
--input-scheme: dark;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -53,11 +82,38 @@ body {
|
||||
|
||||
body {
|
||||
background:
|
||||
linear-gradient(120deg, rgba(125, 211, 168, 0.055), transparent 34%),
|
||||
linear-gradient(180deg, rgba(213, 162, 83, 0.075), transparent 30%),
|
||||
linear-gradient(120deg, rgba(40, 116, 91, 0.08), transparent 34%),
|
||||
linear-gradient(180deg, rgba(156, 103, 40, 0.08), transparent 30%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
color-scheme: var(--input-scheme);
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
:where(a, button, input, select, textarea):focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 58%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
[data-theme='dark'] body {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
@@ -66,22 +122,27 @@ body {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #26362d;
|
||||
background: color-mix(in srgb, var(--text-3) 42%, transparent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #355044;
|
||||
background: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
|
||||
.card {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(237, 241, 232, 0.035), transparent 48%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.35), transparent 48%),
|
||||
var(--surface);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .card {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -89,8 +150,8 @@ body {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: rgba(16, 24, 18, 0.86);
|
||||
color: var(--text-2);
|
||||
background: color-mix(in srgb, var(--control-bg) 86%, var(--surface));
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s, color 0.15s, opacity 0.15s;
|
||||
@@ -116,19 +177,26 @@ body {
|
||||
}
|
||||
.btn-primary {
|
||||
background: linear-gradient(180deg, var(--accent), var(--accent-2));
|
||||
color: #07120c;
|
||||
color: #fdfbf7;
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(180deg, #98e2bb, var(--accent-2));
|
||||
color: #07120c;
|
||||
background: linear-gradient(180deg, var(--accent-2), var(--accent));
|
||||
color: #fdfbf7;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .btn-primary,
|
||||
[data-theme='dark'] .btn-primary:hover {
|
||||
background: var(--accent);
|
||||
color: #06100b;
|
||||
}
|
||||
|
||||
.control-surface {
|
||||
border: 1px solid var(--border-soft);
|
||||
background: rgba(16, 24, 18, 0.82);
|
||||
box-shadow: 0 1px 0 rgba(237, 241, 232, 0.03) inset;
|
||||
background: color-mix(in srgb, var(--control-bg) 88%, var(--surface));
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.28) inset;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.report-kicker {
|
||||
@@ -144,3 +212,12 @@ body {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.theme-date-input {
|
||||
color-scheme: var(--input-scheme);
|
||||
}
|
||||
|
||||
.report-title {
|
||||
font-family: "Noto Serif SC", "Songti SC", Georgia, serif;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
+44
-27
@@ -6,7 +6,18 @@ import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import MessageContent from '@/components/MessageContent';
|
||||
import { ArrowLeft, BarChart3, Calendar, History, ListFilter, MessageSquare, Star, Trophy } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
BarChart3,
|
||||
Calendar,
|
||||
Check,
|
||||
Copy,
|
||||
History,
|
||||
ListFilter,
|
||||
MessageSquare,
|
||||
Star,
|
||||
Trophy,
|
||||
} from 'lucide-react';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
|
||||
const ReactECharts = dynamicImport(() => import('echarts-for-react'), { ssr: false });
|
||||
@@ -60,6 +71,7 @@ export default function GroupDetailPage({
|
||||
const [fav, setFav] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const load = async (d: string) => {
|
||||
setLoading(true);
|
||||
@@ -112,6 +124,24 @@ export default function GroupDetailPage({
|
||||
});
|
||||
};
|
||||
|
||||
const copyMessages = async () => {
|
||||
const messages = data?.recent ?? [];
|
||||
if (messages.length === 0) return;
|
||||
const title = data?.stats?.chat ?? chatroomId;
|
||||
const text = [
|
||||
`# ${title} ${date}`,
|
||||
'',
|
||||
...messages.map((m) => {
|
||||
const time = m.time.slice(11, 16);
|
||||
const content = m.content.replace(/\s+/g, ' ').trim();
|
||||
return `[${time}] ${m.sender}: ${content}`;
|
||||
}),
|
||||
].join('\n');
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
const hourOption: EChartsOption | null = data?.stats
|
||||
? {
|
||||
grid: { top: 20, right: 16, bottom: 28, left: 36 },
|
||||
@@ -177,20 +207,11 @@ export default function GroupDetailPage({
|
||||
}
|
||||
: null;
|
||||
|
||||
const dateOptions = useMemo(() => {
|
||||
if (!data?.daily_history) return [];
|
||||
return data.daily_history
|
||||
.filter((d) => d.total > 0 || d.date === date)
|
||||
.map((d) => d.date)
|
||||
.sort()
|
||||
.reverse();
|
||||
}, [data, date]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
|
||||
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[var(--chrome-bg)] px-6 py-3 backdrop-blur">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Link href="/" className="shrink-0 text-[var(--text-3)] hover:text-[var(--text)]">
|
||||
<ArrowLeft size={16} />
|
||||
@@ -208,27 +229,12 @@ export default function GroupDetailPage({
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="control-surface flex items-center gap-1.5 rounded-md px-2.5 py-1.5">
|
||||
<Calendar size={13} className="text-[var(--text-3)]" />
|
||||
{dateOptions.length > 0 ? (
|
||||
<select
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="bg-transparent text-[12px] outline-none"
|
||||
>
|
||||
{!dateOptions.includes(date) && <option value={date}>{date}(未扫描)</option>}
|
||||
{dateOptions.map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="bg-transparent text-[12px] outline-none [color-scheme:dark]"
|
||||
className="theme-date-input bg-transparent text-[12px] outline-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button className={`btn ${fav ? 'btn-warn' : ''}`} onClick={toggleFav}>
|
||||
<Star size={13} />
|
||||
@@ -326,10 +332,21 @@ export default function GroupDetailPage({
|
||||
<MessageSquare size={14} className="text-[var(--accent)]" />
|
||||
{date} 完整消息
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="btn py-1 text-[12px]"
|
||||
onClick={copyMessages}
|
||||
disabled={loading || (data?.recent.length ?? 0) === 0}
|
||||
title="复制当日完整消息"
|
||||
>
|
||||
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||
<span>{copied ? '已复制' : '复制'}</span>
|
||||
</button>
|
||||
<div className="text-[11px] text-[var(--text-3)]">
|
||||
{loading ? '加载中…' : `共 ${data?.recent.length ?? 0} 条`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!loading && data?.recent && data.recent.length === 0 ? (
|
||||
<div className="py-12 text-center text-[12px] text-[var(--text-3)]">
|
||||
当日无消息
|
||||
|
||||
+2
-2
@@ -99,7 +99,7 @@ function GroupsListContent() {
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
|
||||
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[var(--chrome-bg)] px-6 py-3 backdrop-blur">
|
||||
<div>
|
||||
<div className="report-kicker">Group Directory</div>
|
||||
<div className="text-[15px] font-semibold">{title}</div>
|
||||
@@ -178,7 +178,7 @@ function GroupsListFallback() {
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
|
||||
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[var(--chrome-bg)] px-6 py-3 backdrop-blur">
|
||||
<div>
|
||||
<div className="report-kicker">Group Directory</div>
|
||||
<div className="text-[15px] font-semibold">所有群</div>
|
||||
|
||||
+6
-4
@@ -2,8 +2,8 @@ import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "微信雷达",
|
||||
description: "本地优先的微信群聊情报看板",
|
||||
title: "WeChat Radar",
|
||||
description: "Local-first WeChat group intelligence dashboard",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -12,8 +12,10 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN" className="h-full">
|
||||
<body className="min-h-full">{children}</body>
|
||||
<html lang="zh-CN" className="h-full" suppressHydrationWarning>
|
||||
<body className="min-h-full">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+98
-4
@@ -22,6 +22,7 @@ type LinkInsight = {
|
||||
time: string;
|
||||
local_id: number;
|
||||
snippet: string;
|
||||
source: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -32,6 +33,20 @@ type LinkInsightResp = {
|
||||
tools: LinkInsight[];
|
||||
};
|
||||
|
||||
type RawWechatLink = {
|
||||
chatroom_id: string;
|
||||
chat_name: string;
|
||||
local_id: number;
|
||||
sender: string;
|
||||
time: string;
|
||||
url: string;
|
||||
canonical_url: string;
|
||||
title: string | null;
|
||||
domain: string;
|
||||
source: string;
|
||||
raw_kind: string;
|
||||
};
|
||||
|
||||
function localToday(): string {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
@@ -43,6 +58,7 @@ function localToday(): string {
|
||||
export default function LinksPage() {
|
||||
const [date, setDate] = useState(() => localToday());
|
||||
const [links, setLinks] = useState<LinkInsightResp | null>(null);
|
||||
const [rawLinks, setRawLinks] = useState<RawWechatLink[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -53,6 +69,11 @@ export default function LinksPage() {
|
||||
const j = (await r.json()) as LinkInsightResp;
|
||||
if (!cancelled && j.ok) setLinks(j);
|
||||
} catch {}
|
||||
try {
|
||||
const r = await fetch(`/api/message-links/raw?date=${date}`, { cache: 'no-store' });
|
||||
const j = (await r.json()) as { ok: boolean; links?: RawWechatLink[] };
|
||||
if (!cancelled && j.ok) setRawLinks(j.links ?? []);
|
||||
} catch {}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -76,7 +97,7 @@ export default function LinksPage() {
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
|
||||
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[var(--chrome-bg)] px-6 py-3 backdrop-blur">
|
||||
<div>
|
||||
<div className="report-kicker">Link Intelligence</div>
|
||||
<div className="flex items-center gap-2 text-[15px] font-semibold">
|
||||
@@ -96,7 +117,7 @@ export default function LinksPage() {
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="bg-transparent text-[12px] outline-none [color-scheme:dark]"
|
||||
className="theme-date-input bg-transparent text-[12px] outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@@ -112,7 +133,7 @@ export default function LinksPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid flex-1 grid-cols-1 gap-5 overflow-hidden p-5 xl:grid-cols-2">
|
||||
<div className="grid flex-1 grid-cols-1 gap-5 overflow-hidden p-5 xl:grid-cols-3">
|
||||
<LinkInsightPanel
|
||||
title="文章链接"
|
||||
icon={<Newspaper size={14} className="text-[var(--accent)]" />}
|
||||
@@ -129,12 +150,68 @@ export default function LinksPage() {
|
||||
loading={loading}
|
||||
empty="当天还没有工具链接"
|
||||
/>
|
||||
<RawWechatPanel date={date} items={loading ? [] : rawLinks} loading={loading} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RawWechatPanel({
|
||||
date,
|
||||
items,
|
||||
loading,
|
||||
}: {
|
||||
date: string;
|
||||
items: RawWechatLink[];
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section className="card flex min-h-0 min-w-0 flex-col">
|
||||
<div className="flex items-center justify-between border-b border-[var(--border-soft)] px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-[12px] font-semibold">
|
||||
<ExternalLink size={14} className="text-[var(--accent)]" />
|
||||
<span>微信文章链接</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--text-3)]">{loading ? '加载中' : `${items.length} 条`}</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{loading ? (
|
||||
<div className="py-16 text-center text-[11px] text-[var(--text-3)]">加载中…</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="py-16 text-center text-[11px] text-[var(--text-3)]">当天没有解析到微信文章链接</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{items.map((item) => (
|
||||
<div key={`${item.chatroom_id}:${item.local_id}:${item.canonical_url}`} className="rounded-md border border-transparent px-2 py-2 hover:border-[var(--border-soft)] hover:bg-[var(--surface-2)]">
|
||||
<a href={item.url} target="_blank" rel="noreferrer" className="group block min-w-0" title={item.url}>
|
||||
<div className="line-clamp-2 text-[12px] font-medium leading-snug text-[var(--text)] group-hover:text-[var(--accent)]">
|
||||
{item.title || item.domain || item.raw_kind}
|
||||
</div>
|
||||
<div className="mt-1 break-all text-[10px] leading-snug text-[var(--text-3)]">
|
||||
{item.url}
|
||||
</div>
|
||||
</a>
|
||||
<div className="mt-1 flex items-center justify-between gap-2 text-[10px] text-[var(--text-3)]">
|
||||
<Link
|
||||
href={`/groups/${encodeURIComponent(item.chatroom_id)}?date=${date}`}
|
||||
className="min-w-0 truncate text-[var(--text-2)] hover:text-[var(--accent)]"
|
||||
>
|
||||
{item.chat_name} · {item.sender}
|
||||
</Link>
|
||||
<span className="shrink-0 rounded bg-[var(--accent-soft)] px-1.5 py-0.5 text-[var(--accent)]">
|
||||
{item.raw_kind}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkInsightPanel({
|
||||
title,
|
||||
icon,
|
||||
@@ -191,7 +268,10 @@ function LinkInsightRow({ item, date }: { item: LinkInsight; date: string }) {
|
||||
<span className="line-clamp-2 text-[12px] font-medium leading-snug text-[var(--text)] group-hover:text-[var(--accent)]">
|
||||
{item.title}
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-[10px] text-[var(--text-3)]">{item.domain}</span>
|
||||
<span className="mt-1 flex min-w-0 items-center gap-2 text-[10px] text-[var(--text-3)]">
|
||||
<span className={sourceClass(first?.source)}>{sourceLabel(first?.source)}</span>
|
||||
<span className="truncate">{item.domain}</span>
|
||||
</span>
|
||||
</span>
|
||||
<ExternalLink size={12} className="mt-0.5 shrink-0 text-[var(--text-3)] group-hover:text-[var(--accent)]" />
|
||||
</a>
|
||||
@@ -214,3 +294,17 @@ function LinkInsightRow({ item, date }: { item: LinkInsight; date: string }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sourceLabel(source?: string): string {
|
||||
if (source === 'wechat_raw') return '微信原文';
|
||||
if (source === 'public_search') return '公开补链';
|
||||
if (source === 'manual') return '手动补链';
|
||||
return '网页链接';
|
||||
}
|
||||
|
||||
function sourceClass(source?: string): string {
|
||||
const base = 'shrink-0 rounded px-1.5 py-0.5';
|
||||
if (source === 'wechat_raw') return `${base} bg-[var(--accent-soft)] text-[var(--accent)]`;
|
||||
if (source === 'public_search' || source === 'manual') return `${base} bg-[var(--warn-soft)] text-[var(--warn)]`;
|
||||
return `${base} bg-[var(--surface-2)] text-[var(--text-3)]`;
|
||||
}
|
||||
|
||||
+13
-7
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import TopBar, { type RangeKey, type RefreshMode } from '@/components/TopBar';
|
||||
import TopBar, { type RangeKey } from '@/components/TopBar';
|
||||
import StatGrid, { type CardsData } from '@/components/StatGrid';
|
||||
import TrendChart, { type TrendPoint } from '@/components/TrendChart';
|
||||
import ActiveGroupsList, { type ActiveGroup } from '@/components/ActiveGroupsList';
|
||||
@@ -24,13 +24,11 @@ type StatsResponse = {
|
||||
export default function Page() {
|
||||
const [range, setRange] = useState<RangeKey>('month');
|
||||
const [date, setDate] = useState(() => localToday());
|
||||
const [mode, setMode] = useState<RefreshMode>('auto');
|
||||
const [stats, setStats] = useState<StatsResponse | null>(null);
|
||||
const [rescanning, setRescanning] = useState(false);
|
||||
const [rescanInfo, setRescanInfo] = useState<string | undefined>(undefined);
|
||||
const [setupChecked, setSetupChecked] = useState(false);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
@@ -113,6 +111,12 @@ export default function Page() {
|
||||
setRescanInfo(
|
||||
`完成 · ${evt.messages ?? evt.inserted_messages ?? 0} 条消息已入库`,
|
||||
);
|
||||
} else if (evt.type === 'topics_start') {
|
||||
setRescanInfo(`消息已入库 · 开始构建 ${evt.dates} 天话题`);
|
||||
} else if (evt.type === 'topics_date') {
|
||||
setRescanInfo(`构建话题 · ${evt.date}`);
|
||||
} else if (typeof evt.type === 'string' && evt.type.startsWith('topics_')) {
|
||||
setRescanInfo(`构建话题 · ${evt.date}${evt.message ? ` · ${evt.message}` : ''}`);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -128,7 +132,11 @@ export default function Page() {
|
||||
);
|
||||
|
||||
if (!setupChecked) {
|
||||
return <div className="flex h-screen items-center justify-center bg-[var(--bg)] text-[12px] text-[var(--text-3)]">加载配置…</div>;
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-[var(--bg)] text-[12px] text-[var(--text-3)]">
|
||||
加载配置…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -141,8 +149,6 @@ export default function Page() {
|
||||
date={date}
|
||||
onRangeChange={setRange}
|
||||
onDateChange={setDate}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
rescanning={rescanning}
|
||||
onRescan={() => runRescan(false)}
|
||||
onFullSync={() => runRescan(true)}
|
||||
@@ -166,7 +172,7 @@ export default function Page() {
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 2xl:grid-cols-[1.4fr_1fr]">
|
||||
<ActiveGroupsList groups={stats?.active_groups ?? []} />
|
||||
<ActiveGroupsList groups={stats?.active_groups ?? []} date={stats?.window.until ?? date} />
|
||||
<CategoryChart categories={stats?.categories ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,602 @@
|
||||
'use client';
|
||||
|
||||
import { use, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import { ArrowLeft, Check, Copy, ExternalLink, FileText, Link2, MessageSquare, Trophy } from 'lucide-react';
|
||||
|
||||
type Detail = {
|
||||
ok: boolean;
|
||||
chatroom_id: string;
|
||||
date: string;
|
||||
stats: {
|
||||
chat: string;
|
||||
total: number;
|
||||
top_senders: Array<{ sender: string; count: number }>;
|
||||
} | null;
|
||||
recent: Array<{
|
||||
local_id: number;
|
||||
sender: string;
|
||||
content: string;
|
||||
time: string;
|
||||
type: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function GroupDailyReportPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const chatroomId = decodeURIComponent(id);
|
||||
const date = useSearchParams().get('date') ?? localToday();
|
||||
const [data, setData] = useState<Detail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await fetch(`/api/group/${encodeURIComponent(chatroomId)}?date=${date}&limit=500`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
const j = (await r.json()) as Detail;
|
||||
if (!cancelled) setData(j);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [chatroomId, date]);
|
||||
|
||||
const reportText = useMemo(() => buildReportText(data, chatroomId, date), [data, chatroomId, date]);
|
||||
|
||||
const copyReport = useCallback(async () => {
|
||||
await navigator.clipboard.writeText(reportText);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1200);
|
||||
}, [reportText]);
|
||||
|
||||
const title = data?.stats?.chat ?? chatroomId;
|
||||
const report = useMemo(() => analyzeReport(data?.recent ?? []), [data?.recent]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-[var(--bg)]">
|
||||
<Sidebar />
|
||||
<main className="flex flex-1 flex-col overflow-hidden">
|
||||
<header className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[var(--chrome-bg)] px-6 py-3 backdrop-blur">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Link href="/" className="shrink-0 text-[var(--text-3)] hover:text-[var(--text)]">
|
||||
<ArrowLeft size={16} />
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<div className="report-kicker">Group Daily Report</div>
|
||||
<h1 className="truncate text-[16px] font-semibold">{title}</h1>
|
||||
<div className="mt-0.5 text-[11px] text-[var(--text-3)]">
|
||||
{date} · {loading ? '加载中…' : `${data?.stats?.total ?? 0} 条消息`} · 日报地址
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="btn" onClick={copyReport} disabled={!data?.ok}>
|
||||
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||
<span>{copied ? '已复制' : '复制日报'}</span>
|
||||
</button>
|
||||
<Link
|
||||
className="btn"
|
||||
href={`/groups/${encodeURIComponent(chatroomId)}?date=${date}`}
|
||||
title="查看完整群消息"
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
<span>完整消息</span>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
<section className="card p-5">
|
||||
<div className="flex items-center gap-1.5 text-[14px] font-semibold">
|
||||
<FileText size={15} className="text-[var(--accent)]" />
|
||||
日报摘要
|
||||
</div>
|
||||
<p className="mt-3 text-[13px] leading-6 text-[var(--text-2)]">
|
||||
{loading
|
||||
? '正在生成日报视图…'
|
||||
: `今天这个群共产生 ${data?.stats?.total ?? 0} 条消息,按摘要策略提炼为核心主题、证据引用、资源链接和可跟进项。`}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-[1.25fr_0.75fr]">
|
||||
<section className="card p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-[14px] font-semibold">
|
||||
<MessageSquare size={15} className="text-[var(--accent)]" />
|
||||
核心主题
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--text-3)]">{report.topics.length} 个</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-3">
|
||||
{report.topics.length === 0 ? (
|
||||
<div className="py-10 text-center text-[12px] text-[var(--text-3)]">暂无可聚合主题</div>
|
||||
) : (
|
||||
report.topics.map((topic, i) => (
|
||||
<article key={topic.title} className="rounded-md border border-[var(--border-soft)] bg-[var(--surface-2)] px-3 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 className="report-title text-[16px] font-semibold text-[var(--text)]">
|
||||
{i + 1}. {topic.title}
|
||||
</h2>
|
||||
<span className="shrink-0 rounded bg-[var(--accent-soft)] px-1.5 py-0.5 text-[10px] text-[var(--accent)]">
|
||||
{topic.count} 条
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[12px] leading-5 text-[var(--text-2)]">
|
||||
<span className="font-medium text-[var(--text)]">聊了什么:</span>
|
||||
{topic.what}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] leading-5 text-[var(--text-2)]">
|
||||
<span className="font-medium text-[var(--text)]">为什么重要:</span>
|
||||
{topic.why}
|
||||
</div>
|
||||
{topic.quote && (
|
||||
<blockquote className="mt-2 border-l-2 border-[var(--accent)] pl-3 text-[11px] leading-5 text-[var(--text-3)]">
|
||||
{topic.quote.sender}:「{topic.quote.text}」
|
||||
</blockquote>
|
||||
)}
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-4">
|
||||
<section className="card p-5">
|
||||
<div className="flex items-center gap-1.5 text-[14px] font-semibold">
|
||||
<Link2 size={15} className="text-[var(--accent)]" />
|
||||
工具/文章/链接
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{report.links.length === 0 ? (
|
||||
<div className="py-6 text-center text-[12px] text-[var(--text-3)]">暂无链接</div>
|
||||
) : (
|
||||
report.links.slice(0, 10).map((link) => (
|
||||
<a key={link.key} href={link.url} target="_blank" rel="noreferrer" className="block rounded-md px-2 py-2 hover:bg-[var(--surface-2)]">
|
||||
<div className="line-clamp-2 text-[12px] font-medium text-[var(--text)]">{link.title}</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-2 text-[10px] text-[var(--text-3)]">
|
||||
<span className="truncate">{link.url}</span>
|
||||
{(link.count > 1 || link.urlCount > 1) && (
|
||||
<span className="shrink-0 rounded bg-[var(--accent-soft)] px-1.5 py-0.5 text-[var(--accent)]">
|
||||
合并 {link.count} 次 / {link.urlCount} 链接
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card p-5">
|
||||
<div className="flex items-center gap-1.5 text-[14px] font-semibold">
|
||||
<FileText size={15} className="text-[var(--warn)]" />
|
||||
可跟进
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{report.followups.length === 0 ? (
|
||||
<div className="py-6 text-center text-[12px] text-[var(--text-3)]">暂无明确问题</div>
|
||||
) : (
|
||||
report.followups.slice(0, 8).map((m) => (
|
||||
<div key={m.local_id} className="rounded-md px-2 py-2 text-[12px] leading-5 text-[var(--text-2)] hover:bg-[var(--surface-2)]">
|
||||
<span className="font-medium text-[var(--text)]">{m.sender}:</span>
|
||||
{cleanMessage(m.content)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card p-5">
|
||||
<div className="flex items-center gap-1.5 text-[14px] font-semibold">
|
||||
<Trophy size={15} className="text-[var(--warn)]" />
|
||||
Top 发言人
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{(data?.stats?.top_senders ?? []).slice(0, 8).map((sender, i) => (
|
||||
<div key={`${sender.sender}:${i}`} className="flex items-center justify-between gap-3 text-[13px]">
|
||||
<span className="truncate text-[var(--text-2)]">{i + 1}. {sender.sender}</span>
|
||||
<span className="tabular-nums text-[var(--text)]">{sender.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ReportMessage = Detail['recent'][number];
|
||||
|
||||
type TopicReport = {
|
||||
title: string;
|
||||
count: number;
|
||||
what: string;
|
||||
why: string;
|
||||
quote?: { sender: string; text: string };
|
||||
};
|
||||
|
||||
type TopicRule = {
|
||||
title: string;
|
||||
re: RegExp;
|
||||
why: string;
|
||||
priority: number;
|
||||
};
|
||||
|
||||
const TOPIC_RULES: TopicRule[] = [
|
||||
{
|
||||
title: 'AI 编程与 Agent 工作流',
|
||||
re: /codex|claude code|agent|mcp|skills?|cli|vibe.?coding|编程|代码/i,
|
||||
why: '这类讨论通常直接影响个人和团队的生产方式,适合沉淀成工作流、工具清单或实践经验。',
|
||||
priority: 5,
|
||||
},
|
||||
{
|
||||
title: '新工具、新模型与产品体验',
|
||||
re: /工具|产品|模型|内测|API|插件|开源|github|huggingface|modelscope/i,
|
||||
why: '群里对工具和模型的实测反馈,比单纯发布新闻更接近真实采用价值,适合优先试用和收藏。',
|
||||
priority: 4,
|
||||
},
|
||||
{
|
||||
title: '活动、报名与社群机会',
|
||||
re: /报名|活动|直播|大会|训练营|课程|线下|名额|招募/i,
|
||||
why: '这些信息有时间窗口,错过就失效,适合当天判断是否参与、转发或对接。',
|
||||
priority: 6,
|
||||
},
|
||||
{
|
||||
title: '内容创作、AIGC 与传播',
|
||||
re: /内容|小红书|公众号|视频|图像|封面|传播|选题|日报|文章/i,
|
||||
why: '这类讨论可以转化为公开内容、选题储备或素材库,对后续写作和传播有复用价值。',
|
||||
priority: 3,
|
||||
},
|
||||
{
|
||||
title: '需求、问题与可回复线索',
|
||||
re: /求推荐|有没有|谁有|怎么|如何|能不能|可不可以|问题|方案/i,
|
||||
why: '明确问题背后往往有真实需求,适合用你的经验、资源或产品线索去回应。',
|
||||
priority: 7,
|
||||
},
|
||||
];
|
||||
|
||||
function analyzeReport(messages: ReportMessage[]) {
|
||||
const clean = messages
|
||||
.map((m) => ({ ...m, content: cleanMessage(m.content) }))
|
||||
.map((m) => ({ ...m, content: meaningfulMessageText(m.content) }))
|
||||
.filter((m) => m.content.length >= 6 && !isLowValueMessage(m.content));
|
||||
const topics = clusterTopics(clean);
|
||||
|
||||
const links = extractLinks(clean).slice(0, 24);
|
||||
const followups = clean
|
||||
.filter((m) => /求推荐|有没有|谁有|怎么|如何|能不能|可不可以|请教|帮忙/i.test(m.content))
|
||||
.slice(0, 12);
|
||||
|
||||
return {
|
||||
topics: topics.sort((a, b) => b.count - a.count).slice(0, 5),
|
||||
links,
|
||||
followups,
|
||||
};
|
||||
}
|
||||
|
||||
function clusterTopics(messages: ReportMessage[]): TopicReport[] {
|
||||
const assigned = new Set<number>();
|
||||
const clusters = new Map<string, { rule: TopicRule; messages: ReportMessage[] }>();
|
||||
|
||||
for (const m of messages) {
|
||||
if (assigned.has(m.local_id)) continue;
|
||||
const rule = bestTopicRule(m.content);
|
||||
if (!rule) continue;
|
||||
const key = discussionKey(m.content, rule.title);
|
||||
const existing = clusters.get(key);
|
||||
if (existing) {
|
||||
existing.messages.push(m);
|
||||
} else {
|
||||
clusters.set(key, { rule, messages: [m] });
|
||||
}
|
||||
assigned.add(m.local_id);
|
||||
}
|
||||
|
||||
return Array.from(clusters.values())
|
||||
.map(({ rule, messages: hits }) => {
|
||||
const unique = dedupeMessages(hits);
|
||||
const representative = unique
|
||||
.slice()
|
||||
.sort((a, b) => scoreMessage(b.content) - scoreMessage(a.content))[0];
|
||||
return {
|
||||
title: specificTopicTitle(representative?.content ?? rule.title, rule.title),
|
||||
count: unique.length,
|
||||
what: summarizeTopic(unique),
|
||||
why: rule.why,
|
||||
quote: representative
|
||||
? { sender: representative.sender, text: stripUrls(representative.content).slice(0, 120) }
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
.filter((topic) => topic.count >= 2 || scoreMessage(topic.what) >= 8)
|
||||
.sort((a, b) => b.count - a.count || b.what.length - a.what.length)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
function bestTopicRule(content: string): TopicRule | null {
|
||||
const matches = TOPIC_RULES.filter((rule) => rule.re.test(content));
|
||||
if (matches.length === 0) return null;
|
||||
return matches.sort((a, b) => b.priority - a.priority)[0];
|
||||
}
|
||||
|
||||
function dedupeMessages(messages: ReportMessage[]): ReportMessage[] {
|
||||
const seen = new Set<string>();
|
||||
const out: ReportMessage[] = [];
|
||||
for (const m of messages) {
|
||||
const key = messageFingerprint(m.content);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function discussionKey(content: string, fallback: string): string {
|
||||
const url = firstUrl(content);
|
||||
if (url) return `url:${normalizeResourceUrl(url)}`;
|
||||
const title = bracketTitle(content);
|
||||
if (title) return `title:${textFingerprint(title)}`;
|
||||
return `text:${textFingerprint(content).slice(0, 48) || fallback}`;
|
||||
}
|
||||
|
||||
function messageFingerprint(content: string): string {
|
||||
const url = firstUrl(content);
|
||||
if (url) return `url:${normalizeResourceUrl(url)}`;
|
||||
return textFingerprint(stripUrls(content)).slice(0, 80);
|
||||
}
|
||||
|
||||
function textFingerprint(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/https?:\/\/\S+/g, ' ')
|
||||
.replace(/[^\p{Script=Han}\p{L}\p{N}]+/gu, '')
|
||||
.slice(0, 120);
|
||||
}
|
||||
|
||||
function firstUrl(content: string): string | null {
|
||||
const match = content.match(/https?:\/\/[^\s<>"']+/);
|
||||
return match ? match[0].replace(/[),,。;;!?!?、\]}>]+$/g, '') : null;
|
||||
}
|
||||
|
||||
function bracketTitle(content: string): string | null {
|
||||
const match = content.match(/\[(?:链接|链接\/文件)\]\s*([^\n。;;]{4,80})/);
|
||||
const title = match?.[1]?.trim();
|
||||
if (!title || isMeaninglessTitle(title)) return null;
|
||||
return title;
|
||||
}
|
||||
|
||||
function specificTopicTitle(content: string, fallback: string): string {
|
||||
const title = bracketTitle(content);
|
||||
if (title) return title.replace(/^【|】$/g, '').slice(0, 28);
|
||||
const cleaned = meaningfulMessageText(content)
|
||||
.replace(/^\[引用\]\s*/g, '')
|
||||
.replace(/^\[(?:链接|链接\/文件)\]\s*/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const product = cleaned.match(/(Claude Code|GPT-Image-2|ChatGPT|Codex|MCP|InkOS|Munger Models|awesome-mac|Sloth-Poster-Den|Bloome|Stitch)/i)?.[0];
|
||||
if (product) return `${product} 相关讨论`.slice(0, 28);
|
||||
return (cleaned.split(/[。!?!?;;]/)[0] || fallback).slice(0, 28);
|
||||
}
|
||||
|
||||
function summarizeTopic(messages: ReportMessage[]): string {
|
||||
const top = messages
|
||||
.slice()
|
||||
.sort((a, b) => scoreMessage(b.content) - scoreMessage(a.content))
|
||||
.slice(0, 3)
|
||||
.map((m) => meaningfulMessageText(m.content))
|
||||
.filter((text) => text.length >= 6 && !isMeaninglessTitle(text));
|
||||
if (top.length === 0) return '当天有零散讨论,但缺少足够完整的上下文。';
|
||||
return top.join(';').slice(0, 220);
|
||||
}
|
||||
|
||||
function scoreMessage(content: string): number {
|
||||
let score = Math.min(content.length / 18, 8);
|
||||
if (/https?:\/\/|<url>|github|工具|模型|报名|求推荐/i.test(content)) score += 4;
|
||||
if (/我觉得|实测|经验|方案|问题|原因|为什么/i.test(content)) score += 2;
|
||||
return score;
|
||||
}
|
||||
|
||||
function extractLinks(messages: ReportMessage[]) {
|
||||
const groups = new Map<string, { title: string; urls: Set<string>; count: number; score: number }>();
|
||||
for (const m of messages) {
|
||||
for (const match of m.content.matchAll(/https?:\/\/[^\s<>"']+/g)) {
|
||||
const url = match[0].replace(/[),,。;;!?!?、\]}>]+$/g, '');
|
||||
if (isNoiseUrl(url)) continue;
|
||||
const title = titleFromMessage(m.content, url);
|
||||
if (isMeaninglessTitle(title)) continue;
|
||||
const key = linkGroupKey(title, url);
|
||||
const current = groups.get(key);
|
||||
const nextScore = scoreMessage(m.content);
|
||||
if (current) {
|
||||
current.urls.add(url);
|
||||
current.count++;
|
||||
if (nextScore > current.score) {
|
||||
current.title = title;
|
||||
current.score = nextScore;
|
||||
}
|
||||
} else {
|
||||
groups.set(key, { title, urls: new Set([url]), count: 1, score: nextScore });
|
||||
}
|
||||
}
|
||||
}
|
||||
const rows = Array.from(groups.entries())
|
||||
.map(([key, item]) => ({
|
||||
key,
|
||||
title: item.title,
|
||||
url: Array.from(item.urls)[0],
|
||||
count: item.count,
|
||||
urlCount: item.urls.size,
|
||||
}));
|
||||
|
||||
const byUrl = new Map<string, (typeof rows)[number]>();
|
||||
for (const row of rows) {
|
||||
const urlKey = normalizeResourceUrl(row.url);
|
||||
const current = byUrl.get(urlKey);
|
||||
if (!current) {
|
||||
byUrl.set(urlKey, row);
|
||||
continue;
|
||||
}
|
||||
current.key = `${current.key}|${row.key}`;
|
||||
current.count += row.count;
|
||||
current.urlCount = Math.max(current.urlCount, row.urlCount);
|
||||
if (row.title.length > current.title.length) current.title = row.title;
|
||||
}
|
||||
|
||||
return Array.from(byUrl.values())
|
||||
.sort((a, b) => b.count - a.count || b.urlCount - a.urlCount);
|
||||
}
|
||||
|
||||
function titleFromMessage(content: string, url: string): string {
|
||||
const text = meaningfulMessageText(content)
|
||||
.replace(/^\[(?:链接|链接\/文件)\]\s*/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return (text || domainFromUrl(url)).slice(0, 96);
|
||||
}
|
||||
|
||||
function linkGroupKey(title: string, url: string): string {
|
||||
const titleKey = textFingerprint(title).slice(0, 48);
|
||||
if (titleKey.length >= 12) return `title:${titleKey}`;
|
||||
return `url:${normalizeResourceUrl(url)}`;
|
||||
}
|
||||
|
||||
function normalizeResourceUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
u.hash = '';
|
||||
for (const key of Array.from(u.searchParams.keys())) {
|
||||
if (/^(utm_|spm|from|share_|vd_source|scene|clicktime|enterid)/i.test(key)) {
|
||||
u.searchParams.delete(key);
|
||||
}
|
||||
}
|
||||
return `${u.hostname}${u.pathname}${u.searchParams.toString() ? `?${u.searchParams.toString()}` : ''}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function domainFromUrl(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function stripUrls(content: string): string {
|
||||
return content.replace(/https?:\/\/\S+/g, '').trim();
|
||||
}
|
||||
|
||||
const MEANINGLESS_TITLE_RE =
|
||||
/^(?:\[?链接\]?\s*)?(?:当前(?:微信)?版本不支持展示该内容,请升级至?最新(?:版|版本)|当前版本不支持|请升级至最新版本)[。.]?$/i;
|
||||
|
||||
const NOISE_URL_HOSTS = new Set([
|
||||
'support.weixin.qq.com',
|
||||
'wx.qlogo.cn',
|
||||
'dldir1v6.qq.com',
|
||||
'finder.video.qq.com',
|
||||
]);
|
||||
|
||||
function meaningfulMessageText(content: string): string {
|
||||
const withoutUrls = stripUrls(content)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/\[(?:链接|链接\/文件)\]\s*(?:当前(?:微信)?版本不支持展示该内容,请升级至?最新(?:版|版本)|当前版本不支持展示该内容,请升级至最新版本)[。.]?/gi, ' ')
|
||||
.replace(/当前(?:微信)?版本不支持展示该内容,请升级至?最新(?:版|版本)[。.]?/gi, ' ')
|
||||
.replace(/\[图片\]\s*local_id=\d+/gi, ' ')
|
||||
.replace(/\b[a-f0-9]{24,}\b/gi, ' ')
|
||||
.replace(/\b\d{12,}\b/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const parts = withoutUrls
|
||||
.split(/↳|\\n|\n/)
|
||||
.map((part) =>
|
||||
part
|
||||
.replace(/^\[引用\]\s*/g, '')
|
||||
.replace(/^\[(?:链接|链接\/文件|图片|视频|表情)\]\s*/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim(),
|
||||
)
|
||||
.filter((part) => part.length >= 6 && !isMeaninglessTitle(part));
|
||||
|
||||
return (parts[0] ?? withoutUrls).trim();
|
||||
}
|
||||
|
||||
function isMeaninglessTitle(title: string): boolean {
|
||||
const text = stripUrls(title)
|
||||
.replace(/^\[(?:链接|链接\/文件|图片|视频|表情)\]\s*/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!text) return true;
|
||||
if (MEANINGLESS_TITLE_RE.test(text)) return true;
|
||||
return /^(?:xml_url_text|网页链接|视频号|finder|wx\.qlogo\.cn)$/i.test(text);
|
||||
}
|
||||
|
||||
function isLowValueMessage(content: string): boolean {
|
||||
const text = meaningfulMessageText(content);
|
||||
if (isMeaninglessTitle(text)) return true;
|
||||
return /^[\d\s._-]+$/.test(text) || text.length < 6;
|
||||
}
|
||||
|
||||
function isNoiseUrl(url: string): boolean {
|
||||
try {
|
||||
const host = new URL(url).hostname.toLowerCase();
|
||||
return NOISE_URL_HOSTS.has(host);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildReportText(data: Detail | null, chatroomId: string, date: string): string {
|
||||
const title = data?.stats?.chat ?? chatroomId;
|
||||
const messages = data?.recent ?? [];
|
||||
const report = analyzeReport(messages);
|
||||
return [
|
||||
`# ${title} ${date} 群日报`,
|
||||
'',
|
||||
`今天共 ${data?.stats?.total ?? 0} 条消息。`,
|
||||
'',
|
||||
'## 核心主题',
|
||||
...report.topics.flatMap((topic, i) => [
|
||||
'',
|
||||
`### ${i + 1}. ${topic.title}`,
|
||||
`聊了什么:${topic.what}`,
|
||||
`为什么重要:${topic.why}`,
|
||||
topic.quote ? `引用/证据:${topic.quote.sender}:「${topic.quote.text}」` : '',
|
||||
]),
|
||||
'',
|
||||
'## 工具/文章/链接',
|
||||
...report.links.map((link) => `- ${link.title}: ${link.url}`),
|
||||
'',
|
||||
'## 未解决/可跟进',
|
||||
...report.followups.map((m) => `- ${m.sender}: ${cleanMessage(m.content)}`),
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function cleanMessage(content: string): string {
|
||||
return content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function localToday(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export default function SignalsPage() {
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
|
||||
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[var(--chrome-bg)] px-6 py-3 backdrop-blur">
|
||||
<div>
|
||||
<div className="report-kicker">Live Signals</div>
|
||||
<div className="flex items-center gap-2 text-[15px] font-semibold">
|
||||
|
||||
+11
-4
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import MessageContent from '@/components/MessageContent';
|
||||
@@ -42,6 +42,7 @@ export default function TopicsPage() {
|
||||
const [detail, setDetail] = useState<{ topic: Topic; messages: TopicMessage[] } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [info, setInfo] = useState<string | undefined>(undefined);
|
||||
const autoBuildDates = useRef(new Set<string>());
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
@@ -148,11 +149,17 @@ export default function TopicsPage() {
|
||||
}
|
||||
}, [date, reload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (busy || topics.length > 0 || autoBuildDates.current.has(date)) return;
|
||||
autoBuildDates.current.add(date);
|
||||
build();
|
||||
}, [build, busy, date, topics.length]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
|
||||
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[var(--chrome-bg)] px-6 py-3 backdrop-blur">
|
||||
<div>
|
||||
<div className="report-kicker">Cross-Group Topics</div>
|
||||
<div className="flex items-center gap-2 text-[15px] font-semibold">
|
||||
@@ -170,7 +177,7 @@ export default function TopicsPage() {
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="bg-transparent text-[12px] outline-none [color-scheme:dark]"
|
||||
className="theme-date-input bg-transparent text-[12px] outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button className={`btn ${busy ? 'btn-warn' : 'btn-primary'}`} onClick={build} disabled={busy}>
|
||||
@@ -184,7 +191,7 @@ export default function TopicsPage() {
|
||||
<div className="overflow-y-auto border-r border-[var(--border-soft)] p-4">
|
||||
{topics.length === 0 ? (
|
||||
<div className="py-16 text-center text-[12px] text-[var(--text-3)]">
|
||||
当日还没构建话题 · 点击「构建话题」
|
||||
{busy ? '正在自动构建当日话题…' : '当日暂无可聚合话题'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from 'next/link';
|
||||
import { Flame } from 'lucide-react';
|
||||
import { FileText, Flame } from 'lucide-react';
|
||||
|
||||
export interface ActiveGroup {
|
||||
chatroom_id: string;
|
||||
@@ -9,7 +9,7 @@ export interface ActiveGroup {
|
||||
top_senders: Array<{ sender: string; count: number }>;
|
||||
}
|
||||
|
||||
export default function ActiveGroupsList({ groups }: { groups: ActiveGroup[] }) {
|
||||
export default function ActiveGroupsList({ groups, date }: { groups: ActiveGroup[]; date?: string }) {
|
||||
const max = groups[0]?.total ?? 1;
|
||||
return (
|
||||
<div className="card p-5">
|
||||
@@ -19,7 +19,7 @@ export default function ActiveGroupsList({ groups }: { groups: ActiveGroup[] })
|
||||
智能活跃群
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--text-3)]">
|
||||
去噪后 {groups.length} 个 · 综合排序
|
||||
去噪后 {groups.length} 个 · 前 10 个有日报入口
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function ActiveGroupsList({ groups }: { groups: ActiveGroup[] })
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{groups.slice(0, 12).map((g, i) => (
|
||||
<Row key={g.chatroom_id} group={g} rank={i + 1} max={max} />
|
||||
<Row key={g.chatroom_id} group={g} rank={i + 1} max={max} date={date} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -38,18 +38,32 @@ export default function ActiveGroupsList({ groups }: { groups: ActiveGroup[] })
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ group, rank, max }: { group: ActiveGroup; rank: number; max: number }) {
|
||||
function Row({
|
||||
group,
|
||||
rank,
|
||||
max,
|
||||
date,
|
||||
}: {
|
||||
group: ActiveGroup;
|
||||
rank: number;
|
||||
max: number;
|
||||
date?: string;
|
||||
}) {
|
||||
const initial = group.name.slice(0, 2);
|
||||
const senders = group.top_senders
|
||||
.slice(0, 3)
|
||||
.map((s) => s.sender)
|
||||
.join(' · ');
|
||||
const pct = (group.total / max) * 100;
|
||||
const groupHref = `/groups/${encodeURIComponent(group.chatroom_id)}${date ? `?date=${date}` : ''}`;
|
||||
const reportHref = `/reports/groups/${encodeURIComponent(group.chatroom_id)}?date=${date}`;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-2 rounded-md px-2 py-2 transition-colors hover:bg-[var(--surface-2)]">
|
||||
<Link
|
||||
href={`/groups/${encodeURIComponent(group.chatroom_id)}`}
|
||||
className="grid grid-cols-[24px_36px_1fr_70px] items-center gap-3 rounded-md px-2 py-2 transition-colors hover:bg-[var(--surface-2)]"
|
||||
href={groupHref}
|
||||
className="grid min-w-0 grid-cols-[24px_36px_1fr_88px] items-center gap-3"
|
||||
title="查看群详情"
|
||||
>
|
||||
<span className="rounded bg-[var(--surface-2)] py-0.5 text-center text-[10px] tabular-nums text-[var(--text-3)]">
|
||||
{rank}
|
||||
@@ -75,5 +89,18 @@ function Row({ group, rank, max }: { group: ActiveGroup; rank: number; max: numb
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{rank <= 10 && date ? (
|
||||
<Link
|
||||
href={reportHref}
|
||||
className="inline-flex h-7 items-center gap-1 rounded border border-[var(--border-soft)] bg-[var(--accent-soft)] px-2 text-[11px] font-medium text-[var(--accent)] transition-colors hover:border-[var(--accent)]"
|
||||
title="查看群日报"
|
||||
>
|
||||
<FileText size={12} />
|
||||
日报
|
||||
</Link>
|
||||
) : (
|
||||
<span className="w-[52px]" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ExternalLink, Loader2, Search } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
type Result = {
|
||||
id: string;
|
||||
type: 'group' | 'topic' | 'person' | 'message' | 'link';
|
||||
title: string;
|
||||
subtitle: string;
|
||||
href: string;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
type SearchResponse = {
|
||||
ok: boolean;
|
||||
results: Result[];
|
||||
};
|
||||
|
||||
const TYPE_LABEL: Record<Result['type'], string> = {
|
||||
group: '群',
|
||||
topic: '话题',
|
||||
person: '人',
|
||||
message: '消息',
|
||||
link: '链接',
|
||||
};
|
||||
|
||||
export default function GlobalSearch() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<Result[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!boxRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
return () => document.removeEventListener('mousedown', onDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const q = query.trim();
|
||||
if (q.length < 2) {
|
||||
return;
|
||||
}
|
||||
const ctl = new AbortController();
|
||||
const timer = window.setTimeout(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
|
||||
cache: 'no-store',
|
||||
signal: ctl.signal,
|
||||
});
|
||||
const j = (await r.json()) as SearchResponse;
|
||||
if (j.ok) {
|
||||
setResults(j.results);
|
||||
setOpen(true);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!(e instanceof DOMException && e.name === 'AbortError')) console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 220);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
ctl.abort();
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<div ref={boxRef} className="relative w-[280px]">
|
||||
<div className="control-surface flex items-center gap-2 rounded-md px-2.5 py-1.5">
|
||||
<Search size={13} className="text-[var(--text-3)]" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={() => query.trim().length >= 2 && setOpen(true)}
|
||||
className="min-w-0 flex-1 bg-transparent text-[12px] outline-none placeholder:text-[var(--text-3)]"
|
||||
placeholder="搜索群、话题、人、关键词"
|
||||
/>
|
||||
{loading && <Loader2 size={12} className="animate-spin text-[var(--text-3)]" />}
|
||||
</div>
|
||||
|
||||
{open && query.trim().length >= 2 && (
|
||||
<div className="absolute right-0 top-[calc(100%+8px)] z-40 max-h-[520px] w-[420px] overflow-y-auto rounded-lg border border-[var(--border-soft)] bg-[var(--surface)] p-2 shadow-[var(--shadow)]">
|
||||
{results.length === 0 && !loading ? (
|
||||
<div className="px-3 py-8 text-center text-[12px] text-[var(--text-3)]">
|
||||
没找到匹配结果
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{results.map((item) => (
|
||||
<SearchItem key={item.id} item={item} onClick={() => setOpen(false)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchItem({ item, onClick }: { item: Result; onClick: () => void }) {
|
||||
const inner = (
|
||||
<>
|
||||
<span className="mt-0.5 rounded border border-[var(--border-soft)] px-1.5 py-0.5 text-[10px] text-[var(--text-3)]">
|
||||
{TYPE_LABEL[item.type]}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] font-medium text-[var(--text)]">
|
||||
{item.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[11px] text-[var(--text-3)]">
|
||||
{item.subtitle}
|
||||
</span>
|
||||
</span>
|
||||
{item.external && <ExternalLink size={12} className="text-[var(--text-3)]" />}
|
||||
</>
|
||||
);
|
||||
|
||||
const className =
|
||||
'flex items-start gap-2 rounded-md px-2.5 py-2 transition-colors hover:bg-[var(--surface-2)]';
|
||||
|
||||
if (item.external) {
|
||||
return (
|
||||
<a href={item.href} target="_blank" rel="noreferrer" className={className} onClick={onClick}>
|
||||
{inner}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={item.href} className={className} onClick={onClick}>
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
+323
-345
@@ -4,15 +4,15 @@ import { useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Check,
|
||||
Clipboard,
|
||||
ExternalLink,
|
||||
Flame,
|
||||
Lightbulb,
|
||||
FileText,
|
||||
Link2,
|
||||
Radar,
|
||||
Target,
|
||||
UserRoundCheck,
|
||||
Sparkles,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface DashboardLinkHighlight {
|
||||
title: string;
|
||||
url: string;
|
||||
domain: string;
|
||||
source: string;
|
||||
score: number;
|
||||
verdict: string;
|
||||
count: number;
|
||||
@@ -107,11 +108,7 @@ export interface DashboardIntelligence {
|
||||
anomalies: DashboardAnomalySignal[];
|
||||
}
|
||||
|
||||
export default function IntelligenceBrief({ intelligence }: { intelligence?: DashboardIntelligence }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const data =
|
||||
intelligence ??
|
||||
({
|
||||
const EMPTY: DashboardIntelligence = {
|
||||
date: '',
|
||||
must_read: [],
|
||||
opportunities: [],
|
||||
@@ -122,8 +119,44 @@ export default function IntelligenceBrief({ intelligence }: { intelligence?: Das
|
||||
people_radar: [],
|
||||
content_ideas: [],
|
||||
anomalies: [],
|
||||
} satisfies DashboardIntelligence);
|
||||
const summary = useMemo(() => buildDailySummary(data), [data]);
|
||||
};
|
||||
|
||||
type QueueItem = {
|
||||
title: string;
|
||||
href?: string;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
export default function IntelligenceBrief({ intelligence }: { intelligence?: DashboardIntelligence }) {
|
||||
const data = intelligence ?? EMPTY;
|
||||
const articles = data.link_highlights.filter((item) => item.kind === 'article');
|
||||
const tools = data.link_highlights.filter((item) => item.kind === 'tool');
|
||||
const summary = useMemo(() => buildSummary(data, articles, tools), [data, articles, tools]);
|
||||
const queue = useMemo(
|
||||
() => ({
|
||||
messages: data.must_read.slice(0, 2).map((item) => ({
|
||||
title: item.title,
|
||||
href: `/groups/${encodeURIComponent(item.chatroom_id)}?date=${data.date}`,
|
||||
})),
|
||||
articles: articles.slice(0, 2).map((item) => ({
|
||||
title: item.title,
|
||||
href: item.url,
|
||||
external: true,
|
||||
})),
|
||||
tools: tools.slice(0, 2).map((item) => ({
|
||||
title: item.title,
|
||||
href: item.url,
|
||||
external: true,
|
||||
})),
|
||||
anomalies: data.anomalies.slice(0, 2).map((item) => ({
|
||||
title: item.title,
|
||||
href: item.href,
|
||||
external: item.href?.startsWith('http') ?? false,
|
||||
})),
|
||||
}),
|
||||
[articles, data.anomalies, data.date, data.must_read, tools],
|
||||
);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copySummary() {
|
||||
await navigator.clipboard.writeText(summary);
|
||||
@@ -133,88 +166,161 @@ export default function IntelligenceBrief({ intelligence }: { intelligence?: Das
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="card flex items-center justify-between gap-4 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="report-kicker">Briefing Note</div>
|
||||
<section className="card p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="report-kicker">Today Queue</div>
|
||||
<div className="mt-1 flex items-center gap-1.5 text-[14px] font-semibold">
|
||||
<Radar size={14} className="text-[var(--accent)]" />
|
||||
今日情报简报
|
||||
<Sparkles size={14} className="text-[var(--accent)]" />
|
||||
今日先看这些
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[12px] text-[var(--text-2)]">
|
||||
{summary.split('\n').slice(1, 4).join(' · ')}
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 lg:grid-cols-4">
|
||||
<QueueBlock
|
||||
label="消息"
|
||||
items={queue.messages}
|
||||
empty="暂无高信号消息"
|
||||
/>
|
||||
<QueueBlock
|
||||
label="文章"
|
||||
items={queue.articles}
|
||||
empty="暂无文章"
|
||||
/>
|
||||
<QueueBlock
|
||||
label="工具"
|
||||
items={queue.tools}
|
||||
empty="暂无工具"
|
||||
/>
|
||||
<QueueBlock
|
||||
label="异动"
|
||||
items={queue.anomalies}
|
||||
empty="暂无异动"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copySummary}
|
||||
className="btn shrink-0"
|
||||
disabled={!data.date}
|
||||
title="复制今日情报摘要"
|
||||
>
|
||||
<button type="button" onClick={copySummary} className="btn shrink-0" disabled={!data.date}>
|
||||
{copied ? <Check size={13} /> : <Clipboard size={13} />}
|
||||
<span>{copied ? '已复制' : '复制摘要'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[1.1fr_1fr_1fr]">
|
||||
<ActionPanel date={data.date} items={data.action_items} fallback={data.opportunities} />
|
||||
<TopicLifecyclePanel topics={data.topic_lifecycle} />
|
||||
<AnomalyPanel anomalies={data.anomalies} />
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[1.15fr_1fr_0.85fr]">
|
||||
<MustReadPanel date={data.date} items={data.must_read} actions={data.action_items} />
|
||||
<ResourcePanel articles={articles} tools={tools} />
|
||||
<WatchPanel date={data.date} anomalies={data.anomalies} people={data.people_radar} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[1.1fr_1fr_1fr]">
|
||||
<LinkHighlightPanel items={data.link_highlights} />
|
||||
<PeopleRadarPanel people={data.people_radar} fallback={data.signal_sources} />
|
||||
<ContentIdeaPanel ideas={data.content_ideas} />
|
||||
</div>
|
||||
|
||||
<MustReadPanel date={data.date} items={data.must_read} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionPanel({
|
||||
function QueueBlock({
|
||||
label,
|
||||
items,
|
||||
empty,
|
||||
}: {
|
||||
label: string;
|
||||
items: QueueItem[];
|
||||
empty: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--border-soft)] bg-[var(--surface-2)] px-3 py-2">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-[var(--accent)]">{label}</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{items.length === 0 ? (
|
||||
<div className="text-[12px] text-[var(--text-3)]">{empty}</div>
|
||||
) : (
|
||||
items.map((item, i) => (
|
||||
<QueueLink key={`${label}:${i}:${item.title}`} item={item} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QueueLink({ item }: { item: QueueItem }) {
|
||||
const content = (
|
||||
<>
|
||||
<span className="line-clamp-2 min-w-0 flex-1">{item.title}</span>
|
||||
{item.href ? (
|
||||
item.external ? (
|
||||
<ExternalLink size={11} className="mt-1 shrink-0 text-[var(--text-3)]" />
|
||||
) : (
|
||||
<ArrowRight size={11} className="mt-1 shrink-0 text-[var(--text-3)]" />
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
const className =
|
||||
'flex items-start gap-2 rounded px-1.5 py-1 text-[12px] leading-5 text-[var(--text-2)] transition-colors hover:bg-[var(--surface)] hover:text-[var(--text)]';
|
||||
|
||||
if (!item.href) return <div className={className}>{content}</div>;
|
||||
if (item.external) {
|
||||
return (
|
||||
<a href={item.href} target="_blank" rel="noreferrer" className={className}>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link href={item.href} className={className}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MustReadPanel({
|
||||
date,
|
||||
items,
|
||||
fallback,
|
||||
actions,
|
||||
}: {
|
||||
date: string;
|
||||
items: DashboardActionItem[];
|
||||
fallback: DashboardOpportunityItem[];
|
||||
items: DashboardSignalItem[];
|
||||
actions: DashboardActionItem[];
|
||||
}) {
|
||||
const displayItems =
|
||||
items.length > 0
|
||||
? items
|
||||
: fallback.map((item) => ({
|
||||
...item,
|
||||
why: '包含明确需求或行动线索,适合进入上下文判断',
|
||||
urgency: 'medium' as const,
|
||||
}));
|
||||
const promoted = mergeSignals(actions, items).slice(0, 7);
|
||||
return (
|
||||
<section className="card min-h-[300px] p-4">
|
||||
<section className="card min-h-[360px] p-4">
|
||||
<PanelTitle
|
||||
icon={<Target size={14} className="text-[var(--warn)]" />}
|
||||
title="今日值得出手"
|
||||
meta={`${displayItems.length} 条`}
|
||||
icon={<Radar size={14} className="text-[var(--accent)]" />}
|
||||
title="关键话题与消息"
|
||||
meta={`${promoted.length} 条`}
|
||||
/>
|
||||
{displayItems.length === 0 ? (
|
||||
<EmptyState text="暂无明确行动项" />
|
||||
{promoted.length === 0 ? (
|
||||
<EmptyState text="暂无高信号消息" />
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{displayItems.slice(0, 6).map((item) => (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
{promoted.map((item, index) => (
|
||||
<Link
|
||||
key={`${item.chatroom_id}:${item.local_id}`}
|
||||
href={`/groups/${encodeURIComponent(item.chatroom_id)}?date=${date}`}
|
||||
className="block rounded-md border border-transparent px-2 py-2 transition-colors hover:border-[rgba(213,162,83,0.34)] hover:bg-[var(--surface-2)]"
|
||||
className="grid grid-cols-[24px_1fr_16px] items-start gap-2 rounded-md px-2 py-2 transition-colors hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="rounded border border-[rgba(213,162,83,0.22)] bg-[var(--warn-soft)] px-1.5 py-0.5 text-[10px] text-[var(--warn)]">
|
||||
{item.action}
|
||||
<span className="rounded bg-[var(--surface-2)] py-0.5 text-center text-[10px] tabular-nums text-[var(--text-3)]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className={urgencyClass(item.urgency)}>{urgencyText(item.urgency)}</span>
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-[12px] leading-snug text-[var(--text)]">{item.title}</div>
|
||||
<div className="mt-1 line-clamp-1 text-[10px] text-[var(--text-3)]">{item.why}</div>
|
||||
<span className="min-w-0">
|
||||
<span className="flex items-start justify-between gap-2">
|
||||
<span className="line-clamp-2 text-[12px] font-medium leading-snug text-[var(--text)]">
|
||||
{item.title}
|
||||
</span>
|
||||
{'action' in item && (
|
||||
<span className={urgencyClass((item as DashboardActionItem).urgency)}>
|
||||
{(item as DashboardActionItem).action}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-1 flex min-w-0 items-center gap-1.5 text-[10px] text-[var(--text-3)]">
|
||||
<span className="truncate">
|
||||
{item.chat_name} · {item.sender}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">{item.time.slice(11)}</span>
|
||||
</span>
|
||||
<span className="mt-1 line-clamp-1 text-[10px] text-[var(--text-3)]">
|
||||
{'why' in item ? (item as DashboardActionItem).why : item.reasons.join(' / ')}
|
||||
</span>
|
||||
</span>
|
||||
<ArrowRight size={12} className="mt-0.5 text-[var(--text-3)]" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
@@ -223,53 +329,112 @@ function ActionPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function TopicLifecyclePanel({ topics }: { topics: DashboardTopicLifecycle[] }) {
|
||||
function ResourcePanel({
|
||||
articles,
|
||||
tools,
|
||||
}: {
|
||||
articles: DashboardLinkHighlight[];
|
||||
tools: DashboardLinkHighlight[];
|
||||
}) {
|
||||
return (
|
||||
<section className="card min-h-[300px] p-4">
|
||||
<section className="card min-h-[360px] p-4">
|
||||
<PanelTitle
|
||||
icon={<Flame size={14} className="text-[var(--accent)]" />}
|
||||
title="趋势升温"
|
||||
meta={`${topics.length} 个话题`}
|
||||
icon={<Link2 size={14} className="text-[var(--accent)]" />}
|
||||
title="链接情报"
|
||||
meta={`${articles.length + tools.length} 条`}
|
||||
/>
|
||||
{topics.length === 0 ? (
|
||||
<EmptyState text="暂无可识别趋势" />
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{topics.slice(0, 6).map((topic) => (
|
||||
<div key={topic.title} className="rounded-md px-2 py-2 hover:bg-[var(--surface-2)]">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="line-clamp-1 text-[12px] font-medium text-[var(--text)]">{topic.title}</div>
|
||||
<div className="mt-1 line-clamp-1 text-[10px] text-[var(--text-3)]">{topic.reason}</div>
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 2xl:grid-cols-2">
|
||||
<ResourceColumn icon={<FileText size={13} />} title="文章 / 内容" items={articles.slice(0, 5)} />
|
||||
<ResourceColumn icon={<Wrench size={13} />} title="工具 / 资源" items={tools.slice(0, 5)} />
|
||||
</div>
|
||||
<span className={topicStatusClass(topic.status)}>{topicStatusText(topic.status)}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex gap-2 text-[10px] text-[var(--text-3)]">
|
||||
<span>{topic.today_count} 条</span>
|
||||
<span>{topic.group_count} 群</span>
|
||||
<span>均值 {topic.previous_avg}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AnomalyPanel({ anomalies }: { anomalies: DashboardAnomalySignal[] }) {
|
||||
function ResourceColumn({
|
||||
icon,
|
||||
title,
|
||||
items,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
items: DashboardLinkHighlight[];
|
||||
}) {
|
||||
return (
|
||||
<section className="card min-h-[300px] p-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-[12px] font-medium text-[var(--text-2)]">
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<div className="mt-3 rounded-md border border-[var(--border-soft)] px-3 py-8 text-center text-[12px] text-[var(--text-3)]">
|
||||
暂无
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{items.map((item) => (
|
||||
<a
|
||||
key={item.url}
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block rounded-md px-2 py-2 transition-colors hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="line-clamp-2 text-[12px] font-medium leading-snug text-[var(--text)]">
|
||||
{item.title}
|
||||
</div>
|
||||
<ExternalLink size={12} className="mt-0.5 shrink-0 text-[var(--text-3)]" />
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-2 text-[10px] text-[var(--text-3)]">
|
||||
<span className={sourceClass(item.source)}>{sourceLabel(item.source)}</span>
|
||||
<span className="truncate">{item.domain}</span>
|
||||
<span>{item.group_count} 群</span>
|
||||
<span>{item.count} 次</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sourceLabel(source: string): string {
|
||||
if (source === 'wechat_raw') return '微信原文';
|
||||
if (source === 'public_search') return '公开补链';
|
||||
if (source === 'manual') return '手动补链';
|
||||
return '网页链接';
|
||||
}
|
||||
|
||||
function sourceClass(source: string): string {
|
||||
const base = 'shrink-0 rounded px-1.5 py-0.5';
|
||||
if (source === 'wechat_raw') return `${base} bg-[var(--accent-soft)] text-[var(--accent)]`;
|
||||
if (source === 'public_search' || source === 'manual') return `${base} bg-[var(--warn-soft)] text-[var(--warn)]`;
|
||||
return `${base} bg-[var(--surface-2)] text-[var(--text-3)]`;
|
||||
}
|
||||
|
||||
function WatchPanel({
|
||||
date,
|
||||
anomalies,
|
||||
people,
|
||||
}: {
|
||||
date: string;
|
||||
anomalies: DashboardAnomalySignal[];
|
||||
people: DashboardPeopleRadar[];
|
||||
}) {
|
||||
return (
|
||||
<section className="card min-h-[360px] p-4">
|
||||
<PanelTitle
|
||||
icon={<AlertTriangle size={14} className="text-[var(--warn)]" />}
|
||||
title="异常信号"
|
||||
meta={`${anomalies.length} 条`}
|
||||
title="需要盯一下"
|
||||
meta={`${anomalies.length} 个异动`}
|
||||
/>
|
||||
{anomalies.length === 0 ? (
|
||||
<EmptyState text="暂无异常波动" />
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{anomalies.slice(0, 6).map((item) => {
|
||||
{anomalies.length === 0 ? (
|
||||
<EmptyState compact text="暂无明显异动" />
|
||||
) : (
|
||||
anomalies.slice(0, 4).map((item) => {
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -281,260 +446,100 @@ function AnomalyPanel({ anomalies }: { anomalies: DashboardAnomalySignal[] }) {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return item.href ? (
|
||||
<a
|
||||
key={`${item.kind}:${item.title}`}
|
||||
href={item.href}
|
||||
target={item.href.startsWith('http') ? '_blank' : undefined}
|
||||
rel={item.href.startsWith('http') ? 'noreferrer' : undefined}
|
||||
className="block rounded-md px-2 py-2 hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
{body}
|
||||
</a>
|
||||
) : (
|
||||
if (!item.href) {
|
||||
return (
|
||||
<div key={`${item.kind}:${item.title}`} className="rounded-md px-2 py-2 hover:bg-[var(--surface-2)]">
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkHighlightPanel({ items }: { items: DashboardLinkHighlight[] }) {
|
||||
if (item.href.startsWith('http')) {
|
||||
return (
|
||||
<section className="card min-h-[300px] p-4">
|
||||
<PanelTitle
|
||||
icon={<Link2 size={14} className="text-[var(--accent)]" />}
|
||||
title="链接精选"
|
||||
meta={`${items.length} 条`}
|
||||
/>
|
||||
{items.length === 0 ? (
|
||||
<EmptyState text="暂无高价值链接" />
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{items.slice(0, 6).map((item) => (
|
||||
<a
|
||||
key={item.url}
|
||||
href={item.url}
|
||||
key={`${item.kind}:${item.title}`}
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block rounded-md px-2 py-2 hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="line-clamp-1 text-[12px] font-medium text-[var(--text)]">{item.title}</div>
|
||||
<div className="mt-1 line-clamp-1 text-[10px] text-[var(--text-3)]">{item.verdict}</div>
|
||||
</div>
|
||||
<span className="signal-chip rounded px-1.5 py-0.5 text-[10px]">
|
||||
{item.kind === 'tool' ? '工具' : '文章'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex gap-2 text-[10px] text-[var(--text-3)]">
|
||||
<span className="truncate">{item.domain}</span>
|
||||
<span>{item.group_count} 群</span>
|
||||
<span>{item.count} 次</span>
|
||||
</div>
|
||||
{body}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PeopleRadarPanel({
|
||||
people,
|
||||
fallback,
|
||||
}: {
|
||||
people: DashboardPeopleRadar[];
|
||||
fallback: DashboardSignalSource[];
|
||||
}) {
|
||||
const displayPeople =
|
||||
people.length > 0
|
||||
? people
|
||||
: fallback.map((source) => ({
|
||||
sender: source.sender,
|
||||
role: '分享者' as const,
|
||||
score: source.signal_count,
|
||||
group_count: source.group_count,
|
||||
signal_count: source.signal_count,
|
||||
top_group: source.top_group,
|
||||
reason: `${source.signal_count} 条高信号`,
|
||||
}));
|
||||
return (
|
||||
<section className="card min-h-[300px] p-4">
|
||||
<PanelTitle
|
||||
icon={<UserRoundCheck size={14} className="text-[var(--accent)]" />}
|
||||
title="人物雷达"
|
||||
meta={`${displayPeople.length} 人`}
|
||||
/>
|
||||
{displayPeople.length === 0 ? (
|
||||
<EmptyState text="暂无稳定情报源" />
|
||||
) : (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
{displayPeople.slice(0, 7).map((person, index) => (
|
||||
<div key={`${person.sender}:${person.top_group}`} className="grid grid-cols-[22px_1fr_42px] items-center gap-2 rounded-md px-2 py-2 hover:bg-[var(--surface-2)]">
|
||||
<span className="rounded bg-[var(--surface-2)] py-0.5 text-center text-[10px] tabular-nums text-[var(--text-3)]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[12px] text-[var(--text)]">{person.sender}</div>
|
||||
<div className="truncate text-[10px] text-[var(--text-3)]">
|
||||
{person.role} · {person.reason}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[13px] font-semibold tabular-nums text-[var(--accent)]">{person.score}</div>
|
||||
<div className="text-[10px] text-[var(--text-3)]">{person.group_count} 群</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentIdeaPanel({ ideas }: { ideas: DashboardContentIdea[] }) {
|
||||
return (
|
||||
<section className="card min-h-[300px] p-4">
|
||||
<PanelTitle
|
||||
icon={<Lightbulb size={14} className="text-[var(--warn)]" />}
|
||||
title="内容选题"
|
||||
meta={`${ideas.length} 个`}
|
||||
/>
|
||||
{ideas.length === 0 ? (
|
||||
<EmptyState text="暂无可转化选题" />
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{ideas.slice(0, 6).map((idea) => (
|
||||
<div key={`${idea.suggested_channel}:${idea.title}`} className="rounded-md px-2 py-2 hover:bg-[var(--surface-2)]">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="line-clamp-2 text-[12px] font-medium leading-snug text-[var(--text)]">{idea.title}</div>
|
||||
<span className="rounded bg-[var(--surface-2)] px-1.5 py-0.5 text-[10px] text-[var(--text-2)]">
|
||||
{idea.suggested_channel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-1 text-[10px] text-[var(--text-3)]">{idea.angle}</div>
|
||||
<div className="mt-1 text-[10px] text-[var(--text-3)]">证据:{idea.evidence}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MustReadPanel({ date, items }: { date: string; items: DashboardSignalItem[] }) {
|
||||
return (
|
||||
<section className="card min-h-[300px] p-4">
|
||||
<PanelTitle
|
||||
icon={<Radar size={14} className="text-[var(--accent)]" />}
|
||||
title="最值得关注"
|
||||
meta={`${items.length} 条高信号`}
|
||||
/>
|
||||
{items.length === 0 ? (
|
||||
<EmptyState text="暂无高信号消息" />
|
||||
) : (
|
||||
<div className="mt-3 space-y-1">
|
||||
{items.slice(0, 5).map((item, index) => (
|
||||
<SignalRow key={`${item.chatroom_id}:${item.local_id}`} item={item} date={date} rank={index + 1} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SignalRow({
|
||||
item,
|
||||
date,
|
||||
rank,
|
||||
}: {
|
||||
item: DashboardSignalItem;
|
||||
date: string;
|
||||
rank: number;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/groups/${encodeURIComponent(item.chatroom_id)}?date=${date}`}
|
||||
className="grid grid-cols-[22px_1fr_16px] items-start gap-2 rounded-md px-2 py-2 transition-colors hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
<span className="rounded bg-[var(--surface-2)] py-0.5 text-center text-[10px] tabular-nums text-[var(--text-3)]">
|
||||
{rank}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="flex min-w-0 items-start justify-between gap-2">
|
||||
<span className="line-clamp-1 text-[12px] font-medium text-[var(--text)]">{item.title}</span>
|
||||
<span className="shrink-0 rounded bg-[var(--surface-2)] px-1.5 py-0.5 text-[10px] tabular-nums text-[var(--text-3)]">
|
||||
{item.score}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-1 flex min-w-0 items-center gap-1.5 text-[10px] text-[var(--text-3)]">
|
||||
<span className="truncate">
|
||||
{item.chat_name} · {item.sender}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">{item.time.slice(11)}</span>
|
||||
</span>
|
||||
<span className="mt-1 flex flex-wrap gap-1">
|
||||
{item.reasons.slice(0, 3).map((reason) => (
|
||||
<span
|
||||
key={reason}
|
||||
className="signal-chip rounded px-1.5 py-0.5 text-[10px]"
|
||||
title={reasonExplanation(reason)}
|
||||
>
|
||||
{reason}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
<ExternalLink size={12} className="mt-0.5 text-[var(--text-3)]" />
|
||||
<Link key={`${item.kind}:${item.title}`} href={item.href} className="block rounded-md px-2 py-2 hover:bg-[var(--surface-2)]">
|
||||
{body}
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-[var(--border-soft)] pt-3">
|
||||
<div className="text-[12px] font-medium text-[var(--text-2)]">高信号人物</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{people.slice(0, 4).map((person) => (
|
||||
<div key={`${person.sender}:${person.top_group}`} className="rounded-md px-2 py-1.5 hover:bg-[var(--surface-2)]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="truncate text-[12px] text-[var(--text)]">{person.sender}</div>
|
||||
<div className="text-[11px] tabular-nums text-[var(--accent)]">{person.score}</div>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[10px] text-[var(--text-3)]">
|
||||
{person.role} · {person.reason} · {date}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function mergeSignals(actions: DashboardActionItem[], mustRead: DashboardSignalItem[]) {
|
||||
const seen = new Set<string>();
|
||||
const out: Array<DashboardSignalItem | DashboardActionItem> = [];
|
||||
for (const item of [...actions.slice(0, 4), ...mustRead]) {
|
||||
const key = `${item.chatroom_id}:${item.local_id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildSummary(
|
||||
data: DashboardIntelligence,
|
||||
articles: DashboardLinkHighlight[],
|
||||
tools: DashboardLinkHighlight[],
|
||||
): string {
|
||||
const lines = [`${data.date || '今日'} 情报队列`];
|
||||
lines.push(`消息:${data.must_read.slice(0, 2).map((item) => item.title).join(';') || '暂无'}`);
|
||||
lines.push(`文章:${articles.slice(0, 2).map((item) => item.title).join(';') || '暂无'}`);
|
||||
lines.push(`工具:${tools.slice(0, 2).map((item) => item.title).join(';') || '暂无'}`);
|
||||
lines.push(`异动:${data.anomalies.slice(0, 2).map((item) => item.title).join(';') || '暂无'}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function PanelTitle({ icon, title, meta }: { icon: ReactNode; title: string; meta: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 text-[14px] font-semibold">
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--text-3)]">{meta}</div>
|
||||
<div className="shrink-0 text-[11px] text-[var(--text-3)]">{meta}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ text }: { text: string }) {
|
||||
function EmptyState({ text, compact }: { text: string; compact?: boolean }) {
|
||||
return (
|
||||
<div className="flex h-[220px] items-center justify-center text-[12px] text-[var(--text-3)]">
|
||||
<div className={`flex items-center justify-center text-[12px] text-[var(--text-3)] ${compact ? 'h-[96px]' : 'h-[220px]'}`}>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildDailySummary(data: DashboardIntelligence): string {
|
||||
const lines = [`${data.date || '今日'} 情报摘要`];
|
||||
lines.push(`出手:${data.action_items.slice(0, 3).map((item) => `${item.action}|${item.title}`).join(';') || '暂无'}`);
|
||||
lines.push(`趋势:${data.topic_lifecycle.slice(0, 3).map((topic) => `${topic.title}(${topicStatusText(topic.status)})`).join(';') || '暂无'}`);
|
||||
lines.push(
|
||||
`链接:${data.link_highlights.slice(0, 3).map((item) => item.title).join(';') || '暂无'}`,
|
||||
);
|
||||
lines.push(
|
||||
`异常:${data.anomalies.slice(0, 2).map((item) => item.title).join(';') || '暂无'}`,
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function urgencyText(urgency: DashboardActionItem['urgency']): string {
|
||||
if (urgency === 'high') return '高';
|
||||
if (urgency === 'medium') return '中';
|
||||
return '低';
|
||||
}
|
||||
|
||||
function urgencyClass(urgency: DashboardActionItem['urgency']): string {
|
||||
const base = 'shrink-0 rounded px-1.5 py-0.5 text-[10px]';
|
||||
if (urgency === 'high') return `${base} bg-[var(--warn-soft)] text-[var(--warn)]`;
|
||||
@@ -542,21 +547,6 @@ function urgencyClass(urgency: DashboardActionItem['urgency']): string {
|
||||
return `${base} bg-[var(--surface-2)] text-[var(--text-3)]`;
|
||||
}
|
||||
|
||||
function topicStatusText(status: DashboardTopicLifecycle['status']): string {
|
||||
if (status === 'spreading') return '扩散';
|
||||
if (status === 'rising') return '升温';
|
||||
if (status === 'cooling') return '退潮';
|
||||
return '高热';
|
||||
}
|
||||
|
||||
function topicStatusClass(status: DashboardTopicLifecycle['status']): string {
|
||||
const base = 'shrink-0 rounded px-1.5 py-0.5 text-[10px]';
|
||||
if (status === 'spreading') return `${base} bg-[var(--accent-soft)] text-[var(--accent)]`;
|
||||
if (status === 'rising') return `${base} bg-[var(--warn-soft)] text-[var(--warn)]`;
|
||||
if (status === 'cooling') return `${base} bg-[var(--surface-2)] text-[var(--text-3)]`;
|
||||
return `${base} bg-[var(--surface-2)] text-[var(--text-2)]`;
|
||||
}
|
||||
|
||||
function severityText(severity: DashboardAnomalySignal['severity']): string {
|
||||
if (severity === 'high') return '高';
|
||||
if (severity === 'medium') return '中';
|
||||
@@ -569,15 +559,3 @@ function severityClass(severity: DashboardAnomalySignal['severity']): string {
|
||||
if (severity === 'medium') return `${base} bg-[var(--accent-soft)] text-[var(--accent)]`;
|
||||
return `${base} bg-[var(--surface-2)] text-[var(--text-3)]`;
|
||||
}
|
||||
|
||||
function reasonExplanation(reason: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'机会/需求': '包含合作、采购、报名、求推荐或找资源等可行动信号',
|
||||
'工具/产品': '提到了工具、产品、模型、插件、项目或技术栈',
|
||||
链接信号: '包含可跳转链接,适合进入原文或资源查看',
|
||||
可跟进: '含有联系、报名、评估、试用、帮忙等行动线索',
|
||||
长观点: '消息长度较高,可能包含完整观点或经验复盘',
|
||||
问题: '包含明确问题,可能适合回复或继续追踪',
|
||||
};
|
||||
return map[reason] ?? reason;
|
||||
}
|
||||
|
||||
+5
-18
@@ -8,11 +8,10 @@ import {
|
||||
Folder,
|
||||
Inbox,
|
||||
LayoutDashboard,
|
||||
Activity,
|
||||
Sparkles,
|
||||
Link2,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
|
||||
type Category = {
|
||||
id: number;
|
||||
@@ -68,21 +67,16 @@ export default function Sidebar() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<aside className="flex h-screen w-[236px] shrink-0 flex-col border-r border-[var(--border-soft)] bg-[rgba(10,16,12,0.86)] backdrop-blur">
|
||||
<aside className="flex h-screen w-[236px] shrink-0 flex-col border-r border-[var(--border-soft)] bg-[var(--sidebar-bg)] backdrop-blur">
|
||||
<div className="border-b border-[var(--border-soft)] px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href="/" className="min-w-0">
|
||||
<div className="report-kicker">WeChat Radar</div>
|
||||
<div className="report-kicker">Qiaomu Radar</div>
|
||||
<div className="mt-1 text-[15px] font-semibold tracking-wide text-[var(--text)]">
|
||||
微信雷达
|
||||
微信群聊情报
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
className="rounded-md p-1 text-[var(--text-3)] transition-colors hover:bg-[var(--surface-2)] hover:text-[var(--text)]"
|
||||
aria-label="设置"
|
||||
>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-[var(--text-3)]">私有看板 · 高信号优先</div>
|
||||
</div>
|
||||
@@ -95,13 +89,6 @@ export default function Sidebar() {
|
||||
badge="Brief"
|
||||
active={pathname === '/'}
|
||||
/>
|
||||
<NavItem
|
||||
href="/signals"
|
||||
icon={<Activity size={15} />}
|
||||
label="信号流"
|
||||
badge="Live"
|
||||
active={pathname === '/signals'}
|
||||
/>
|
||||
<NavItem
|
||||
href="/topics"
|
||||
icon={<Sparkles size={15} />}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
const STORAGE_KEY = 'wechat-radar-theme-v1';
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
return localStorage.getItem(STORAGE_KEY) === 'dark' ? 'dark' : 'light';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggle = () => {
|
||||
const next = theme === 'dark' ? 'light' : 'dark';
|
||||
setTheme(next);
|
||||
};
|
||||
|
||||
const title = theme === 'dark' ? '切换到浅色主题' : '切换到深色主题';
|
||||
const Icon = theme === 'dark' ? Sun : Moon;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="control-surface inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--text-2)] transition-colors hover:border-[var(--accent)] hover:text-[var(--accent)]"
|
||||
onClick={toggle}
|
||||
aria-label={title}
|
||||
title={title}
|
||||
>
|
||||
<Icon size={15} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.body.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
for (const [key, value] of Object.entries(theme === 'dark' ? DARK_VARS : LIGHT_VARS)) {
|
||||
document.documentElement.style.setProperty(key, value);
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, theme);
|
||||
}
|
||||
|
||||
const LIGHT_VARS = {
|
||||
'--bg': '#f1efea',
|
||||
'--bg-2': '#e8e5de',
|
||||
'--surface': '#faf9f6',
|
||||
'--surface-2': '#f1eee8',
|
||||
'--surface-3': '#e5dfd6',
|
||||
'--border': '#d8d2c8',
|
||||
'--border-soft': 'rgba(46, 42, 34, 0.12)',
|
||||
'--text': '#171814',
|
||||
'--text-2': '#5e6459',
|
||||
'--text-3': '#8b8b80',
|
||||
'--accent': '#28745b',
|
||||
'--accent-2': '#3a9d71',
|
||||
'--accent-soft': 'rgba(40, 116, 91, 0.12)',
|
||||
'--warn': '#9c6728',
|
||||
'--warn-soft': 'rgba(156, 103, 40, 0.12)',
|
||||
'--danger': '#b34242',
|
||||
'--danger-soft': 'rgba(179, 66, 66, 0.12)',
|
||||
'--shadow': '0 18px 55px rgba(55, 47, 34, 0.09)',
|
||||
'--chrome-bg': 'rgba(250, 249, 246, 0.86)',
|
||||
'--sidebar-bg': 'rgba(248, 246, 239, 0.92)',
|
||||
'--control-bg': 'rgba(250, 249, 246, 0.78)',
|
||||
'--input-scheme': 'light',
|
||||
};
|
||||
|
||||
const DARK_VARS = {
|
||||
'--bg': '#080b0a',
|
||||
'--bg-2': '#0d1110',
|
||||
'--surface': '#111511',
|
||||
'--surface-2': '#171c17',
|
||||
'--surface-3': '#20261f',
|
||||
'--border': '#293029',
|
||||
'--border-soft': 'rgba(175, 190, 167, 0.13)',
|
||||
'--text': '#eef2eb',
|
||||
'--text-2': '#b6bdb1',
|
||||
'--text-3': '#80887d',
|
||||
'--accent': '#7dd3a8',
|
||||
'--accent-2': '#46b978',
|
||||
'--accent-soft': 'rgba(125, 211, 168, 0.13)',
|
||||
'--warn': '#d5a253',
|
||||
'--warn-soft': 'rgba(213, 162, 83, 0.14)',
|
||||
'--danger': '#df6b6b',
|
||||
'--danger-soft': 'rgba(223, 107, 107, 0.14)',
|
||||
'--shadow': '0 18px 48px rgba(0, 0, 0, 0.34)',
|
||||
'--chrome-bg': 'rgba(8, 11, 10, 0.84)',
|
||||
'--sidebar-bg': 'rgba(8, 11, 10, 0.9)',
|
||||
'--control-bg': 'rgba(17, 22, 18, 0.86)',
|
||||
'--input-scheme': 'dark',
|
||||
};
|
||||
+9
-25
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { Calendar, RefreshCw, Database } from 'lucide-react';
|
||||
import GlobalSearch from './GlobalSearch';
|
||||
|
||||
export type RangeKey = 'day' | 'week' | 'month' | 'quarter' | 'year' | 'custom';
|
||||
export type RefreshMode = 'auto' | 'hour' | 'day' | 'week';
|
||||
|
||||
const RANGES: { key: RangeKey; label: string }[] = [
|
||||
{ key: 'day', label: '日' },
|
||||
@@ -11,14 +11,6 @@ const RANGES: { key: RangeKey; label: string }[] = [
|
||||
{ key: 'month', label: '月' },
|
||||
{ key: 'quarter', label: '季' },
|
||||
{ key: 'year', label: '年' },
|
||||
{ key: 'custom', label: '自定义' },
|
||||
];
|
||||
|
||||
const MODES: { key: RefreshMode; label: string }[] = [
|
||||
{ key: 'auto', label: '自动' },
|
||||
{ key: 'hour', label: '时' },
|
||||
{ key: 'day', label: '日' },
|
||||
{ key: 'week', label: '周' },
|
||||
];
|
||||
|
||||
export default function TopBar({
|
||||
@@ -26,8 +18,6 @@ export default function TopBar({
|
||||
date,
|
||||
onRangeChange,
|
||||
onDateChange,
|
||||
mode,
|
||||
onModeChange,
|
||||
rescanning,
|
||||
onRescan,
|
||||
onFullSync,
|
||||
@@ -37,36 +27,38 @@ export default function TopBar({
|
||||
date: string;
|
||||
onRangeChange: (r: RangeKey) => void;
|
||||
onDateChange: (date: string) => void;
|
||||
mode: RefreshMode;
|
||||
onModeChange: (m: RefreshMode) => void;
|
||||
rescanning: boolean;
|
||||
onRescan: () => void;
|
||||
onFullSync?: () => void;
|
||||
rescanInfo?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-[var(--border-soft)] bg-[rgba(8,13,10,0.74)] px-6 py-3 backdrop-blur">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-[var(--border-soft)] bg-[var(--chrome-bg)] px-6 py-3 backdrop-blur">
|
||||
<div>
|
||||
<div className="report-kicker">Daily Intelligence</div>
|
||||
<div className="mt-1 text-[16px] font-semibold tracking-wide">微信雷达 · 情报看板</div>
|
||||
<div className="mt-1 text-[16px] font-semibold tracking-wide">驾驶舱 · 情报看板</div>
|
||||
<div className="mt-0.5 text-[11px] text-[var(--text-3)]">
|
||||
{rescanInfo ?? '尚未扫描,点击「重扫」加载数据'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<GlobalSearch />
|
||||
<div className="control-surface flex items-center gap-1.5 rounded-md px-2.5 py-1.5">
|
||||
<Calendar size={13} className="text-[var(--text-3)]" />
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => onDateChange(e.target.value)}
|
||||
className="bg-transparent text-[12px] outline-none [color-scheme:dark]"
|
||||
title="按日期查看微信雷达"
|
||||
className="theme-date-input min-w-[128px] bg-transparent text-[12px] outline-none"
|
||||
title="按日期查看驾驶舱"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SegGroup>
|
||||
<span className="border-r border-[var(--border-soft)] px-2 py-1 text-[11px] text-[var(--text-3)]">
|
||||
范围
|
||||
</span>
|
||||
{RANGES.map((r) => (
|
||||
<SegBtn key={r.key} active={range === r.key} onClick={() => onRangeChange(r.key)}>
|
||||
{r.label}
|
||||
@@ -74,14 +66,6 @@ export default function TopBar({
|
||||
))}
|
||||
</SegGroup>
|
||||
|
||||
<SegGroup>
|
||||
{MODES.map((m) => (
|
||||
<SegBtn key={m.key} active={mode === m.key} onClick={() => onModeChange(m.key)}>
|
||||
{m.label}
|
||||
</SegBtn>
|
||||
))}
|
||||
</SegGroup>
|
||||
|
||||
{onFullSync && (
|
||||
<button
|
||||
className="btn"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<svg width="1440" height="860" viewBox="0 0 1440 860" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1440" height="860" fill="#f5f1e8"/>
|
||||
<rect x="24" y="24" width="1392" height="812" rx="28" fill="#fbfaf6" stroke="#dfd7c8"/>
|
||||
<rect x="24" y="24" width="176" height="812" rx="28" fill="#f1eadf"/>
|
||||
<text x="48" y="82" fill="#26735b" font-family="Inter, Arial, sans-serif" font-size="12" font-weight="700" letter-spacing="2">WECHAT RADAR</text>
|
||||
<text x="48" y="112" fill="#24322d" font-family="Inter, Arial, sans-serif" font-size="20" font-weight="800">微信群聊情报</text>
|
||||
<text x="48" y="137" fill="#7d8078" font-family="Inter, Arial, sans-serif" font-size="13">本地优先 · 高信号聚合</text>
|
||||
<rect x="40" y="190" width="144" height="36" rx="8" fill="#dfeade"/>
|
||||
<text x="62" y="213" fill="#255947" font-family="Inter, Arial, sans-serif" font-size="14" font-weight="700">看板 Brief</text>
|
||||
<text x="62" y="260" fill="#59655f" font-family="Inter, Arial, sans-serif" font-size="14">话题雷达</text>
|
||||
<text x="62" y="304" fill="#59655f" font-family="Inter, Arial, sans-serif" font-size="14">链接情报</text>
|
||||
<text x="48" y="374" fill="#9b9c95" font-family="Inter, Arial, sans-serif" font-size="11" font-weight="700" letter-spacing="1.6">COLLECTIONS</text>
|
||||
<circle cx="52" cy="414" r="4" fill="#ef4444"/><text x="66" y="419" fill="#59655f" font-family="Inter, Arial, sans-serif" font-size="13">AI 产品群</text>
|
||||
<circle cx="52" cy="446" r="4" fill="#10b981"/><text x="66" y="451" fill="#59655f" font-family="Inter, Arial, sans-serif" font-size="13">Coding 群</text>
|
||||
<circle cx="52" cy="478" r="4" fill="#06b6d4"/><text x="66" y="483" fill="#59655f" font-family="Inter, Arial, sans-serif" font-size="13">工具分享</text>
|
||||
<text x="236" y="82" fill="#26735b" font-family="Inter, Arial, sans-serif" font-size="12" font-weight="800" letter-spacing="2">DAILY INTELLIGENCE</text>
|
||||
<text x="236" y="116" fill="#22312b" font-family="Inter, Arial, sans-serif" font-size="28" font-weight="850">驾驶舱 · 情报看板</text>
|
||||
<text x="236" y="144" fill="#7a8079" font-family="Inter, Arial, sans-serif" font-size="14">今天的微信群,只看值得处理的信号。</text>
|
||||
<rect x="870" y="72" width="230" height="40" rx="8" fill="#ffffff" stroke="#e3dacb"/>
|
||||
<text x="900" y="98" fill="#4b554f" font-family="Inter, Arial, sans-serif" font-size="14">搜索群、话题、人、关键词</text>
|
||||
<rect x="1118" y="72" width="132" height="40" rx="8" fill="#ffffff" stroke="#e3dacb"/>
|
||||
<text x="1151" y="98" fill="#4b554f" font-family="Inter, Arial, sans-serif" font-size="14">2026/05/25</text>
|
||||
<rect x="1266" y="72" width="98" height="40" rx="8" fill="#2fbf78"/>
|
||||
<text x="1290" y="98" fill="#ffffff" font-family="Inter, Arial, sans-serif" font-size="14" font-weight="700">重扫</text>
|
||||
<g>
|
||||
<rect x="236" y="178" width="250" height="96" rx="12" fill="#ffffff" stroke="#e1dacd"/>
|
||||
<text x="258" y="212" fill="#26735b" font-family="Inter, Arial, sans-serif" font-size="13" font-weight="700">活跃群</text>
|
||||
<text x="258" y="252" fill="#23312b" font-family="Inter, Arial, sans-serif" font-size="34" font-weight="850">153</text>
|
||||
<rect x="506" y="178" width="250" height="96" rx="12" fill="#ffffff" stroke="#e1dacd"/>
|
||||
<text x="528" y="212" fill="#26735b" font-family="Inter, Arial, sans-serif" font-size="13" font-weight="700">总消息</text>
|
||||
<text x="528" y="252" fill="#23312b" font-family="Inter, Arial, sans-serif" font-size="34" font-weight="850">178,580</text>
|
||||
<rect x="776" y="178" width="250" height="96" rx="12" fill="#ffffff" stroke="#e1dacd"/>
|
||||
<text x="798" y="212" fill="#a36c21" font-family="Inter, Arial, sans-serif" font-size="13" font-weight="700">@ 我的</text>
|
||||
<text x="798" y="252" fill="#a36c21" font-family="Inter, Arial, sans-serif" font-size="34" font-weight="850">117</text>
|
||||
<rect x="1046" y="178" width="250" height="96" rx="12" fill="#ffffff" stroke="#e1dacd"/>
|
||||
<text x="1068" y="212" fill="#66716b" font-family="Inter, Arial, sans-serif" font-size="13" font-weight="700">静默群</text>
|
||||
<text x="1068" y="252" fill="#23312b" font-family="Inter, Arial, sans-serif" font-size="34" font-weight="850">22</text>
|
||||
</g>
|
||||
<rect x="236" y="304" width="1060" height="104" rx="12" fill="#ffffff" stroke="#e1dacd"/>
|
||||
<text x="260" y="338" fill="#26735b" font-family="Inter, Arial, sans-serif" font-size="13" font-weight="800" letter-spacing="1">TODAY QUEUE</text>
|
||||
<text x="260" y="366" fill="#22312b" font-family="Inter, Arial, sans-serif" font-size="18" font-weight="800">今日先看这些</text>
|
||||
<text x="260" y="390" fill="#6f756f" font-family="Inter, Arial, sans-serif" font-size="14">消息 · 文章 · 工具 · 异动,自动聚合成可点击清单。</text>
|
||||
<rect x="236" y="438" width="510" height="330" rx="12" fill="#ffffff" stroke="#e1dacd"/>
|
||||
<text x="260" y="474" fill="#22312b" font-family="Inter, Arial, sans-serif" font-size="18" font-weight="800">关键话题与消息</text>
|
||||
<g fill="#f5f1e8">
|
||||
<rect x="260" y="502" width="462" height="46" rx="7"/>
|
||||
<rect x="260" y="564" width="462" height="46" rx="7"/>
|
||||
<rect x="260" y="626" width="462" height="46" rx="7"/>
|
||||
<rect x="260" y="688" width="462" height="46" rx="7"/>
|
||||
</g>
|
||||
<text x="282" y="530" fill="#29352f" font-family="Inter, Arial, sans-serif" font-size="14" font-weight="700">企业 AI 原生重构</text>
|
||||
<text x="282" y="592" fill="#29352f" font-family="Inter, Arial, sans-serif" font-size="14" font-weight="700">Codex 智能体工作流</text>
|
||||
<text x="282" y="654" fill="#29352f" font-family="Inter, Arial, sans-serif" font-size="14" font-weight="700">华为特斯拉智驾争论</text>
|
||||
<text x="282" y="716" fill="#29352f" font-family="Inter, Arial, sans-serif" font-size="14" font-weight="700">群知识库沉淀整理</text>
|
||||
<rect x="770" y="438" width="526" height="330" rx="12" fill="#ffffff" stroke="#e1dacd"/>
|
||||
<text x="794" y="474" fill="#22312b" font-family="Inter, Arial, sans-serif" font-size="18" font-weight="800">链接情报</text>
|
||||
<line x1="794" y1="512" x2="1264" y2="512" stroke="#eadfce"/>
|
||||
<text x="794" y="548" fill="#26735b" font-family="Inter, Arial, sans-serif" font-size="13" font-weight="800">文章 / 内容</text>
|
||||
<text x="794" y="578" fill="#29352f" font-family="Inter, Arial, sans-serif" font-size="14" font-weight="700">Agent 落地为什么卡在组织流程</text>
|
||||
<text x="794" y="604" fill="#7b8079" font-family="Inter, Arial, sans-serif" font-size="12">mp.weixin.qq.com · 4 群 · 9 次</text>
|
||||
<text x="794" y="652" fill="#26735b" font-family="Inter, Arial, sans-serif" font-size="13" font-weight="800">工具 / 资源</text>
|
||||
<text x="794" y="682" fill="#29352f" font-family="Inter, Arial, sans-serif" font-size="14" font-weight="700">开源多 Agent 工作流项目</text>
|
||||
<text x="794" y="708" fill="#7b8079" font-family="Inter, Arial, sans-serif" font-size="12">github.com · 3 群 · 6 次</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.8 KiB |
@@ -12,7 +12,6 @@ const eslintConfig = defineConfig([
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
"scripts/*.cjs",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface DashboardLinkHighlight {
|
||||
title: string;
|
||||
url: string;
|
||||
domain: string;
|
||||
source: string;
|
||||
score: number;
|
||||
verdict: string;
|
||||
count: number;
|
||||
@@ -149,7 +150,7 @@ export function buildDashboardIntelligence(
|
||||
groupNames = new Map<string, string>(),
|
||||
): DashboardIntelligence {
|
||||
date = resolveIntelligenceDate(date);
|
||||
const key = `dashboard-intelligence:${date}:v11`;
|
||||
const key = `dashboard-intelligence:${date}:v14`;
|
||||
const cached = cache.get(key) as DashboardIntelligence | undefined;
|
||||
if (cached) return cached;
|
||||
|
||||
@@ -174,6 +175,37 @@ export function buildDashboardIntelligence(
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(minusDays(date, 7), date, 9000) as MessageSignalRow[];
|
||||
const linkRows = db()
|
||||
.prepare(
|
||||
`SELECT
|
||||
ml.url,
|
||||
ml.canonical_url,
|
||||
ml.title,
|
||||
ml.domain,
|
||||
ml.source,
|
||||
ml.confidence,
|
||||
ml.time,
|
||||
ml.chatroom_id,
|
||||
m.content
|
||||
FROM message_links ml
|
||||
JOIN messages m
|
||||
ON m.chatroom_id = ml.chatroom_id
|
||||
AND m.local_id = ml.local_id
|
||||
WHERE ml.date = ?
|
||||
ORDER BY ml.timestamp DESC
|
||||
LIMIT 600`,
|
||||
)
|
||||
.all(date) as Array<{
|
||||
url: string;
|
||||
canonical_url: string;
|
||||
title: string | null;
|
||||
domain: string;
|
||||
source: string;
|
||||
confidence: number;
|
||||
time: string;
|
||||
chatroom_id: string;
|
||||
content: string;
|
||||
}>;
|
||||
|
||||
const candidates: DashboardSignalItem[] = [];
|
||||
const opportunities: DashboardOpportunityItem[] = [];
|
||||
@@ -203,6 +235,7 @@ export function buildDashboardIntelligence(
|
||||
groups: Set<string>;
|
||||
last_seen: string;
|
||||
snippets: string[];
|
||||
sources: Set<string>;
|
||||
}
|
||||
>();
|
||||
|
||||
@@ -261,29 +294,31 @@ export function buildDashboardIntelligence(
|
||||
if (TOOL_RE.test(clean)) source.tool_count++;
|
||||
sourceMap.set(sourceKey, source);
|
||||
|
||||
for (const url of extractUrls(row.content)) {
|
||||
const domain = domainOf(url);
|
||||
if (!domain) continue;
|
||||
const kind = isArticleUrl(url) ? 'article' : isToolUrl(url, clean) ? 'tool' : null;
|
||||
}
|
||||
|
||||
for (const row of linkRows) {
|
||||
const kind = isArticleUrl(row.canonical_url) ? 'article' : isToolUrl(row.canonical_url, row.content) ? 'tool' : null;
|
||||
if (!kind) continue;
|
||||
const key = normalizeUrlKey(url);
|
||||
const key = normalizeUrlKey(row.canonical_url);
|
||||
const clean = cleanContent(row.content);
|
||||
const bucket = linkBuckets.get(key) ?? {
|
||||
kind,
|
||||
title: titleFromLinkContext(row.content, url),
|
||||
url,
|
||||
domain,
|
||||
title: (row.title || titleFromLinkContext(row.content, row.url)).slice(0, 80),
|
||||
url: row.url,
|
||||
domain: row.domain || domainOf(row.canonical_url),
|
||||
count: 0,
|
||||
groups: new Set<string>(),
|
||||
last_seen: row.time,
|
||||
snippets: [],
|
||||
sources: new Set<string>(),
|
||||
};
|
||||
bucket.count++;
|
||||
bucket.groups.add(row.chatroom_id);
|
||||
bucket.sources.add(row.source);
|
||||
bucket.last_seen = bucket.last_seen > row.time ? bucket.last_seen : row.time;
|
||||
if (clean) bucket.snippets.push(clean.slice(0, 80));
|
||||
linkBuckets.set(key, bucket);
|
||||
}
|
||||
}
|
||||
|
||||
const mustRead = candidates
|
||||
.sort((a, b) => b.score - a.score || b.time.localeCompare(a.time))
|
||||
@@ -430,6 +465,7 @@ function buildLinkHighlights(
|
||||
groups: Set<string>;
|
||||
last_seen: string;
|
||||
snippets: string[];
|
||||
sources: Set<string>;
|
||||
}
|
||||
>,
|
||||
): DashboardLinkHighlight[] {
|
||||
@@ -441,6 +477,7 @@ function buildLinkHighlights(
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
domain: item.domain,
|
||||
source: preferredLinkSource(item.sources),
|
||||
score,
|
||||
verdict: verdictForLink(item.kind, item.count, item.groups.size, item.snippets.join(' ')),
|
||||
count: item.count,
|
||||
@@ -452,6 +489,13 @@ function buildLinkHighlights(
|
||||
.slice(0, MAX_LINK_HIGHLIGHTS);
|
||||
}
|
||||
|
||||
function preferredLinkSource(sources: Set<string>): string {
|
||||
if (sources.has('wechat_raw')) return 'wechat_raw';
|
||||
if (sources.has('public_search')) return 'public_search';
|
||||
if (sources.has('manual')) return 'manual';
|
||||
return Array.from(sources)[0] ?? 'plain_url';
|
||||
}
|
||||
|
||||
function buildPeopleRadar(
|
||||
sourceMap: Map<
|
||||
string,
|
||||
@@ -602,14 +646,6 @@ function minusDays(date: string, days: number): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function extractUrls(content: string): string[] {
|
||||
const decoded = decodeHtml(content);
|
||||
return Array.from(decoded.matchAll(URL_GLOBAL_RE))
|
||||
.map((m) => cleanUrl(m[0]))
|
||||
.filter(Boolean)
|
||||
.slice(0, 6);
|
||||
}
|
||||
|
||||
function cleanUrl(raw: string): string {
|
||||
return raw
|
||||
.replace(/[),,。;;!?!?、\]}>]+$/g, '')
|
||||
@@ -645,7 +681,7 @@ function isArticleUrl(raw: string): boolean {
|
||||
if (host === 'mp.weixin.qq.com') return true;
|
||||
if ((host === 'x.com' || host === 'twitter.com') && /\/status\/\d{12,}/.test(u.pathname)) return true;
|
||||
if (host === 'youtube.com' || host === 'youtu.be') return true;
|
||||
return /zhihu|toutiao|sohu|163\.com|qq\.com|medium\.com|substack\.com|juejin\.cn/i.test(host);
|
||||
return /zhihu|toutiao|sohu|163\.com|qq\.com|medium\.com|substack\.com|juejin\.cn|podcasts\.apple\.com|podscan\.fm/i.test(host);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,34 @@ function migrate(d: Database.Database) {
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_links (
|
||||
chatroom_id TEXT NOT NULL,
|
||||
local_id INTEGER NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
sender TEXT NOT NULL,
|
||||
time TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
canonical_url TEXT NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
domain TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
raw_kind TEXT NOT NULL,
|
||||
confidence REAL NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (chatroom_id, local_id, canonical_url)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_message_links_date
|
||||
ON message_links(date, timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_links_canonical
|
||||
ON message_links(canonical_url);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_links_domain
|
||||
ON message_links(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_links_source
|
||||
ON message_links(source);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS topics (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
@@ -115,6 +143,9 @@ function migrate(d: Database.Database) {
|
||||
PRIMARY KEY (date, version)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_link_intelligence_cache_generated
|
||||
ON link_intelligence_cache(generated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_state (
|
||||
chatroom_id TEXT PRIMARY KEY,
|
||||
last_synced_at INTEGER NOT NULL,
|
||||
@@ -136,30 +167,50 @@ function migrate(d: Database.Database) {
|
||||
ensureColumn(d, 'sync_state', 'total_chunks', 'INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
|
||||
function ensureColumn(d: Database.Database, table: string, name: string, definition: string) {
|
||||
function ensureColumn(
|
||||
d: Database.Database,
|
||||
table: string,
|
||||
name: string,
|
||||
definition: string,
|
||||
) {
|
||||
const rows = d.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||
if (rows.some((r) => r.name === name)) return;
|
||||
d.prepare(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`).run();
|
||||
}
|
||||
|
||||
const SEED_VERSION = 'wechat_radar_v1_2026_05_24';
|
||||
const SEED_VERSION = 'qiaomu_v2_2026_05_23';
|
||||
|
||||
const DEFAULT_GROUPS: Array<{ name: string; color: string; emoji: string }> = [
|
||||
{ name: 'AI / Coding', color: '#7dd3a8', emoji: '💻' },
|
||||
{ name: 'Tools', color: '#f59e0b', emoji: '🛠️' },
|
||||
{ name: 'Articles', color: '#06b6d4', emoji: '📚' },
|
||||
{ name: 'Business', color: '#10b981', emoji: '💼' },
|
||||
{ name: 'Events', color: '#f97316', emoji: '📅' },
|
||||
{ name: 'Research', color: '#a855f7', emoji: '🔬' },
|
||||
{ name: 'Lifestyle', color: '#fb7185', emoji: '🏠' },
|
||||
{ name: 'AI产品蝗虫团', color: '#ef4444', emoji: '🐝' },
|
||||
{ name: '自营/读者群', color: '#22c55e', emoji: '🌟' },
|
||||
{ name: 'WaytoAGI', color: '#06b6d4', emoji: '🛸' },
|
||||
{ name: 'HowOneAI', color: '#0ea5e9', emoji: '🚀' },
|
||||
{ name: 'Vibe Coding · 编程', color: '#6366f1', emoji: '💻' },
|
||||
{ name: 'AIGC · 内容创作', color: '#ec4899', emoji: '🎨' },
|
||||
{ name: 'AI 学术', color: '#a855f7', emoji: '🎓' },
|
||||
{ name: 'AI 商业 · 营销', color: '#10b981', emoji: '💰' },
|
||||
{ name: 'AI 工具用户群', color: '#f59e0b', emoji: '🛠️' },
|
||||
{ name: '付费社区', color: '#eab308', emoji: '💎' },
|
||||
{ name: 'AI 圈社交', color: '#8b5cf6', emoji: '🤖' },
|
||||
{ name: '大佬 · 媒体圈', color: '#f97316', emoji: '📰' },
|
||||
{ name: '行业活动', color: '#22d3ee', emoji: '🎯' },
|
||||
{ name: '生活 · 兴趣', color: '#fb7185', emoji: '🏘️' },
|
||||
];
|
||||
|
||||
function seed(d: Database.Database) {
|
||||
const meta = d.prepare("SELECT value FROM meta WHERE key = 'seed_version'").get() as { value: string } | undefined;
|
||||
const meta = d
|
||||
.prepare("SELECT value FROM meta WHERE key = 'seed_version'")
|
||||
.get() as { value: string } | undefined;
|
||||
|
||||
if (meta?.value === SEED_VERSION) return;
|
||||
|
||||
// Check if any groups have user tags — if so, leave them alone (additive seed).
|
||||
const tagged = d.prepare('SELECT COUNT(*) AS n FROM group_tags').get() as { n: number };
|
||||
if (tagged.n === 0) d.prepare('DELETE FROM groups').run();
|
||||
|
||||
if (tagged.n === 0) {
|
||||
// Safe to wipe and re-seed.
|
||||
d.prepare('DELETE FROM groups').run();
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const insertOrIgnore = d.prepare(
|
||||
@@ -169,5 +220,7 @@ function seed(d: Database.Database) {
|
||||
DEFAULT_GROUPS.forEach((g, i) => insertOrIgnore.run(g.name, g.color, g.emoji, i, now));
|
||||
})();
|
||||
|
||||
d.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('seed_version', ?)").run(SEED_VERSION);
|
||||
d.prepare(
|
||||
"INSERT OR REPLACE INTO meta (key, value) VALUES ('seed_version', ?)",
|
||||
).run(SEED_VERSION);
|
||||
}
|
||||
|
||||
+70
-22
@@ -2,35 +2,83 @@ import type { GroupRow } from './groups';
|
||||
|
||||
export function classifyGroupHeuristic(name: string, summary: string, groups: GroupRow[]) {
|
||||
const text = `${name} ${summary}`.toLowerCase();
|
||||
const lookup = (target: string) => groups.find((g) => g.name.toLowerCase().includes(target.toLowerCase()));
|
||||
const lookup = (target: string) => groups.find((g) => g.name.includes(target));
|
||||
|
||||
if (/vibe.?coding|coding|代码|编程|developer|dev|cli|mcp|skills?|github|开源|agent|gpt|claude|llm/i.test(text)) {
|
||||
const t = lookup('AI / Coding');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: 'AI / Coding keywords' };
|
||||
if (/蝗虫团|huangchong/i.test(name)) {
|
||||
const t = lookup('蝗虫');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '蝗虫团系列' };
|
||||
}
|
||||
if (/工具|产品|插件|内测|api|chrome|notion|obsidian|飞书|workflow|workspace|效率/i.test(text)) {
|
||||
const t = lookup('Tools');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: 'Tools / product keywords' };
|
||||
if (/自营|公众号读者|读者群|用户群|粉丝群/i.test(name)) {
|
||||
const t = lookup('自营/读者群');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '自营 / 读者群' };
|
||||
}
|
||||
if (/文章|公众号|日报|newsletter|读者|知识库|教程|报告|访谈|paper|论文/i.test(text)) {
|
||||
const t = lookup('Articles');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: 'Articles / knowledge keywords' };
|
||||
if (/waytoagi|通往agi|通往ai|通往 ai/i.test(name)) {
|
||||
const t = lookup('WaytoAGI');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: 'WaytoAGI 系列' };
|
||||
}
|
||||
if (/商业|营销|增长|seo|geo|销售|客户|采购|团购|合作|商务|创业|投资/i.test(text)) {
|
||||
const t = lookup('Business');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: 'Business keywords' };
|
||||
if (/howoneai|howone/i.test(name)) {
|
||||
const t = lookup('HowOneAI');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: 'HowOneAI 系列' };
|
||||
}
|
||||
if (/活动|报名|直播|大会|线下|分享会|训练营|课程|会议|meetup|workshop/i.test(text)) {
|
||||
const t = lookup('Events');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: 'Event keywords' };
|
||||
if (
|
||||
/vibe.?coding|vibecoding|vibe first|cherry studio|cli|claude.?skills|clawdbot|codepilot|mcp|cola|geoflow|refly|camel|eigent|thinkinai|skills|all in cli/i.test(
|
||||
text,
|
||||
)
|
||||
) {
|
||||
const t = lookup('Vibe Coding');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '编程 / Skills / CLI' };
|
||||
}
|
||||
if (/研究|学术|论文|paper|模型|实验|benchmark|评测/i.test(text)) {
|
||||
const t = lookup('Research');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: 'Research keywords' };
|
||||
if (/学术|论文|paper|未来硅世界|研究室|nixy|simonlin|博文视点|knowledge|灵枭/i.test(text)) {
|
||||
const t = lookup('AI 学术');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '学术 / 论文' };
|
||||
}
|
||||
if (/生活|阅读|运动|小区|邻里|钓鱼|健身|跑步|英语|校友|投资主题/i.test(text)) {
|
||||
const t = lookup('Lifestyle');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: 'Lifestyle keywords' };
|
||||
if (/seo|geo|商业化|营销|kol|gaidn|adg|vip|生财|appsail|tutti|商业|broker|出版|收付款|社交新品|社群/i.test(text)) {
|
||||
const t = lookup('AI 商业');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '商业 / 营销 / KOL' };
|
||||
}
|
||||
if (/aigc|图|视频|音乐|spy|拍我ai|ai媒体|ai音视频|创意|graceful|创作|graphic|listenhub|notetomp|youmind|短视频|video|music|illustrat/i.test(text)) {
|
||||
const t = lookup('AIGC');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: 'AIGC / 内容创作' };
|
||||
}
|
||||
if (/vip|烟花|修饼|传术师|生财有术|兔子ai|ai领导力|早鸟|内测|种子用户|订阅用户|私董|学员|api 渠道|一人公司|训练营|课程/i.test(text)) {
|
||||
const t = lookup('付费社区');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '付费 / 内测 / VIP' };
|
||||
}
|
||||
if (/用户群|用户中文|内测群|jackywine|mindcode|remio|cherry|camel|refly|cola|geoflow|hosi|aigocode|appsail|tutti|api/i.test(text)) {
|
||||
const t = lookup('AI 工具用户群');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '工具用户群' };
|
||||
}
|
||||
if (/神的孩子|明人明言|先行者|agi bar|智能体成精|life hacker|超级玩家|未来趋势|agent橘|新物种|创造营|不息为体|未知书社|新互联网/i.test(text)) {
|
||||
const t = lookup('AI 圈社交');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: 'AI 圈社交' };
|
||||
}
|
||||
if (/donews|何夕|辛亥|对接群|百度世界|央馆|火山方舟|43talks|tgo|商务|媒体/i.test(text)) {
|
||||
const t = lookup('大佬');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '大佬 / 媒体圈' };
|
||||
}
|
||||
if (/活动现场|聚餐|筹备组|聚会|开播|直播|线下|大会|分享会|黑客松|日历/i.test(text)) {
|
||||
const t = lookup('行业活动');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '一次性活动群' };
|
||||
}
|
||||
if (/钓友|路亚|果粉|大家庭|班级|邻里|小区|羽毛球|健身|跑步|徒步|阅读|共读|英语|校友|歌友|篮球/i.test(text)) {
|
||||
const t = lookup('生活');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '生活 / 兴趣' };
|
||||
}
|
||||
if (/粉丝|fans|读者/i.test(text)) {
|
||||
const t = lookup('AI 圈社交');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '粉丝团 / 读者群' };
|
||||
}
|
||||
if (/财经|股票|投资|基金|币圈|crypto|trade/i.test(text)) {
|
||||
const t = lookup('AI 商业');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '财经 / 投资' };
|
||||
}
|
||||
if (/x boost|twitter|推特|x kol/i.test(text)) {
|
||||
const t = lookup('AIGC');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: 'X / 推特运营' };
|
||||
}
|
||||
if (/ai|agent|gpt|claude|llm|coding|开源/i.test(text)) {
|
||||
const t = lookup('AI 圈社交');
|
||||
if (t) return { group_id: t.id, group_name: t.name, reason: '通用 AI(兜底)' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
+37
-54
@@ -13,7 +13,7 @@ const TITLE_FETCH_TIMEOUT_MS = 1400;
|
||||
const MAX_TITLE_GENERATION_ITEMS = 80;
|
||||
const CODEX_TIMEOUT_MS = Number(process.env.WECHAT_RADAR_LINK_CODEX_TIMEOUT_MS ?? 180_000);
|
||||
const CODEX_MODEL = process.env.WECHAT_RADAR_CODEX_MODEL;
|
||||
const LINK_INTELLIGENCE_CACHE_VERSION = 'v6';
|
||||
const LINK_INTELLIGENCE_CACHE_VERSION = 'v8';
|
||||
const LINK_INTELLIGENCE_CACHE_TTL_SECONDS = 60 * 60 * 24;
|
||||
|
||||
const TOOL_HINT_RE =
|
||||
@@ -27,6 +27,10 @@ const ARTICLE_HOSTS = [
|
||||
'page.om.qq.com',
|
||||
'www.163.com',
|
||||
'mparticle.uc.cn',
|
||||
'podcasts.apple.com',
|
||||
'open.spotify.com',
|
||||
'podcasters.spotify.com',
|
||||
'podscan.fm',
|
||||
];
|
||||
|
||||
const TOOL_HOST_HINTS = [
|
||||
@@ -61,6 +65,12 @@ interface MessageLinkRow {
|
||||
time: string;
|
||||
timestamp: number;
|
||||
type: string;
|
||||
url: string;
|
||||
canonical_url: string;
|
||||
link_title: string | null;
|
||||
domain: string;
|
||||
source: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface LinkIntelligenceItem {
|
||||
@@ -80,6 +90,7 @@ export interface LinkIntelligenceItem {
|
||||
time: string;
|
||||
local_id: number;
|
||||
snippet: string;
|
||||
source: string;
|
||||
}>;
|
||||
dedupe_key?: string;
|
||||
}
|
||||
@@ -134,46 +145,6 @@ function decodeHtmlEntities(s: string): string {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function cleanUrl(raw: string): string {
|
||||
return decodeHtmlEntities(raw)
|
||||
.replace(/[),,。;;!?!?、\]}>]+$/g, '')
|
||||
.replace(/\.{3,}$/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeUrl(raw: string): string | null {
|
||||
if (raw.includes('...') || raw.includes('…')) return null;
|
||||
try {
|
||||
const u = new URL(cleanUrl(raw));
|
||||
u.hash = '';
|
||||
for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']) {
|
||||
u.searchParams.delete(key);
|
||||
}
|
||||
return u.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractUrls(content: string): string[] {
|
||||
const decoded = decodeHtmlEntities(content);
|
||||
const urls = new Set<string>();
|
||||
|
||||
for (const m of decoded.matchAll(/imgsourceurl="([^"]+)"/g)) {
|
||||
try {
|
||||
urls.add(decodeURIComponent(m[1]));
|
||||
} catch {
|
||||
urls.add(m[1]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const m of decoded.matchAll(/https?:\/\/[^\s<>"']+/g)) {
|
||||
urls.add(cleanUrl(m[0]));
|
||||
}
|
||||
|
||||
return Array.from(urls).filter(Boolean);
|
||||
}
|
||||
|
||||
function domainOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
@@ -532,11 +503,26 @@ export async function getDailyLinkIntelligence(
|
||||
|
||||
const rows = db()
|
||||
.prepare(
|
||||
`SELECT chatroom_id, local_id, sender, content, time, timestamp, type
|
||||
FROM messages
|
||||
WHERE date = ?
|
||||
AND content LIKE '%http%'
|
||||
ORDER BY timestamp DESC
|
||||
`SELECT
|
||||
m.chatroom_id,
|
||||
m.local_id,
|
||||
m.sender,
|
||||
m.content,
|
||||
m.time,
|
||||
m.timestamp,
|
||||
m.type,
|
||||
ml.url,
|
||||
ml.canonical_url,
|
||||
ml.title AS link_title,
|
||||
ml.domain,
|
||||
ml.source,
|
||||
ml.confidence
|
||||
FROM message_links ml
|
||||
JOIN messages m
|
||||
ON m.chatroom_id = ml.chatroom_id
|
||||
AND m.local_id = ml.local_id
|
||||
WHERE ml.date = ?
|
||||
ORDER BY ml.timestamp DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(date, MAX_MESSAGES) as MessageLinkRow[];
|
||||
@@ -548,10 +534,7 @@ export async function getDailyLinkIntelligence(
|
||||
const buckets = new Map<string, LinkIntelligenceItem>();
|
||||
|
||||
for (const row of rows) {
|
||||
for (const raw of extractUrls(row.content)) {
|
||||
const canonical = normalizeUrl(raw);
|
||||
if (!canonical) continue;
|
||||
|
||||
const canonical = row.canonical_url;
|
||||
const kind: LinkKind | null = isArticleLink(canonical)
|
||||
? 'article'
|
||||
: isToolLink(canonical, row.content)
|
||||
@@ -568,6 +551,7 @@ export async function getDailyLinkIntelligence(
|
||||
time: row.time,
|
||||
local_id: row.local_id,
|
||||
snippet: cleanSnippet(row.content),
|
||||
source: row.source,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
@@ -581,10 +565,10 @@ export async function getDailyLinkIntelligence(
|
||||
} else {
|
||||
buckets.set(key, {
|
||||
kind,
|
||||
url: raw,
|
||||
url: row.url,
|
||||
canonical_url: canonical,
|
||||
title: titleFromContext(row.content, raw),
|
||||
domain: domainOf(canonical),
|
||||
title: row.link_title || titleFromContext(row.content, row.url),
|
||||
domain: row.domain || domainOf(canonical),
|
||||
count: 1,
|
||||
group_count: 1,
|
||||
first_seen: row.time,
|
||||
@@ -593,7 +577,6 @@ export async function getDailyLinkIntelligence(
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sortItems = (kind: LinkKind, limit = MAX_ITEMS_PER_KIND) =>
|
||||
Array.from(buckets.values())
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { db } from './db';
|
||||
import type { MessageRow } from './messages-store';
|
||||
|
||||
export type MessageLinkSource = 'wechat_raw' | 'plain_url' | 'public_search' | 'manual';
|
||||
|
||||
export interface ParsedMessageLink {
|
||||
url: string;
|
||||
canonical_url: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
domain: string;
|
||||
source: MessageLinkSource;
|
||||
raw_kind: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
type LinkInput = Pick<
|
||||
MessageRow,
|
||||
'chatroom_id' | 'local_id' | 'date' | 'sender' | 'content' | 'time' | 'timestamp'
|
||||
>;
|
||||
|
||||
export function decodeHtmlEntities(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, hex: string) => String.fromCodePoint(Number.parseInt(hex, 16)))
|
||||
.replace(/&#(\d+);/g, (_, num: string) => String.fromCodePoint(Number.parseInt(num, 10)));
|
||||
}
|
||||
|
||||
export function cleanUrl(raw: string): string {
|
||||
return decodeHtmlEntities(raw)
|
||||
.replace(/[),,。;;!?!?、\]}>]+$/g, '')
|
||||
.replace(/\.{3,}$/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function normalizeUrl(raw: string): string | null {
|
||||
if (!raw || raw.includes('...') || raw.includes('…')) return null;
|
||||
try {
|
||||
const u = new URL(cleanUrl(raw));
|
||||
u.hash = '';
|
||||
for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']) {
|
||||
u.searchParams.delete(key);
|
||||
}
|
||||
return u.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function domainOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isWechatArticleUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(cleanUrl(url));
|
||||
return u.hostname === 'mp.weixin.qq.com' && (/^\/s\/?/.test(u.pathname) || u.searchParams.has('__biz'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function tagText(content: string, tag: string): string {
|
||||
const text = content.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i'))?.[1] ?? '';
|
||||
return decodeHtmlEntities(text)
|
||||
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function attrValues(content: string, attr: string): string[] {
|
||||
return Array.from(content.matchAll(new RegExp(`${attr}=["']([^"']+)["']`, 'gi')))
|
||||
.map((m) => decodeHtmlEntities(m[1]).trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function titleFromContext(content: string, url: string): string | null {
|
||||
const xmlTitle = tagText(content, 'title');
|
||||
if (xmlTitle) return xmlTitle.slice(0, 160);
|
||||
|
||||
const decoded = decodeHtmlEntities(content).replace(/<\?xml[\s\S]+?<\/msg>/g, ' ');
|
||||
const lines = decoded
|
||||
.split(/\n+/)
|
||||
.map((line) =>
|
||||
line
|
||||
.replace(url, '')
|
||||
.replace(/https?:\/\/\S+/g, '')
|
||||
.replace(/\[引用\]/g, '')
|
||||
.replace(/^\s*↳\s*/, '')
|
||||
.replace(/^\s*\[链接\]\s*/, '')
|
||||
.trim(),
|
||||
)
|
||||
.filter((line) => line.length >= 4 && line.length <= 120);
|
||||
|
||||
return lines.find((line) => !/^[@#\d\s::-]+$/.test(line))?.slice(0, 160) ?? null;
|
||||
}
|
||||
|
||||
export function extractMessageLinks(content: string): ParsedMessageLink[] {
|
||||
const decoded = decodeHtmlEntities(content);
|
||||
const hasXml = /<msg[\s>]|<appmsg[\s>]/i.test(decoded);
|
||||
const xmlTitle = tagText(decoded, 'title') || null;
|
||||
const xmlDescription = tagText(decoded, 'des') || tagText(decoded, 'digest') || null;
|
||||
const candidates: Array<{ url: string; source: MessageLinkSource; raw_kind: string; confidence: number }> = [];
|
||||
|
||||
for (const tag of ['url', 'lowurl']) {
|
||||
const url = tagText(decoded, tag);
|
||||
if (url && isWechatArticleUrl(url)) {
|
||||
candidates.push({ url, source: 'wechat_raw', raw_kind: `appmsg_${tag}`, confidence: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of attrValues(decoded, 'url')) {
|
||||
if (!isWechatArticleUrl(value)) continue;
|
||||
candidates.push({
|
||||
url: value,
|
||||
source: hasXml ? 'wechat_raw' : 'plain_url',
|
||||
raw_kind: hasXml ? 'appmsg_attr_url' : 'plain_attr_url',
|
||||
confidence: hasXml ? 0.98 : 0.9,
|
||||
});
|
||||
}
|
||||
|
||||
for (const m of decoded.matchAll(/https?:\/\/[^\s<>"']+/g)) {
|
||||
const rawUrl = cleanUrl(m[0]);
|
||||
const article = isWechatArticleUrl(rawUrl);
|
||||
const source: MessageLinkSource = hasXml && article ? 'wechat_raw' : 'plain_url';
|
||||
candidates.push({
|
||||
url: rawUrl,
|
||||
source,
|
||||
raw_kind: hasXml && article ? 'appmsg_url_text' : 'plain_url',
|
||||
confidence: article ? 0.96 : 0.9,
|
||||
});
|
||||
}
|
||||
|
||||
const out = new Map<string, ParsedMessageLink>();
|
||||
for (const c of candidates) {
|
||||
const canonical = normalizeUrl(c.url);
|
||||
if (!canonical) continue;
|
||||
const domain = domainOf(canonical);
|
||||
if (!domain) continue;
|
||||
const existing = out.get(canonical);
|
||||
if (existing && existing.confidence >= c.confidence) continue;
|
||||
out.set(canonical, {
|
||||
url: cleanUrl(c.url),
|
||||
canonical_url: canonical,
|
||||
title: xmlTitle ?? titleFromContext(decoded, c.url),
|
||||
description: xmlDescription,
|
||||
domain,
|
||||
source: c.source,
|
||||
raw_kind: c.raw_kind,
|
||||
confidence: c.confidence,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(out.values());
|
||||
}
|
||||
|
||||
const upsertMessageLink = () =>
|
||||
db().prepare(`
|
||||
INSERT INTO message_links (
|
||||
chatroom_id, local_id, date, sender, time, timestamp,
|
||||
url, canonical_url, title, description, domain, source, raw_kind, confidence, created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(chatroom_id, local_id, canonical_url) DO UPDATE SET
|
||||
url = excluded.url,
|
||||
title = COALESCE(excluded.title, message_links.title),
|
||||
description = COALESCE(excluded.description, message_links.description),
|
||||
domain = excluded.domain,
|
||||
source = excluded.source,
|
||||
raw_kind = excluded.raw_kind,
|
||||
confidence = excluded.confidence
|
||||
`);
|
||||
|
||||
export function upsertLinksForMessage(message: LinkInput): number {
|
||||
const links = extractMessageLinks(message.content);
|
||||
if (links.length === 0) return 0;
|
||||
const stmt = upsertMessageLink();
|
||||
let changed = 0;
|
||||
for (const link of links) {
|
||||
const r = stmt.run(
|
||||
message.chatroom_id,
|
||||
message.local_id,
|
||||
message.date,
|
||||
message.sender ?? '',
|
||||
message.time ?? '',
|
||||
message.timestamp ?? 0,
|
||||
link.url,
|
||||
link.canonical_url,
|
||||
link.title,
|
||||
link.description,
|
||||
link.domain,
|
||||
link.source,
|
||||
link.raw_kind,
|
||||
link.confidence,
|
||||
Date.now(),
|
||||
);
|
||||
changed += r.changes;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export function upsertResolvedLinkForMessage(input: {
|
||||
chatroom_id: string;
|
||||
local_id: number;
|
||||
url: string;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
source: Extract<MessageLinkSource, 'public_search' | 'manual'>;
|
||||
confidence?: number;
|
||||
}): { ok: boolean; error?: string } {
|
||||
const message = db()
|
||||
.prepare(
|
||||
`SELECT chatroom_id, local_id, sender, content, time, timestamp, type, date
|
||||
FROM messages
|
||||
WHERE chatroom_id = ? AND local_id = ?`,
|
||||
)
|
||||
.get(input.chatroom_id, input.local_id) as MessageRow | undefined;
|
||||
|
||||
if (!message) return { ok: false, error: 'message not found' };
|
||||
|
||||
const canonical = normalizeUrl(input.url);
|
||||
if (!canonical) return { ok: false, error: 'invalid url' };
|
||||
|
||||
const domain = domainOf(canonical);
|
||||
if (!domain) return { ok: false, error: 'invalid domain' };
|
||||
|
||||
upsertMessageLink().run(
|
||||
message.chatroom_id,
|
||||
message.local_id,
|
||||
message.date,
|
||||
message.sender ?? '',
|
||||
message.time ?? '',
|
||||
message.timestamp ?? 0,
|
||||
cleanUrl(input.url),
|
||||
canonical,
|
||||
input.title?.trim() || titleFromContext(message.content, input.url),
|
||||
input.description?.trim() || null,
|
||||
domain,
|
||||
input.source,
|
||||
input.source,
|
||||
input.confidence ?? (input.source === 'manual' ? 0.95 : 0.72),
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function backfillMessageLinks(since?: string, until?: string): { scanned: number; links: number } {
|
||||
const clauses = ["(content LIKE '%http%' OR content LIKE '%<url>%' OR content LIKE '%imgsourceurl=%')"];
|
||||
const params: string[] = [];
|
||||
if (since) {
|
||||
clauses.push('date >= ?');
|
||||
params.push(since);
|
||||
}
|
||||
if (until) {
|
||||
clauses.push('date <= ?');
|
||||
params.push(until);
|
||||
}
|
||||
|
||||
const rows = db()
|
||||
.prepare(
|
||||
`SELECT chatroom_id, local_id, sender, content, time, timestamp, type, date
|
||||
FROM messages
|
||||
WHERE ${clauses.join(' AND ')}
|
||||
ORDER BY timestamp DESC`,
|
||||
)
|
||||
.all(...params) as MessageRow[];
|
||||
|
||||
let links = 0;
|
||||
const tx = db().transaction(() => {
|
||||
db()
|
||||
.prepare(
|
||||
`DELETE FROM message_links
|
||||
WHERE source = 'wechat_raw'
|
||||
AND canonical_url NOT LIKE '%://mp.weixin.qq.com/%'`,
|
||||
)
|
||||
.run();
|
||||
for (const row of rows) links += upsertLinksForMessage(row);
|
||||
});
|
||||
tx();
|
||||
return { scanned: rows.length, links };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { db } from './db';
|
||||
import { upsertLinksForMessage } from './message-links';
|
||||
import type { WxMessage } from './wx-types';
|
||||
|
||||
export interface MessageRow extends WxMessage {
|
||||
@@ -42,6 +43,15 @@ export function bulkInsertMessages(chatroomId: string, messages: WxMessage[]): n
|
||||
m.type ?? '',
|
||||
dateOfMessage(m),
|
||||
);
|
||||
upsertLinksForMessage({
|
||||
chatroom_id: chatroomId,
|
||||
local_id: m.local_id,
|
||||
sender: m.sender ?? '',
|
||||
content: m.content ?? '',
|
||||
time: m.time ?? '',
|
||||
timestamp: m.timestamp ?? 0,
|
||||
date: dateOfMessage(m),
|
||||
});
|
||||
if (r.changes > 0) inserted++;
|
||||
}
|
||||
});
|
||||
|
||||
+45
-6
@@ -42,19 +42,35 @@ type TopicWithMembers = {
|
||||
};
|
||||
|
||||
function cleanContent(s: string): string {
|
||||
return s
|
||||
const text = s
|
||||
.replace(/\[图片\]\s*local_id=\d+/g, '')
|
||||
.replace(/\[引用\][^\n]*\n?/g, '')
|
||||
.replace(/\[小程序\][^\n]*/g, '')
|
||||
.replace(/↳\s*[^\n]*/g, '')
|
||||
.replace(/<\?xml[\s\S]+?\?>[\s\S]*?<\/msg>/g, '')
|
||||
.replace(/\[(?:链接|链接\/文件)\]\s*(?:当前(?:微信)?版本不支持展示该内容,请升级至?最新(?:版|版本)|当前版本不支持展示该内容,请升级至最新版本)[。.]?/gi, ' ')
|
||||
.replace(/当前(?:微信)?版本不支持展示该内容,请升级至?最新(?:版|版本)[。.]?/gi, ' ')
|
||||
.replace(/https?:\/\/\S+/g, ' ')
|
||||
.replace(/\b[a-f0-9]{24,}\b/gi, ' ')
|
||||
.replace(/\b\d{12,}\b/g, ' ')
|
||||
.trim();
|
||||
|
||||
const parts = text
|
||||
.split(/↳|\\n|\n/)
|
||||
.map((part) =>
|
||||
part
|
||||
.replace(/^\[引用\]\s*/g, '')
|
||||
.replace(/^\[(?:链接|链接\/文件|图片|视频|表情)\]\s*/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim(),
|
||||
)
|
||||
.filter((part) => part.length >= MIN_MESSAGE_LENGTH && !isPlaceholderTitle(part));
|
||||
|
||||
return parts[0] ?? text.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
// 这些消息整体就是占位符 / wrapper,无实质内容
|
||||
const PLACEHOLDER_PATTERNS = [
|
||||
/^\[链接\]\s*当前版本不支持/,
|
||||
/^\[链接\]\s*当前(?:微信)?版本不支持/,
|
||||
/^当前(?:微信)?版本不支持展示该内容,请升级至?最新(?:版|版本)[。.]?$/,
|
||||
/^\[文件\]\s*[^\s]+\.\w+\s*$/,
|
||||
/^\[视频\]\s*$/,
|
||||
/^\[音频\]\s*$/,
|
||||
@@ -70,7 +86,16 @@ const PLACEHOLDER_PATTERNS = [
|
||||
|
||||
function isPlaceholderOnly(content: string): boolean {
|
||||
if (!content) return true;
|
||||
return PLACEHOLDER_PATTERNS.some((p) => p.test(content));
|
||||
return PLACEHOLDER_PATTERNS.some((p) => p.test(content)) || isPlaceholderTitle(content);
|
||||
}
|
||||
|
||||
function isPlaceholderTitle(value: string): boolean {
|
||||
const text = value
|
||||
.replace(/^\[(?:链接|链接\/文件|图片|视频|表情)\]\s*/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!text) return true;
|
||||
return /^(?:当前(?:微信)?版本不支持展示该内容,请升级至?最新(?:版|版本)|当前版本不支持|请升级至最新版本)[。.]?$/i.test(text);
|
||||
}
|
||||
|
||||
function loadCandidateMessages(date: string): SourceMsg[] {
|
||||
@@ -237,6 +262,8 @@ function buildExtractionPrompt(
|
||||
- 合并同一事件、产品、工具、论文、观点、问题及其追问/回应/转述。
|
||||
- 优先保留跨群出现的话题;同一群内高密度连续讨论也可以保留。
|
||||
- 忽略问候、纯闲聊、广告、无上下文碎片、纯占位内容和过泛的「AI 很火」类讨论。
|
||||
- 严禁把「当前版本不支持展示该内容」「当前微信版本不支持展示该内容」「请升级至最新版本」这类微信占位文案当作话题标题或摘要。
|
||||
- 遇到链接/视频/小程序占位时,只能根据前后文里真正有人讨论的对象命名;没有可读上下文就丢弃。
|
||||
- 每个话题至少包含 ${MIN_MESSAGES_PER_TOPIC} 条消息。
|
||||
- 最多输出 ${maxTopics} 个话题,按重要性排序。
|
||||
- title 用 8-15 个汉字,优先写产品名/事件名/讨论焦点。
|
||||
@@ -268,6 +295,7 @@ function buildMergePrompt(date: string, drafts: LlmTopic[], maxTopics: number):
|
||||
任务要求:
|
||||
- 合并语义相同或强相关的话题草稿,message_ids 取并集。
|
||||
- 删除过泛、重复、证据不足的话题。
|
||||
- 删除微信升级提示、资源封面、头像链接、无语义数字串等占位话题。
|
||||
- 每个最终话题至少包含 ${MIN_MESSAGES_PER_TOPIC} 条消息。
|
||||
- 最多输出 ${maxTopics} 个最终话题,按重要性排序。
|
||||
- title 用 8-15 个汉字,summary 用 1-2 句中文。
|
||||
@@ -294,7 +322,7 @@ function normalizeTopics(rawTopics: LlmTopic[], messageMap: Map<string, SourceMs
|
||||
seenSignatures.add(signature);
|
||||
|
||||
out.push({
|
||||
title: (raw.title || members[0].content.slice(0, 16) || '未命名话题').slice(0, 80),
|
||||
title: cleanTopicTitle(raw.title, members),
|
||||
summary: (raw.summary || '').slice(0, 400),
|
||||
members,
|
||||
groupSet: new Set(members.map((m) => m.chatroom_id)),
|
||||
@@ -304,6 +332,17 @@ function normalizeTopics(rawTopics: LlmTopic[], messageMap: Map<string, SourceMs
|
||||
return out.sort((a, b) => b.members.length - a.members.length).slice(0, MAX_TOPICS_TO_SAVE);
|
||||
}
|
||||
|
||||
function cleanTopicTitle(title: string, members: SourceMsg[]): string {
|
||||
const cleaned = cleanContent(title || '');
|
||||
if (cleaned && !isPlaceholderTitle(cleaned)) return cleaned.slice(0, 80);
|
||||
const candidate = members
|
||||
.slice()
|
||||
.sort((a, b) => b.content.length - a.content.length)
|
||||
.map((m) => cleanContent(m.content))
|
||||
.find((text) => text.length >= 8 && !isPlaceholderTitle(text));
|
||||
return (candidate || '未命名话题').slice(0, 80);
|
||||
}
|
||||
|
||||
async function aggregateWithCodex(
|
||||
date: string,
|
||||
messages: SourceMsg[],
|
||||
|
||||
+16
-16
@@ -9,21 +9,21 @@ const glob = (fsp as unknown as {
|
||||
}).glob;
|
||||
|
||||
const WX_CACHE_ROOT = join(
|
||||
/*turbopackIgnore: true*/ homedir(),
|
||||
homedir(),
|
||||
'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files',
|
||||
);
|
||||
|
||||
let _userDirCache: string | null = null;
|
||||
|
||||
/** 找到当前微信用户目录(取最近修改的) */
|
||||
/** 找到当前登录微信用户目录(取最近修改的) */
|
||||
function findUserDir(): string | null {
|
||||
if (_userDirCache && existsSync(/*turbopackIgnore: true*/ _userDirCache)) return _userDirCache;
|
||||
if (!existsSync(/*turbopackIgnore: true*/ WX_CACHE_ROOT)) return null;
|
||||
const entries = readdirSync(/*turbopackIgnore: true*/ WX_CACHE_ROOT, { withFileTypes: true })
|
||||
if (_userDirCache && existsSync(_userDirCache)) return _userDirCache;
|
||||
if (!existsSync(WX_CACHE_ROOT)) return null;
|
||||
const entries = readdirSync(WX_CACHE_ROOT, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && e.name !== 'all_users' && e.name !== 'Backup')
|
||||
.map((e) => {
|
||||
const p = join(/*turbopackIgnore: true*/ WX_CACHE_ROOT, e.name);
|
||||
return { p, mtime: statSync(/*turbopackIgnore: true*/ p).mtimeMs };
|
||||
const p = join(WX_CACHE_ROOT, e.name);
|
||||
return { p, mtime: statSync(p).mtimeMs };
|
||||
})
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
if (entries.length === 0) return null;
|
||||
@@ -33,9 +33,9 @@ function findUserDir(): string | null {
|
||||
|
||||
/** 列出按月分的子目录,按时间倒序(最近月份优先) */
|
||||
function listMonthDirs(userDir: string): string[] {
|
||||
const cacheDir = join(/*turbopackIgnore: true*/ userDir, 'cache');
|
||||
if (!existsSync(/*turbopackIgnore: true*/ cacheDir)) return [];
|
||||
return readdirSync(/*turbopackIgnore: true*/ cacheDir)
|
||||
const cacheDir = join(userDir, 'cache');
|
||||
if (!existsSync(cacheDir)) return [];
|
||||
return readdirSync(cacheDir)
|
||||
.filter((d) => /^\d{4}-\d{2}$/.test(d))
|
||||
.sort((a, b) => b.localeCompare(a));
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export interface ResolvedImage {
|
||||
/** 检测文件 magic bytes */
|
||||
function detectFormat(path: string): ResolvedImage['format'] {
|
||||
try {
|
||||
const fd = readFileSync(/*turbopackIgnore: true*/ path, { flag: 'r' });
|
||||
const fd = readFileSync(path, { flag: 'r' });
|
||||
const h = fd.subarray(0, 4);
|
||||
if (h[0] === 0xff && h[1] === 0xd8) return 'jpeg';
|
||||
if (h[0] === 0x89 && h[1] === 0x50 && h[2] === 0x4e && h[3] === 0x47) return 'png';
|
||||
@@ -69,8 +69,8 @@ async function buildMonthIndex(userDir: string, month: string): Promise<void> {
|
||||
if (existing) return existing;
|
||||
|
||||
const p = (async () => {
|
||||
const monthRoot = join(/*turbopackIgnore: true*/ userDir, 'cache', month, 'Message');
|
||||
if (!existsSync(/*turbopackIgnore: true*/ monthRoot)) {
|
||||
const monthRoot = join(userDir, 'cache', month, 'Message');
|
||||
if (!existsSync(monthRoot)) {
|
||||
monthIndexCache.set(month, new Map());
|
||||
return;
|
||||
}
|
||||
@@ -89,13 +89,13 @@ async function buildMonthIndex(userDir: string, month: string): Promise<void> {
|
||||
};
|
||||
|
||||
for await (const p of glob('*/ImageTemp/*hd_temp_convert', { cwd: monthRoot })) {
|
||||
consider(join(/*turbopackIgnore: true*/ monthRoot, String(p)), 'hd');
|
||||
consider(join(monthRoot, String(p)), 'hd');
|
||||
}
|
||||
for await (const p of glob('*/ImageTemp/*mid_temp_convert', { cwd: monthRoot })) {
|
||||
consider(join(/*turbopackIgnore: true*/ monthRoot, String(p)), 'mid');
|
||||
consider(join(monthRoot, String(p)), 'mid');
|
||||
}
|
||||
for await (const p of glob('*/Thumb/*thumb.jpg', { cwd: monthRoot })) {
|
||||
consider(join(/*turbopackIgnore: true*/ monthRoot, String(p)), 'thumb');
|
||||
consider(join(monthRoot, String(p)), 'thumb');
|
||||
}
|
||||
monthIndexCache.set(month, idx);
|
||||
})();
|
||||
|
||||
+1
-3
@@ -1,9 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
turbopack: {
|
||||
root: process.cwd(),
|
||||
},
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"db:backup": "node scripts/backup-cockpit-db.mjs",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"demo:seed": "node scripts/seed_demo.cjs"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
onlyBuiltDependencies:
|
||||
- better-sqlite3
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const dataDir = process.env.WECHAT_RADAR_DATA_DIR || join(homedir(), '.wechat-radar');
|
||||
const source = join(dataDir, 'radar.db');
|
||||
const backupDir = join(dataDir, 'backups');
|
||||
|
||||
if (!existsSync(source)) {
|
||||
console.error(`radar.db not found: ${source}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mkdirSync(backupDir, { recursive: true });
|
||||
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const target = join(backupDir, `radar-${stamp}.db`);
|
||||
|
||||
const db = new Database(source, { readonly: true, fileMustExist: true });
|
||||
|
||||
try {
|
||||
await db.backup(target);
|
||||
console.log(target);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const Database = require('better-sqlite3');
|
||||
const { existsSync, mkdirSync, writeFileSync } = require('node:fs');
|
||||
const { homedir } = require('node:os');
|
||||
|
||||
Reference in New Issue
Block a user