Obsidian 插件开发实战:给 Discuz 发布插件加上多站点、登录态与马甲功能
在上一篇的基础上,继续给插件加功能。本文记录一次完整的扩展过程,包括多站点管理、登录态 UI、超级管理员识别、马甲发帖,以及踩过的坑。
一、需求梳理
在已有"单站点 + 发帖"的基础上,追加以下功能:
- 多站点配置:一个插件管多个论坛,可切换
- 登录/退出 UI:设置页显示当前登录用户
- 超管识别:登录后自动判断是否超级管理员
- 马甲功能:超管可配置多个马甲账号,发帖时选择身份
- 回写 frontmatter:发帖成功后在笔记头部记录 tid、url、身份
二、多站点改造
2.1 数据结构从单站点改为数组
plugin_settings.ts:
export interface SiteConfig {
id: string;
name: string;
apiBase: string; // https://xxx/api/restful/
siteUrl: string; // https://xxx
appid: string;
secret: string;
username: string;
password: string;
defaultFid: number;
// 运行时缓存
token?: string | null;
tokenExpiry?: number;
uid?: number;
// 登录态
userInfo?: any;
isAdmin?: boolean;
// 马甲:格式 "用户名:密码,用户名:密码"
aliases?: string;
}
export interface DiscuzSettings {
sites: SiteConfig[];
activeSiteId: string;
}
export const DEFAULT_SETTINGS: DiscuzSettings = {
sites: [],
activeSiteId: "",
};
export function generateSiteId(): string {
return "site_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
export function createDefaultSite(name = "新站点"): SiteConfig {
return {
id: generateSiteId(),
name,
apiBase: "",
siteUrl: "",
appid: "",
secret: "",
username: "",
password: "",
defaultFid: 2,
aliases: "",
};
}
/** 解析马甲字符串为 [{username, password}] */
export function parseAliases(aliases: string | undefined): { username: string; password: string }[] {
if (!aliases) return [];
return aliases
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((pair) => {
const idx = pair.indexOf(":");
if (idx === -1) return { username: pair, password: "" };
return {
username: pair.slice(0, idx).trim(),
password: pair.slice(idx + 1).trim(),
};
});
}2.2 HttpUtils 改造为"当前站点"上下文
request.ts 里 HttpUtils 不再持有固定的 appid/secret,而是通过 setSite() 切换:
export class HttpUtils {
private static site: SiteConfig | null = null;
private static token: string | null = null;
private static tokenExpiry = 0;
private static loggedIn = false;
private static uid = 0;
private static userInfo: any = null;
static setSite(site: SiteConfig) {
this.site = site;
this.token = site.token || null;
this.tokenExpiry = site.tokenExpiry || 0;
this.uid = site.uid || 0;
this.userInfo = site.userInfo || null;
this.loggedIn = false;
}
static getSite(): SiteConfig | null {
return this.site;
}
static isLoggedIn(): boolean {
return this.loggedIn && !!this.userInfo;
}
// ... 其余不变,只是内部的 this.site.appid / secret 从当前 site 取
}三、登录/退出 UI
3.1 设置页标题行显示登录态
站点卡片标题行:站点名 + 👤 用户名 + 超管标记 + 按钮组(当前/登录/退出/删除/折叠)。
const titleWrap = header.createDiv({
attr: { style: "display: flex; align-items: center; gap: 8px; flex: 1;" },
});
titleWrap.createEl("strong", {
text: site.name || `站点 ${index + 1}`,
attr: { style: "font-size: 14px;" },
});
if (site.userInfo?.username) {
titleWrap.createEl("span", {
text: `👤 ${site.userInfo.username}`,
attr: { style: "font-size: 12px; color: var(--text-muted);" },
});
if (site.isAdmin) {
titleWrap.createEl("span", {
text: "超管",
attr: {
style: "font-size: 11px; color: var(--text-success);",
},
});
}
}3.2 登录/退出按钮
if (site.userInfo?.username) {
const logoutBtn = btnGroup.createEl("button", { text: "退出" });
logoutBtn.addEventListener("click", async () => {
HttpUtils.setSite(site);
HttpUtils.logout();
delete site.userInfo;
delete site.isAdmin;
delete site.token;
delete site.tokenExpiry;
await this.plugin.saveSettings();
new Notice("已退出登录");
this.display();
});
} else {
const loginBtn = btnGroup.createEl("button", { text: "登录" });
loginBtn.addEventListener("click", async () => {
try {
HttpUtils.setSite(site);
await HttpUtils.login();
await this.plugin.saveSettings();
new Notice("✅ 登录成功:" + (site.userInfo?.username || ""));
this.display();
} catch (e) {
new Notice("❌ " + (e instanceof Error ? e.message : String(e)));
}
});
}四、超管识别
4.1 从登录返回判断
/member/login 返回的 user 对象里包含用户组信息。日志实测:
{
"uid": 1,
"username": "XIGE",
"groupid": "1",
"adminid": "1",
"groupname": "管理员",
"allowadmincp": "1"
}判断条件:
const isAdmin =
user?.adminid === 1 ||
user?.adminid === "1" ||
user?.group?.radminid === "1" ||
user?.group?.radminid === 1 ||
user?.groupid === 1 ||
user?.groupid === "1";
this.site.isAdmin = isAdmin;注意:adminid 和 groupid 都是字符串 "1",不是数字。判断要兼容两种类型。
4.2 马甲设置仅超管可见
if (site.isAdmin) {
// 渲染马甲设置区
}五、马甲发帖(核心)
5.1 Discuz Token 与用户 Session 的关系
这是整个功能最难的点。Discuz 的 RESTful API 里:
- Token:应用级,代表"哪个应用在调接口",有效期 1 小时
- 用户 Session:通过 cookie
auth+saltkey标识"当前是谁"
调 /member/login 只是验证账号密码,不会自动切换 Token 绑定的用户。需要:
- 登录成功
- 清空当前 Token
- 重新获取 Token(此时新 Token 与刚登录的用户绑定)
- 再调一次
/member/login验证身份
5.2 loginAs 实现
static async loginAs(username: string, password: string): Promise<any> {
if (!this.site) throw new Error("未设置站点");
// 1. 先用旧 token 调登录接口
const res = await this.post("/member/login", { username, password });
if (res.ret > 0) {
throw new Error(
`马甲 ${username} 登录失败: ` +
(res.msg?.message || res.msg || JSON.stringify(res))
);
}
// 2. 清空当前 token,强制重新获取
this.token = null;
this.tokenExpiry = 0;
this.site.token = null;
this.site.tokenExpiry = 0;
this.loggedIn = false;
this.userInfo = null;
this.site.userInfo = null;
// 3. 重新获取 token(服务端此时记住新用户)
await this.ensureToken();
// 4. 再登录一次验证身份
const verify = await this.post("/member/login", { username, password });
const verifyUser = verify.user || verify.data?.user;
this.loggedIn = true;
this.userInfo = verifyUser;
this.site.userInfo = verifyUser;
if (verifyUser?.uid) {
this.uid = verifyUser.uid;
this.site.uid = verifyUser.uid;
}
return verify;
}5.3 发帖流程:切身份 → 发帖 → 恢复主账号
push.ts 的 publish():
async publish() {
if (!this.title || !this.content) {
new Notice("标题或内容不能为空");
return;
}
this.notice = new Notice("发布中...");
try {
// 1. 确定发帖身份
if (this.selectedAlias) {
const aliases = parseAliases(this.currentSite.aliases);
const alias = aliases.find((a) => a.username === this.selectedAlias);
if (!alias) {
new Notice("❌ 未找到马甲:" + this.selectedAlias);
this.notice.hide();
return;
}
await HttpUtils.loginAs(alias.username, alias.password);
} else {
await HttpUtils.loginAs(
this.currentSite.username,
this.currentSite.password
);
}
// 2. 发帖
let body = stripFrontmatter(this.content);
body = markdownToBBCode(body);
const res = await HttpUtils.post("/post/newthread", {
fid: this.selectedFid,
subject: this.title,
message: body,
});
if (res.ret === 0) {
const tid = res.tid || res.data?.tid;
const url = tid && this.currentSite.siteUrl
? `${this.currentSite.siteUrl}/forum.php?mod=viewthread&tid=${tid}`
: "";
if (tid && this.activeFilePath) {
await this.writeBackFrontmatter(this.activeFilePath, tid, url);
}
new Notice(
"✅ 发布成功" +
(this.selectedAlias ? ` (${this.selectedAlias})` : "") +
(tid ? ` tid: ${tid}` : "")
);
if (url) window.open(url, "_blank");
} else {
new Notice("❌ 发布失败: " + (res.msg?.message || res.msg || "未知错误"));
}
// 3. 恢复主账号身份
try {
await HttpUtils.loginAs(
this.currentSite.username,
this.currentSite.password
);
} catch (e) {
console.error("[Discuz] 恢复主账号失败:", e);
}
this.close();
} catch (e) {
console.error(e);
new Notice("❌ " + (e instanceof Error ? e.message : String(e)));
try {
await HttpUtils.loginAs(
this.currentSite.username,
this.currentSite.password
);
} catch {}
} finally {
this.notice.hide();
}
}六、回写 frontmatter
发帖成功后在笔记头部写入:
---
title: 笔记标题
discuz:
tid: 232
fid: 31
site: 站点名称
as: 马甲用户名 # 仅马甲发帖时才有
url: https://xxx/forum.php?mod=viewthread&tid=232
published: 2026-09-17T14:40:02.000Z
---实现:
private async writeBackFrontmatter(path: string, tid: number, url: string) {
const file = this.app.vault.getFileByPath(path);
if (!file) return;
let content = await this.app.vault.read(file);
const now = new Date().toISOString();
const discuzYaml = [
`discuz:`,
` tid: ${tid}`,
` fid: ${this.selectedFid}`,
` site: ${this.currentSite.name}`,
this.selectedAlias ? ` as: ${this.selectedAlias}` : "",
` url: ${url}`,
` published: ${now}`,
]
.filter(Boolean)
.join("\n");
const fmRegex = /^---\n([\s\S]*?)\n---\n?/;
const match = content.match(fmRegex);
if (match) {
let fm = match[1];
fm = fm.replace(/\ndiscuz:\n(?:[ \t]+.*\n?)*/g, "\n");
fm = fm.replace(/^discuz:\n(?:[ \t]+.*\n?)*/g, "");
const newFm = fm.trimEnd() + "\n" + discuzYaml;
content = content.replace(fmRegex, `---\n${newFm}\n---\n`);
} else {
content = `---\n${discuzYaml}\n---\n\n${content}`;
}
await this.app.vault.modify(file, content);
}七、UI 细节
7.1 站点卡片折叠
网站多了以后页面会拉得很长,给每个站点卡片加折叠按钮:
const toggleBtn = btnGroup.createEl("button", {
text: "▼",
attr: {
style: "width: 28px; padding: 0; font-size: 11px;",
title: "展开/收起设置",
},
});
const detailWrap = siteBox.createDiv({
attr: { style: "display: none; margin-top: 10px;" },
});
toggleBtn.addEventListener("click", () => {
const expanded = detailWrap.style.display !== "none";
detailWrap.style.display = expanded ? "none" : "block";
toggleBtn.setText(expanded ? "▼" : "▲");
});默认收起,点击 ▼ 展开。
7.2 去掉标题左缩进
Obsidian 的 h2 / h3 默认有 padding-left,视觉上比卡片往右偏。加样式去掉:
containerEl.createEl("h2", {
text: "Discuz 发布设置",
attr: { style: "padding-left: 0; margin-left: 0;" },
});
containerEl.createEl("h3", {
text: "站点列表",
attr: { style: "padding-left: 0; margin-left: 0;" },
});八、踩过的坑
8.1 马甲登录不生效
现象:loginAs("ceshi1", ...) 返回的用户还是 XIGE。
原因:Discuz Token 是应用级的,/member/login 只验证密码,不改 Token 与用户的绑定。
解决:登录成功后清空 Token,重新获取,再登录验证。这就是 loginAs 四步流程的由来。
8.2 连续切马甲时,第二次没生效
现象:主账号 → ceshi1 发帖成功;ceshi1 → ceshi2 发帖,作者还是 ceshi1。
原因:为了"优化性能",加了"身份相同就跳过 loginAs"的判断。但 Token 被第一次切换改过,跳过逻辑让第二次的切换被跳过了。
解决:不要加任何跳过逻辑。每次 loginAs 无条件完整执行四步流程。性能损失几百毫秒,但可靠性 100%。
教训:不要在没有充分测试的情况下"优化"已经跑通的核心逻辑。加缓存、加跳过判断、加条件分支,都会引入新的边界问题。
8.3 发帖后身份没恢复
现象:用马甲发帖后,设置页显示的用户变成了马甲,马甲设置区消失。
原因:loginAs 会更新 site.userInfo 为当前登录用户,发完帖没有切回主账号。
解决:publish() 的最后,无论成功失败都执行一次 loginAs(主账号, 主密码),恢复身份。
8.4 adminid 是字符串
现象:user.adminid === 1 判断为 false。
原因:Discuz 返回的 adminid 是字符串 "1",不是数字 1。
解决:判断时兼容两种类型:
user?.adminid === 1 || user?.adminid === "1"8.5 设置页字段值显示为空
现象:明明保存了用户名密码,但重新打开设置页显示为空。
原因:new Setting(...).addText((t) => t.setValue(...)) 是在 display() 调用时求值,如果 site[key] 是 undefined,String(undefined) 会变成 "undefined"。要用 String(site[key] ?? "")。
8.6 点"登录"没填信息也能成功
现象:空表单点登录,居然成功了。
原因:data.json 里已经存了上次填的账号密码,saveSettings() 会持久化。UI 上看不到只是没显示在输入框里,实际值还在。
不是 bug,但初次用会觉得奇怪。可以在 UI 上提示"请先填写账号密码"。
九、经验总结
9.1 先跑通,再优化
最重要的教训。马甲功能跑通后,试图加"跳过相同身份登录"和"版块列表缓存"两个优化,结果连续两次把核心逻辑改坏。回退后又发现回退不干净。
原则:
- 核心流程(登录、发帖)一旦跑通,不动
- 优化只加在外围(UI、日志、提示)
- 每次改动前先备份当前可用版本
9.2 最小改动
改代码时,只改需要改的那一行。不要顺手重构、不要顺手改命名、不要顺手加日志。每一次"顺手"都是一个潜在的 bug。
9.3 关键操作加日志
涉及网络请求、身份切换、状态变更的地方,加 console.log。调试完再删。本次开发中,正是靠 [Discuz] loginAs 验证返回 user: 日志才发现身份没切换的问题。
9.4 数据结构变化要同步改所有引用
从单站点改多站点时,所有 settings.xxx 都要改成 site.xxx,容易漏。改完搜一遍关键词,确认没有残留。
9.5 不要相信"这个优化不会有问题"
加缓存、加跳过、加合并请求,看起来是优化,实际上引入了新的状态和边界条件。除非有明确的性能数据支撑,否则不要优化。
十、最终文件清单
扩展后涉及的文件:
src/
├── main.ts # 入口,加载站点,注册 Ribbon
├── setting/
│ ├── plugin_settings.ts # SiteConfig + DiscuzSettings + parseAliases
│ └── setting_tab.ts # 站点列表 UI + 登录/退出 + 马甲设置 + 折叠
├── utils/
│ ├── request.ts # HttpUtils:签名、Token、loginAs
│ └── converter.ts # Markdown → BBCode
└── view/
└── push.ts # 发布弹窗:身份选择 + 发帖 + 回写十一、未来可扩展方向
- 图片上传:
/upload/post上传文件,拿 aid 后插入[attachimg]aid[/attachimg] - 更新已发布帖子:frontmatter 有 tid 时走编辑接口而非新建
- 发布前 BBCode 预览:弹窗里加折叠区显示转换结果
- 清理调试日志:发布前删掉
console.log - 删除帖子:调对应的删除接口
- 同步评论:拉取帖子回复到笔记
十二、参考
- Discuz RESTful API:
https://gitee.com/Discuz/discuz-restful-api - Obsidian 插件示例:
https://github.com/obsidianmd/obsidian-sample-plugin - Obsidian API 文档:
https://docs.obsidian.md/
暂无评论
还没有评论,来说点什么吧