以 vibe coding 含量极高的语雀推送小 demo 为例,写一份文档指北消化一下
后端 Node.js + MongoDB + axios
后端:数据处理、存储以及与前端进行数据交换
npm install 进行包管理
node xxx.js 运行代码
yuque-email-pusher/
├── app.js # 主程序入口
├── config.js # 所有配置集中在这里
├── models.js # 所有数据模型集中在这里
├── services.js # 所有业务逻辑集中在这里
├── routes.js # 所有API路由集中在这里
├── package.json
└── .env
之前文档里提到 Node.js 作为后端的结构框架
- app.js 主程序入口,启动整个服务,启动 express,将 api 路由交给 routes.js,设置根路由,启动数据库链接,启动定时服务,启动 web
- config.js 配置一些服务链接基本信息,比如服务器端口、数据库连接、语雀 api、邮件服务、定时……
- model.js 定义数据库的一些类型
- routes.js 统一配置 api 路由
- service.js 各种函数功能实现
全局定义与引入其他文件或包
const axios = require('axios'); // 用于请求语雀 API
const nodemailer = require('nodemailer'); // 用于发送邮件
const cron = require('node-cron'); // 用于定时任务
const config = require('./config'); // 加载配置
const fs = require('fs');// files 模块
const { Subscription, PushHistory ,DocTree, User } = require('./models'); // 加载数据模型
const services = require('./services');// 引入服务文件模块
const bcrypt = require('bcryptjs');// 密码 hash
const jwt = require('jsonwebtoken');// token 令牌
require('dotenv').config();// 从项目根目录下读取 .env 的文件,把文件中的键值对加载到 process.env 中,等同于在运行环境里设置了这些环境变量。
const cors = require('cors');// // 允许前端端口跨域访问
const app = express(); // 创建 express 应用实例即后端应用入口
app.js 文件
- 前端跨域访问
app.use(cors({
origin: ['http://10.6.1.104:8080', 'http://localhost:8080','http://26.116.77.54:8088'],
credentials: true
}));
- 设置中间体,即从客户端到服务器端(后端)
app.use(express.json());
express.json()
- 监听 JSON 请求: 它会检查传入请求的
Content-Type头部。- 解析请求体: 如果请求体是 JSON 格式,它会将原始的 JSON 数据(通常是字符串形式)解析成一个 JavaScript 对象。
- 挂载到 req.body: 它会将解析后的 JavaScript 对象自动附加到请求对象(
req)的 body 属性上。
- 设置路由
// 加载路由(所有 API 路由都在 routes.js 里定义)
const routes = require('./routes');
app.use('/api', routes); // 所有 /api 开头的请求交给 routes.js 处理
// 批量管理 api 接口
// 根路由和健康检查接口
app.get('/', (req, res) => {
res.send('欢迎使用语雀邮件推送系统!');
});
app.get('/health', (req, res) => {
res.json({ status: 'OK', service: '语雀邮件推送' });
});
- startServer() 启动服务函数
async function startServer() {
try {
// 连接 MongoDB 数据库,连接地址在 .env 文件或 config.js 里配置
await mongoose.connect(process.env.MONGODB_URI);
console.log('数据库连接成功');
...
console.log('初始化文档树数据');
...
// 启动定时任务(定时自动推送语雀消息,逻辑在 services.js)
// 启动后立即推送一次 await services.manualPush();
services.startCronJob(); // 启动定时任务
// 启动 Web 服务,默认监听0.0.0.0 端口(默认 3000,可在 .env 或 config.js 配置)
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`服务器运行在 http://0.0.0.0:${PORT}`);
});
} catch (error) {
// 启动失败时输出错误
console.error('启动失败:', error);
}
}
config.js 文件
集中管理配置信息,例如
// 语雀 API 配置
yuque: {
token: process.env.YUQUE_TOKEN, // 语雀 API 令牌
baseURL: 'https://nova.yuque.com/api/v2', // 语雀 API 基础地址
baseSlug: process.env.TEAM_BASE_ID + '/' + process.env.KNOWLEDGE_BASE_ID, // 知识库 ID(如 ph25ri/ua1c3q)
targetAuthor: process.env.TARGET_AUTHOR // 只推送特定作者的文档
},
通过 module.export ={} 开放给其他文件使用
model.js 文件
定义数据库类型
const subscriptionSchema = new mongoose.Schema({
email: { type: String, required: true, unique: false },
// docSlug: { type: String, required: false, default: process.env.KNOWLEDGE_BASE_ID },
docUuid: { type: String, required: false, default: '' },
title: { type: String, default: '' },
single: { type: Boolean, default: false },
author: { type: String, default: '' },
isActive: { type: Boolean, default: true },
subscribedAt: { type: Date, default: Date.now }
});
required: true 即为必填项
type 数据类型
unique 是否唯一
const Subscription = mongoose.model('Subscription', subscriptionSchema);
// 链接到对应数据库
module.exports = {
Subscription,
}
// 开放接口
routes.js 文件
routes.js 负责定义所有 HTTP API 路由,并调用 service 层实现
例如注册接口
router.post('/user/register', async (req, res) => {
const { username, email, password } = req.body;
const result = await userService.registerUser({ username, email, password });
// 调用 userService 的函数
res.json(result);
});
req 为请求所携带的内容,res 则为返回信息
service.js 文件
服务层,可以按模块进行拆分:如 userService、yuqueService、emailService
class YuqueEmailService {
constructor() {// 构造函数,每次调用 YuqueEmailService 自动创建
// 创建语雀 API 客户端
this.yuqueClient = axios.create({
baseURL: config.yuque.baseURL,
headers: {
'X-Auth-Token': config.yuque.token,
'User-Agent': 'Yuque-Email-Pusher/1.0'
}
});
// 创建邮件发送器
this.emailTransporter = nodemailer.createTransport({
service: config.email.service,
auth: {
user: config.email.user,
pass: config.email.password
}
});
// 生成 token 密钥
this.jwtSecret = process.env.JWT_SECRET || 'default_jwt_secret';
}
// 服务层函数
async getYuqueDocs(){// async 关键字允许函数内使用 await 将异步操作转为同步
try {
const tocResponse = await this.yuqueClient.get(
`/repos/${config.yuque.baseSlug}/toc`
);
if(tocResponse.status !== 200) {
throw new Error('无法获取知识库目录');
}
// 数据库查询、修改、更新
if(await DocTree.countDocuments({uuid:process.env.KNOWLEDGE_BASE_ID})===0){
const newNode = new DocTree({
title: '社团活动',
type: 'KNOWLEDGE_BASE',
slug: process.env.KNOWLEDGE_BASE_ID,
uuid: process.env.KNOWLEDGE_BASE_ID,
update: false,
children: []
});
await newNode.save();// 只有 save 过才算更新
}
const rootDoc = await DocTree.findOne({uuid:'Root'});
if(!rootDoc.children.includes(process.env.KNOWLEDGE_BASE_ID)){
await DocTree.findOneAndUpdate(// findOneAndUpdate 可以避免一些异步更新错误
{ uuid: 'Root' },
{ $push: { children: process.env.KNOWLEDGE_BASE_ID } }// 对列表进行插入
);
}
...
return [];
} catch (error) {
console.error('获取语雀文档失败:', error.message);
return [];
}
}
async sendEmail(docs, subscriberEmail) {}
buildEmailContent(docs) {}
async runPushTask() {}
async startCronJob() {
cron.schedule(config.cron.schedule, () => {
this.runPushTask();
});
console.log('定时任务已启动');
}
// 手动触发推送任务
async manualPush() {
return await this.runPushTask();
}
// 订阅邮箱
async subscribe(email, title, docUuid, single, author) {}
// 取消订阅
async unsubscribe(email, title, docUuid, single, author) {}
async unsubscribeAll(email) {}
...
}
// 导出业务服务实例,供 app.js 和 routes.js 调用
module.exports = new YuqueEmailService();
具体语法细则问 ai 就 ok 啦
userService 等文件同理
前端 Vue 3 + TypeScript + Ant Design Vue + axios
还是贴出 gpt4.1 给的项目框架
frontend-vue/
├── babel.config.js # Babel 配置(转译 ES6+ 语法)
├── package.json # 项目依赖与脚本
├── tsconfig.json # TypeScript 配置
├── vue.config.js # Vue CLI 配置(如 devServer、代理等)
├── public/
│ ├── favicon.ico
│ └── index.html # HTML 模板
└── src/
├── App.vue # 根组件
├── main.ts # 入口文件,挂载 Vue 应用
├── shims-vue.d.ts # TypeScript 对 .vue 文件的类型声明
├── assets/ # 静态资源(如 logo.png)
├── components/ # 复用组件(如 GlobalHeader、HelloWorld)
├── layouts/ # 页面布局组件(如 BasicLayout)
├── router/
│ └── index.ts # 路由配置
├── views/ # 页面视图(如 HomeView、AboutView)
└── requests.ts # axios 封装,统一管理 API 请求
- 页面入口:main.ts 挂载 App.vue,引入全局样式和 Ant Design Vue。
- 路由跳转:router/index.ts 配置页面路由,views/ 目录下为各页面组件。
- 页面布局:layouts/ 统一页面结构,components/ 复用小组件。
- 数据请求:通过 requests.ts 封装 axios,页面/组件中调用 API。
- UI 展示:用 Ant Design Vue 组件渲染数据,结合响应式样式适配不同终端。
前端主入口 main.ts
import { createApp } from 'vue';
import App from './App.vue';
import Antd from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import router from './router';
createApp(App).use(router).use(Antd).mount('#app');// 把根组件 App 渲染到 public/index.html 的 <div id="app"></div>
index.html 即为 html 模板,整个项目的 HTML/JS/CSS 资源都会自动注入其中,% 中间的内容由注入的内容决定
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>
App.vue/BasicLayout.vue
<template>
<div>
<BasicLayout />
</div>
</template>
<style></style>
<script setup lang="ts">
import BasicLayout from '@/layouts/BasicLayout.vue';// 引入目录下的 BasicLayout.vue 文件
</script>
<template>
<div id="basicLayout">
<a-layout>
<a-layout-header class="header">
<GlobalHeader />
</a-layout-header>
<a-layout-content class="content">
<router-view /> 根据 index.ts 配置,以当前路由决定 content 内容
</a-layout-content>
<a-layout-footer class="footer">
<a href="https://nova.yuque.com/r/organizations/homepage" target="_blank">
Yueque Message Pusher ©2025 Created by NOVA
</a>
</a-layout-footer>
</a-layout>
</div>
</template>
<script setup lang="ts">
import GlobalHeader from '@/components/GlobalHeader.vue';
</script>
<style scoped>// CSS 样式,scoped 代表仅在当前组件有效
#basicLayout .footer { // # 代表 id,. 代表 class
background: #efefef;
text-align: center;
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16px;
}
#basicLayout .content {
margin-bottom: 20px;
padding: 20px;
background: #fff;
}
#basicLayout .header {
background: white;
padding-inline: 20px;
color: unset;
margin-bottom: 20px;
}
</style>
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
import LoginView from '../views/LoginView.vue'
import RegisterView from '../views/RegisterView.vue'
import HomeView from '../views/HomeView.vue'
import SubscriptionManage from '@/views/SubscriptionManage.vue';
const routes: Array<RouteRecordRaw> = [
{
path: '/user/login',
name: 'userLogin',
component: LoginView// 使用引入的文件
},
{
path: '/user/register',
name: 'userRegister',
component: RegisterView
},
{
path: '/home',
name: 'home',
component: HomeView
},
{
path: '/subscriptions',
name: 'subscriptions',
component: SubscriptionManage
},
{
path: '/',
redirect: '/home'
}
];
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),// history 模式 url 中不含 #
routes// 及上文定义的内容
})
// 全局登录态守卫,每次路由跳转前都会执行
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token');
// 允许未登录访问登录/注册页
if (to.path === '/user/login' || to.path === '/user/register') {
next();// 执行跳转
} else {
// 其他页面必须有token
if (token) {
next();
} else {
next('/user/login');
}
}
});
export default router // 导出 router 通过 main.ts 注册到整个 vue
requests.ts
import axios from "axios";
alert(process.env.NODE_ENV);// 引入 env 文件
const myAxios = axios.create({
baseURL:"http://10.6.1.104:3000/api",// 后端 api 接口地址
timeout: 10000,
withCredentials: true,// 让 axios 在跨域请求时也带上 cookie
});
// 请求拦截器:在请求前进行操作,如添加 token
myAxios.interceptors.request.use(
function (config) {
return config;
},
function (error) {
// Do something with request error
return Promise.reject(error);
}
// 请求拦截器:在响应返回后进行操作
myAxios.interceptors.response.use(
function (response) {
console.log(response);
const { data } = response;
console.log(data);
// 未登录
if (data.code === 40100) {
// 不是获取用户信息接口,或者不是登录页面,则跳转到登录页面
// 用户访问受保护页面,未登录时被拦到登录页。
// 登录页登录成功后,前端读取 redirect 参数,自动跳转回原页面
if (!window.location.pathname.includes("/user/login")) {
window.location.href = `/user/login?redirect=${window.location.href}`;
}
}
return response;
},
function (error) {
return Promise.reject(error);
}
);
export default myAxios;
GlobalHeader.vue
以 GlobalHeader.vue 为例,阐明 JS 的用法
<template>
<div id="globalHeader">
<a-row :wrap="false">// wrap=true 响应折叠,即换行
<a-col flex="200px">
<div class="title-bar">
<img src="@/assets/logo.png" alt="logo" class="logo" />
<div class="title">语雀消息推送</div>
</div>
</a-col>
<a-col flex="auto">
<a-menu v-model:selectedKeys="current" mode="horizontal" :items="items" @click="doMenuClick" />
</a-col>
<a-col flex="160px">
<div class="user-login-status btn-group">
<template v-if="isLogin">
<span class="welcome-user">欢迎 {{ username }} 用户</span>
<a-button @click="logout">退出</a-button>
</template>
<template v-else>
<a-button type="primary" @click="() => router.push('/user/login')" style="margin-top: 16px;">登录</a-button>
<a-button @click="() => router.push('/user/register')" style="margin-top: 16px;">注册</a-button>
</template>
</div>
</a-col>
</a-row>
</div>
</template>
<script setup lang="ts">
import { h, ref, watch } from 'vue';
import { HomeOutlined, CrownOutlined } from '@ant-design/icons-vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const doMenuClick = ({ key }: { key: string }) => {
router.push({
path: key,
});
};
const current = ref<string[]>([router.currentRoute.value.path]);
router.afterEach((to) => {
current.value = [to.path];
});
const items = [
{
key: '/',
icon: () => h(HomeOutlined),
label: '主页',
title: '主页',
},
{
key: '/subscriptions',
icon: () => h(CrownOutlined),
label: '订阅管理',
title: '订阅管理',
},
{
key: 'others',
label: h(
'a',
{
href: 'https://nova.yuque.com/r/organizations/homepage',
target: '_blank',
},
'关于我们',
),
title: '关于我们',
},
];
const username = ref(localStorage.getItem('username') || '用户');
const isLogin = ref(!!localStorage.getItem('token'));
function updateLoginState() {
isLogin.value = !!localStorage.getItem('token');
username.value = localStorage.getItem('username') || '用户';
}
function logout() {
localStorage.removeItem('token');
localStorage.removeItem('username');
updateLoginState();
router.push('/user/login');
}
// 监听路由变化,防止手动跳转后状态不同步
router.afterEach(() => {
updateLoginState();
});
</script>
<style scoped>
.title-bar {
display: flex;
align-items: center;
}
.btn-group {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12px;
margin: 0;
}
.welcome-user {
margin-right: 0;
font-weight: 500;
font-size: 16px;
color: #1890ff;
white-space: nowrap;
}
.logo {
width: 55px;
height: 55px;
}
.title {
font-size: 18px;
margin-left: 16px;
color: black;
}
</style>