用油猴脚本读写 WPS 多维表原始 JSON —— 探针探路与逐项实测
结论先行
WPS 多维表官方主推三套玩法:AirScript 脚本、SDK 内嵌、HTTP 直连。但还有一条更省事的野路子:WPS 网页端打开多维表时,会在页面挂一个全局对象 window.WPSOpenApi,它直接暴露了 Application.Record.GetRecords / UpdateRecords 这些接口。只要用油猴脚本在页面里拿到它,就能读/写原始 JSON——不搭 SDK、不管 token、不写 AirScript。
实测结论(全部在我自己的「复习计划表」上跑通):
- 读取:
Application.Record.GetRecords({SheetId, PageSize, Offset})✅ - 写入:
Application.Record.UpdateRecords({SheetId, Records:[{id, fields}]})✅ - 关键前提:要在有编辑权限的文档页(
https://www.kdocs.cn/...)跑;在/l/只读分享链接上读没问题、写大概率被权限挡 - 最值钱的工具是一个「探针」脚本——它把
WPSOpenApi上到底挂了哪些方法一行行枚举出来,让我不用看文档就知道能调什么、参数怎么传(看报错反推)
整个过程就是:抓全局对象 → 探针枚举 → 报错驱动实测。官方文档没讲全的,让页面自己告诉你。
一、背景:我要干嘛
我用一个 WPS 多维表管做题的复习计划(间隔重复复习法)。表结构大概是:关联题目、关联做题记录、复习时间、复习阶段、已毕业、本阶段完成时间、下次复习间隔。
官方 App 里没有「一键标记今天复习完成」这种功能,我想直接写原始数据。最开始需求很简单:把某张表(tableId=9,复习计划表)的全部记录原始 JSON 读出来看看。
二、起点:一个读取脚本
最开始的脚本(v0.6)很朴素:页面挂个面板,点按钮用 WPSOpenApi.Application.Record.GetRecords(9) 把记录 dump 出来。核心机制有三步:
- 油猴往页面
head注入一段<script>; - 注入脚本里
waitApi()轮询window.WPSOpenApi.Application是否就绪(最多等 30s); - 注入脚本和油猴面板不在同一沙箱,必须用
CustomEvent当桥把结果传回面板(这是油猴操作页面全局对象的标准做法)。
起点脚本(完整,可直接装):
// ==UserScript==
// @name WPS读取多维表的原始json 复习计划面板 tableId=9
// @namespace http://tampermonkey.net/
// @version 0.6
// @description 数据表ID=9,dump记录
// @author You
// @match https://www.kdocs.cn/l/*
// @match https://365.kdocs.cn/l/*
// @grant none
// ==/UserScript==
(function () {
'use strict';
const panel = document.createElement('div');
panel.style = `position:fixed;top:10px;right:10px;width:480px;background:#fff;padding:12px;z-index:99999;border:1px solid #ccc;border-radius:8px;font-size:12px;max-height:88vh;overflow:auto;`;
panel.innerHTML = `
<h4>数据表 tableId=9 读取</h4>
<div>状态:<span id="readyStatus">等待注入...</span></div>
<button id="loadBtn" disabled>读取tableId=9全部记录</button>
<div id="outBox" style="margin-top:8px;white-space:pre-wrap;"></div>
`;
document.body.appendChild(panel);
const readyStatus = panel.querySelector('#readyStatus');
const loadBtn = panel.querySelector('#loadBtn');
const outBox = panel.querySelector('#outBox');
const EVENT_NAME = '__wps_api_msg__';
let apiReady = false;
window.addEventListener(EVENT_NAME, (e) => {
const data = e.detail;
if (data.type === 'ready') {
readyStatus.textContent = "✅ WPSOpenApi就绪";
loadBtn.disabled = false;
apiReady = true;
} else if (data.type === 'dump') {
outBox.innerText = data.text;
} else if (data.type === 'error') {
outBox.innerText = "❌错误:\n" + data.msg + "\n" + (data.stack || "");
}
});
const injectCode = `
(function(){
async function waitApi(){
for(let i=0;i<80;i++){
if(window.WPSOpenApi){
window.dispatchEvent(new CustomEvent('${EVENT_NAME}',{detail:{type:'ready'}}));
return;
}
await new Promise(r=>setTimeout(r,250));
}
window.dispatchEvent(new CustomEvent('${EVENT_NAME}',{detail:{type:'error',msg:'WPSOpenApi超时'}}));
}
window.__dumpAllRecords = async function(tableId){
try{
const api = window.WPSOpenApi;
const resp = await api.Application.Record.GetRecords(tableId);
let txt = "tableId:"+tableId+" 总记录数:"+resp.records.length+"\\n\\n";
for(let idx=0; idx < Math.min(6, resp.records.length); idx++){
const rec = resp.records[idx];
txt += "【第"+idx+"条】\\n";
txt += JSON.stringify(rec.fields, null, 2)+"\\n------\\n";
}
window.dispatchEvent(new CustomEvent('${EVENT_NAME}',{detail:{type:'dump', text:txt}}));
}catch(err){
window.dispatchEvent(new CustomEvent('${EVENT_NAME}',{
detail:{type:'error',msg:err.message, stack:err.stack}
}));
}
}
waitApi();
})();
`;
const scriptEl = document.createElement('script');
scriptEl.textContent = injectCode;
document.head.appendChild(scriptEl);
loadBtn.onclick = () => {
if (!apiReady) return;
outBox.innerText = "请求tableId=9中...";
const TABLE_ID = 9;
window.__dumpAllRecords(TABLE_ID);
};
})();
它能跑,但只解决「读」。接下来我想知道:还能写吗?参数怎么传?于是有了探针。
三、探针:怎么把页面里能用的 API 全列出来
官方文档对 WPSOpenApi 这个全局对象几乎没讲(它跟 SDK 实例是两套东西),靠猜不现实。办法是:直接枚举对象上挂的属性名。
JS 里一个对象哪怕被 Proxy 包过、是 class 实例,它的属性名也能通过 Object.getOwnPropertyNames 抓到;方法很多挂在原型链上,所以沿 getPrototypeOf 往上走几层。我把这个函数叫 keysOf:
// 枚举对象自身 + 原型链上的属性名,避免 Proxy / class 实例漏掉方法
function keysOf(obj, depth) {
if (obj === null || obj === undefined) return ['(空)'];
const set = new Set();
let cur = obj, d = 0;
while (cur && d < 4) { // 沿原型链走 4 层
try { Object.getOwnPropertyNames(cur).forEach(k => set.add(k)); } catch (e) {}
try { cur = Object.getPrototypeOf(cur); } catch (e) { cur = null; }
d++;
}
return Array.from(set).sort();
}
为什么沿原型链走 4 层?因为 Application、Application.Record 上的方法大多挂在原型上,只取一层 getOwnPropertyNames(obj) 会漏掉大半。
再配一个 safe 包装——调用可能抛错,把报错也记下来。这点很关键:报错信息本身就是情报(它告诉你 expected boolean、Record not found),往往比成功返回还有用。
async function safe(name, fn, limit) {
limit = limit || 2000;
try {
const v = await fn();
if (v === undefined) return name + ' => undefined';
if (typeof v === 'object') {
try { return name + ' => ' + JSON.stringify(v).slice(0, limit); }
catch (e) { return name + ' => [对象无法序列化] keys=' + keysOf(v).join(','); }
}
return name + ' => ' + String(v).slice(0, limit);
} catch (e) {
return name + ' => [抛错] ' + (e && e.message ? e.message : String(e));
}
}
探针里这么用:
out.push('WPSOpenApi keys: ' + keysOf(api).join(', '));
out.push('Application keys: ' + keysOf(app).join(', '));
out.push(await safe('Application.Record 的方法', async () => keysOf(app.Record).join(',')));
out.push(await safe('Application.Sheets 的方法', async () => keysOf(app.Sheets).join(','), 4000));
跑出来长这样(节选):
WPSOpenApi keys: ApiEvent, Application, CanIUse, ClientId, Config, DBApplication, FileInfo,
GetFileInfo, PluginConfig, Request, Store, Token, UserInfo, ...
Application.Record 的方法 => Application,CreateRecords,DeleteAllRecords,DeleteRecords,Each,
GetAttachmentURL,GetRecord,GetRecords,IsPorxy,UpdateRecords,...
Application.Sheets 的方法 => Add,Application,Count,Delete,Each,GetActiveSheetId,GetActiveSheetIndex,
IsPorxy,Item,ItemById,Move,OnCreateSheet,...
一眼就看到:Record 上除了 GetRecords 还有 UpdateRecords / CreateRecords / DeleteRecords / GetRecord;Sheets 上有 ItemById——这些就是后面读写、定位的入口。不用看一行文档,页面自己把家底亮出来了。
一个注意点:
ActiveSheet.Id / ActiveView.Id这类在部分环境下返回的是「代理函数源码」而不是值,别拿来判断。取表用Sheets.ItemById(id)最稳。
完整可运行的探针脚本见文末附录 A,直接装到油猴就能用。
四、一步步实测:搞清楚哪些能用、怎么用
光看到方法名不够,参数怎么传、返回值啥样,都得实测。每一条都是「先假设 → 调用 → 看报错/返回值 → 修正」逼出来的。
4.1 表定位:tableId 不等于索引
最开始写 Sheets(9) 想取表,直接报错 Index out of range: 9。探针里 Sheets 方法清单明明有 ItemById,改过来就通:
const sht = app.Sheets.ItemById(tableId); // tableId 是表 id(如 9)
const sht = app.Sheets(i); // i 是索引(1..Count)
实测表清单:
Sheets.Count => 6
Sheets(1) => 1 题目表
Sheets(5) => 9 复习计划表 <<< 当前目标
记牢:Sheets(i) 是索引,ItemById(x) 是表 id,GetRecords(x) 的 x 也是表 id。三处长得像,语义各不同。
4.2 记录 id 到底叫什么
getRecords 返回的记录对象,我以为是 recordId,实测是 undefined。把整条 JSON 打出来才发现字段叫 id:
{
"fields": {
"下次复习间隔": 7,
"关联做题记录": ["B-e"],
"关联题目": ["Bp4"],
"复习时间": "2026/09/01",
"复习阶段": 0.04,
"已毕业": false,
"本阶段完成时间": "2026/08/25"
},
"id": "CEh"
}
回写、单条读取都用这个 id:GetRecord(tableId, "CEh")。
4.3 分页:100 条上限 + 字符串游标
GetRecords 默认只返 100 条,但表有 165 条。试了 pageSize / limit / maxRecords / Top / Size / Count 等十几个 key,全被静默忽略(小写还不报错,最坑),最后发现大小写敏感,真 key 是 PageSize:
// 一次取全量
const r = await app.Record.GetRecords({ SheetId: tableId, PageSize: 500 });
// 翻页:Offset 是【字符串游标】,不是数字页码
const r2 = await app.Record.GetRecords({ SheetId: tableId, PageSize: 100, Offset: r.offset });
实测对比(这一行就是破局点):
{pageSize:500} => 100 条 ← 小写 p,静默忽略
{PageSize:500} => 165 条 ← 大写 P,生效
{Offset:100} => 抛错 expected string ← 不能是数字,得是游标字符串
offset 返回的是 "CKw" 这种字符串,代表「下一页从这条之后开始」。所以永远别用 GetRecords 的条数当记录总数,用 ActiveView.RecordRange.Count。
4.4 回写签名:一条条报错逼出来
UpdateRecords 第二个参数的形态,是这么试出来的:
UpdateRecords() → SheetId is required
UpdateRecords({}) → SheetId expected [string, number]
UpdateRecords(id, {}) → Records expected an array
UpdateRecords({SheetId:9, Records:[]}) → 不报错 ← 就是这个
UpdateRecords(id, []) → 不报错 ← 这种也行
最终正确形式:UpdateRecords({ SheetId: tableId, Records: [...] })
4.5 记录元素结构:{id, fields}
Records 数组里单条长啥样?靠报错定论:
Records:[{}] → Records[0].id is required
Records:[{id:"CEh"}] → Records[0].fields is required
Records:[{id:"CEh",fields:{}}] → ✅ 返回更新后的记录
Records:[{RecordId:"CEh",...}] → Records[0].id is required ← 不叫 RecordId
Records:[{id:"不存在的ID",...}] → Record not found ← 接口真查表,不是糊弄
结构就是 { id, fields }。而且 id 不存在会明确报 Record not found——说明接口真的在查表,比「静默成功」可靠多了。
⚠️ 一个教训:
CreateRecords传空fields也会真的建一条记录。我最初为探结构调了一次CreateRecords,结果表里多了一条垃圾FGI。禁止用 CreateRecords 探测参数。删除要这样试(每试一种回读一次):
app.Record.DeleteRecords({ SheetId: tableId, RecordIds: [recId] }) // 回读报 not found 即成功
4.6 字段可写性:公式字段写不进去
探针把字段清单打出来后,发现 7 个字段里有 2 个是公式类型:
字段id 字段名 类型
BR 复习阶段 Complete
Bx 复习时间 Formula ← 写不进
BB 本阶段完成时间 Date ✅ 可写
Bw 下次复习间隔 Formula ← 写不进
BE 关联题目 OneWayLink ✅ 可写
BF 关联做题记录 OneWayLink ✅ 可写
By 已毕业 Checkbox ✅ 可写
从样例反推出公式逻辑:复习时间 = 本阶段完成时间 + 下次复习间隔。所以回写只改「本阶段完成时间」一个字段就够了,复习时间会自动重算。
4.7 一个正常现象:公式重算有延迟(不是 bug)
实测标记完成后立即回读:本阶段完成时间 确实变成今天了(✓),但 复习时间 还是旧值(8/29,比今天还早)。一开始以为是 API 不触发重算,后来确认这只是重算延迟:WPS 的公式是在云端/前端异步计算的,写完立即读会拿到写入前的旧快照。
结论:写 本阶段完成时间 → 等几秒或刷新页面 → 复习时间 自动按 完成时间 + 间隔 算成新值。所以脚本马上回读时 复习时间 还是旧的属正常现象,校验逻辑把它降级为温和提示、不计成失败(见下一节),写完习惯性刷新一次复核即可。
五、最终成果:复习助手脚本
不想每次手填 JSON,于是把通用探测脚本做成业务专用的「复习助手」,三个按钮:清理垃圾记录、列出今天到期、一键标记完成(带回读校验;「复习时间」由公式重算,刚写完回读若仍是旧值仅作延迟提示、不算失败)。
核心读取 + 回写逻辑(节选):
// 读取全量(游标翻页)
async function readAll(tableId) {
let all = [], cursor = null;
while (true) {
const arg = { SheetId: tableId, PageSize: 500 };
if (cursor) arg.Offset = cursor;
const r = await App().Record.GetRecords(arg);
all = all.concat(r.records || []);
if (!r.offset || !r.records.length) break;
cursor = r.offset;
}
return all;
}
// 标记完成:只改「本阶段完成时间」,公式自动算复习时间
await app.Record.UpdateRecords({
SheetId: tableId,
Records: ids.map(id => ({ id, fields: { '本阶段完成时间': today } }))
});
// 回读校验时,主动抓「公式未重算」矛盾
const compDate = af['本阶段完成时间'], nextDue = af['复习时间'];
if (compDate && nextDue && String(nextDue) <= String(compDate)) {
// 复习时间没晚于完成时间 = 公式重算延迟(正常现象),仅提示、不判失败
}
完整脚本见文末附录 B(v2.2,可直接装到油猴用)。
六、给你的避坑清单(对照表)
| 能力 | 正确写法 | 状态 | 坑 |
|---|---|---|---|
| 等 API 就绪 | 轮询 window.WPSOpenApi.Application |
✅ | 最多等 30s,超时刷新页 |
| 读全部记录 | GetRecords({SheetId, PageSize:500, Offset}) |
✅ | PageSize 大写;默认只返 100 |
| 取单条 | GetRecord(tableId, id) |
✅ | id 是 id 不是 recordId |
| 定位表 | Sheets.ItemById(id) |
✅ | 别用 Sheets(数字) 当 id 用 |
| 写字段 | UpdateRecords({SheetId, Records:[{id, fields}]}) |
✅ | 元素必须含 id + fields |
| 建记录 | CreateRecords({SheetId, Records:[{fields}]}) |
✅ | 别拿来探结构,会真建垃圾 |
| 删记录 | DeleteRecords({SheetId, RecordIds:[id]}) |
✅ | 删完回读 not found 才算成 |
| 写公式字段 | — | ❌ | Formula / Complete 类型写不进 |
| 公式重算时机 | — | 🟡 | 写完立即回读仍是旧值,等几秒/刷新页自动重算 |
七、写在最后
这条路线适合「不想搭 SDK、不想管 token、就想在浏览器里直接读写自己多维表」的场景。核心就两步:用油猴脚本抓 window.WPSOpenApi + 用探针枚举 + 报错驱动实测。官方文档没讲全的,让页面自己告诉你。
完整可运行脚本也收在 WorkBuddy 的「WPS多维表」skill 的
scripts/目录里(wpsopenapi-page-review-helper.user.js等),需要可直接取用。
附录 A:API 探针脚本(独立可跑,用来枚举页面能用的所有接口)
// ==UserScript==
// @name WPS多维表 API 探针(枚举 window.WPSOpenApi 可用接口)
// @namespace http://tampermonkey.net/
// @version 1.0
// @description 把页面里 WPSOpenApi 上能用的 API 全列出来
// @match https://www.kdocs.cn/l/*
// @match https://www.kdocs.cn/*
// @match https://365.kdocs.cn/*
// @grant none
// ==/UserScript==
(function () {
'use strict';
const EV = '__wps_probe__';
const panel = document.createElement('div');
panel.style = 'position:fixed;top:10px;right:10px;width:560px;background:#fff;padding:12px;z-index:99999;border:1px solid #ccc;border-radius:8px;font-size:12px;max-height:92vh;overflow:auto;';
panel.innerHTML = `
<h4>WPSOpenApi API 探针</h4>
<div>状态:<span id="st">等待注入...</span></div>
<label>表 id <input id="tid" value="9" style="width:50px;"></label>
<button id="p" disabled>枚举 API</button>
<button id="c">复制</button>
<div id="o" style="white-space:pre-wrap;font-family:Consolas,monospace;font-size:11px;background:#f7f7f7;padding:8px;margin-top:8px;max-height:60vh;overflow:auto;"></div>
`;
document.body.appendChild(panel);
const $ = id => panel.querySelector('#' + id);
window.addEventListener(EV, e => {
const d = e.detail;
if (d.type === 'ready') { $('st').textContent = '就绪'; $('st').style.color = 'green'; $('p').disabled = false; }
else if (d.type === 'out') { $('o').innerText = d.text; }
});
const inj = `
(function(){
const EV='${EV}';
const post=t=>window.dispatchEvent(new CustomEvent(EV,{detail:{type:'out',text:t}}));
function keysOf(o){ if(o==null) return ['(空)']; const s=new Set(); let c=o,d=0;
while(c&&d<4){ try{Object.getOwnPropertyNames(c).forEach(k=>s.add(k));}catch(e){} try{c=Object.getPrototypeOf(c);}catch(e){c=null;} d++; }
return Array.from(s).sort(); }
async function safe(n,fn){ try{ const v=await fn(); return n+' => '+(typeof v==='object'?JSON.stringify(v).slice(0,2000):String(v).slice(0,2000)); }
catch(e){ return n+' => [抛错] '+(e&&e.message?e.message:String(e)); } }
async function wait(){ for(let i=0;i<120;i++){ if(window.WPSOpenApi&&window.WPSOpenApi.Application){ window.dispatchEvent(new CustomEvent(EV,{detail:{type:'ready'}})); return;} await new Promise(r=>setTimeout(r,250)); }
window.dispatchEvent(new CustomEvent(EV,{detail:{type:'out',text:'超时:当前页可能不是多维表或尚未加载完'}})); }
window.__probe=async function(tid){
const api=window.WPSOpenApi, app=api.Application, out=[];
out.push('=== WPSOpenApi API 探针 ==='); out.push('URL: '+location.href); out.push('');
out.push('--- 顶层对象能力 ---');
out.push('WPSOpenApi keys: '+keysOf(api).join(', '));
out.push('Application keys: '+keysOf(app).join(', ')); out.push('');
out.push(await safe('Application.Record 的方法', ()=>keysOf(app.Record).join(',')));
out.push(await safe('Application.Sheets 的方法', ()=>keysOf(app.Sheets).join(',')));
out.push(await safe('Application.View 的方法', ()=>keysOf(app.View).join(',')));
out.push(await safe('Application.Sheet 的方法', ()=>keysOf(app.Sheet).join(','))); out.push('');
out.push('--- 表清单(Sheets(i) 的 i 是索引,第二列才是表 id)---');
out.push(await safe('Sheets.Count', ()=>app.Sheets.Count));
try{ const cnt=await app.Sheets.Count; for(let i=1;i<=Math.min(cnt,30);i++){ out.push(await safe('Sheets('+i+')', async()=>{ const s=app.Sheets(i); return (await s.Id)+' '+(await s.Name)+(String(await s.Id)===String(tid)?' <<< 目标':''); })); } }catch(e){ out.push('枚举失败: '+e.message); }
out.push('');
out.push('--- 字段清单(ItemById 取表,FieldDescriptors 含隐藏字段)---');
try{ const sht=app.Sheets.ItemById(tid); const fds=await sht.FieldDescriptors; const cnt=await fds.Count;
for(let i=1;i<=cnt;i++){ const f=fds.Item(i); out.push(i+' '+(await f.Id)+' '+(await f.Name)+' '+(await f.Type)); } }catch(e){ out.push('字段清单失败: '+e.message); }
out.push('');
out.push('--- 样例记录(第1条,看 id 真实字段名)---');
try{ const r=await app.Record.GetRecords(tid); out.push('记录数(默认上限)='+(r.records||[]).length); if(r.records&&r.records[0]) out.push(JSON.stringify(r.records[0],null,2)); }catch(e){ out.push('读取失败: '+e.message); }
out.push(''); out.push('=== 结束(点"复制"导出)===');
post(out.join('\\n'));
};
wait();
})();
`;
const s = document.createElement('script'); s.textContent = inj; document.head.appendChild(s);
$('p').onclick = () => { if ($('p').disabled) return; window.__probe(Number($('tid').value)); };
$('c').onclick = () => { const t = $('o').innerText; if (navigator.clipboard) navigator.clipboard.writeText(t); };
})();
附录 B:复习助手脚本 v2.2(清理 / 到期清单 / 一键完成,带校验)
// ==UserScript==
// @name WPS多维表 复习助手 v2.2(清理 / 到期清单 / 一键完成)
// @namespace http://tampermonkey.net/
// @version 2.1
// @description 删除探测产生的空记录 + 列出今天到期 + 批量标记复习完成(带校验)
// @match https://www.kdocs.cn/l/*
// @match https://www.kdocs.cn/*
// @grant none
// ==/UserScript==
(function () {
'use strict';
const EVENT_NAME = '__wps_rv_msg__';
const panel = document.createElement('div');
panel.style = 'position:fixed;top:10px;right:10px;width:600px;background:#fff;padding:12px;z-index:99999;border:1px solid #ccc;border-radius:8px;font-size:12px;max-height:92vh;overflow:auto;box-shadow:0 4px 16px rgba(0,0,0,.15);';
panel.innerHTML = `
<h4 style="margin:0 0 8px;">复习助手 v2.2</h4>
<div>状态:<span id="readyStatus">等待注入...</span></div>
<hr style="margin:8px 0;">
<label>表 id <input id="tableId" value="9" style="width:60px;"></label>
<div style="background:#fff4f4;border:1px solid #f5c2c2;padding:8px;border-radius:4px;margin:8px 0;">
<div style="font-weight:bold;color:#c00;">① 清理垃圾记录</div>
<div style="color:#888;margin:4px 0;">B 探测时 CreateRecords 真的建了一条空记录。<b>先删掉它。</b></div>
记录 id:<input id="delId" value="FGI" style="width:100px;">
<button id="delBtn" disabled>删除</button>
</div>
<hr style="margin:8px 0;">
<div style="font-weight:bold;">② 今天到期清单</div>
<button id="dueBtn" disabled>列出待复习</button>
<div style="color:#888;">输出 id 列表,可直接复制到下面</div>
<hr style="margin:8px 0;">
<div style="font-weight:bold;">③ 标记复习完成</div>
<textarea id="idList" rows="3" style="width:100%;font-size:11px;" placeholder="CEh,CEl,CEp (逗号或空格分隔,留空=全部到期)"></textarea>
<div>
<label><input type="checkbox" id="upStage"> 同时阶段 +0.01</label>
<label><input type="checkbox" id="gradChk"> 到 0.05 且勾选此项则标毕业</label>
</div>
<div style="color:#888;">默认只改「本阶段完成时间」=今天,「复习时间」由公式自动重算。</div>
<button id="doneBtn" disabled>标记完成并校验</button>
<button id="rbBtn" disabled>回滚上一次</button>
<div style="color:#888;">改之前会先存快照,改错了点回滚</div>
<hr style="margin:8px 0;">
<div style="margin-bottom:6px;">
<button id="copyBtn">复制结果</button>
<button id="copyCodeBtn">复制为代码块</button>
<span id="copyTip" style="color:green;margin-left:8px;"></span>
</div>
<div id="outBox" style="white-space:pre-wrap;word-break:break-all;font-family:Consolas,monospace;font-size:11px;background:#f7f7f7;padding:8px;border-radius:4px;max-height:340px;overflow:auto;"></div>
`;
document.body.appendChild(panel);
const $ = id => panel.querySelector('#' + id);
const outBox = $('outBox'), copyTip = $('copyTip');
let apiReady = false;
window.addEventListener(EVENT_NAME, e => {
const d = e.detail;
if (d.type === 'ready') {
$('readyStatus').textContent = '就绪'; $('readyStatus').style.color = 'green';
['delBtn', 'dueBtn', 'doneBtn'].forEach(b => $(b).disabled = false);
apiReady = true;
} else if (d.type === 'result') { outBox.innerText = d.text; copyTip.textContent = ''; }
else if (d.type === 'progress') { outBox.innerText = d.text; }
else if (d.type === 'error') { outBox.innerText = '[错误] ' + d.msg + '\n' + (d.stack || ''); copyTip.textContent = ''; }
else if (d.type === 'confirm') {
if (confirm(d.msg)) window['__' + d.fn](...d.args);
}
});
function copyCurrent(asCode) {
const text = outBox.innerText;
if (!text.trim()) return;
const payload = asCode ? '```\n' + text + '\n```' : text;
const done = () => { copyTip.textContent = '已复制 ' + payload.length + ' 字符'; copyTip.style.color = 'green'; setTimeout(() => copyTip.textContent = '', 3000); };
const fb = () => {
const ta = document.createElement('textarea');
ta.value = payload; ta.style.position = 'fixed'; ta.style.left = '-9999px';
document.body.appendChild(ta); ta.select();
let ok = false; try { ok = document.execCommand('copy'); } catch (e) {}
document.body.removeChild(ta);
ok ? done() : (copyTip.textContent = '复制失败', copyTip.style.color = '#c00');
};
(navigator.clipboard && navigator.clipboard.writeText) ? navigator.clipboard.writeText(payload).then(done).catch(fb) : fb();
}
$('copyBtn').onclick = () => copyCurrent(false);
$('copyCodeBtn').onclick = () => copyCurrent(true);
const injectCode = `
(function(){
const EV = '${EVENT_NAME}';
const post = t => window.dispatchEvent(new CustomEvent(EV,{detail:{type:'result',text:t}}));
const prog = t => window.dispatchEvent(new CustomEvent(EV,{detail:{type:'progress',text:t}}));
const fail = e => window.dispatchEvent(new CustomEvent(EV,{detail:{type:'error',msg:e&&e.message?e.message:String(e),stack:e&&e.stack?e.stack:''}}));
const App = () => window.WPSOpenApi.Application;
const todayStr = () => {
const n = new Date();
return n.getFullYear() + '/' + String(n.getMonth()+1).padStart(2,'0') + '/' + String(n.getDate()).padStart(2,'0');
};
async function waitApi(){
for (let i=0;i<120;i++){
if (window.WPSOpenApi && window.WPSOpenApi.Application){
window.dispatchEvent(new CustomEvent(EV,{detail:{type:'ready'}}));
return;
}
await new Promise(r=>setTimeout(r,250));
}
window.dispatchEvent(new CustomEvent(EV,{detail:{type:'error',msg:'WPSOpenApi 超时'}}));
}
async function readAll(tableId){
let all = [], cursor = null, page = 0;
while (true){
const arg = { SheetId: tableId, PageSize: 500 };
if (cursor) arg.Offset = cursor;
const r = await App().Record.GetRecords(arg);
all = all.concat(r.records || []);
page++;
if (!r.offset || !(r.records||[]).length || page >= 50) break;
cursor = r.offset;
}
window.__ALL = all;
return all;
}
// ============ ① 删除记录 ============
window.__deleteRec = async function(tableId, recId){
const log = []; const push = s => { log.push(s); prog(log.join('\\n')); };
try {
const app = App();
push('=== 删除记录 ' + recId + '(表 ' + tableId + ')===');
try {
const r = await app.Record.GetRecord(tableId, recId);
push('该记录当前内容:' + JSON.stringify(r.fields));
push('(确认是空记录再删。如果不是,请取消本次操作)');
} catch(e){ push('读不到该记录,可能已被删除:' + (e.message||e)); }
const forms = [
['{SheetId,RecordIds:[id]}', () => app.Record.DeleteRecords({SheetId:tableId, RecordIds:[recId]})],
['{SheetId,Records:[id]}', () => app.Record.DeleteRecords({SheetId:tableId, Records:[recId]})],
['{SheetId,Ids:[id]}', () => app.Record.DeleteRecords({SheetId:tableId, Ids:[recId]})],
['(id,[id])', () => app.Record.DeleteRecords(tableId, [recId])],
['{SheetId,RecordIds:[{id}]}',() => app.Record.DeleteRecords({SheetId:tableId, RecordIds:[{id:recId}]})]
];
for (const [name, fn] of forms){
try {
const r = await fn();
push('✅ ' + name + ' => 未抛错 ' + JSON.stringify(r === undefined ? '' : r).slice(0,150));
try {
await app.Record.GetRecord(tableId, recId);
push(' ⚠ 回读发现记录还在,这个形式没真删,继续试下一种');
} catch(e2){
push(' ✅ 回读报 not found,删除成功!');
push('');
push('可用形式:' + name);
post(log.join('\\n'));
return;
}
} catch(e){
push('❌ ' + name + ' => ' + (e.message||e));
}
}
post(log.join('\\n'));
} catch(e){ fail(e); }
};
// ============ ② 今天到期清单 ============
window.__listDue = async function(tableId){
const log = []; const push = s => { log.push(s); prog(log.join('\\n')); };
try {
const all = await readAll(tableId);
const today = todayStr();
const due = all.filter(r => {
const f = r.fields || {};
return f['复习时间'] && !f['已毕业'] && String(f['复习时间']) <= today;
});
due.sort((a,b) => String(a.fields['复习时间']).localeCompare(String(b.fields['复习时间'])));
push('=== 今天到期(' + today + ')===');
push('共 ' + due.length + ' 条,总记录 ' + all.length + ' 条');
push('');
push('--- id 列表(可直接复制到 ③)---');
push(due.map(r => r.id).join(','));
push('');
push('--- 明细 ---');
due.forEach((r,i) => {
const f = r.fields || {};
push(String(i+1).padStart(3) + ' ' + r.id.padEnd(6)
+ ' 到期=' + String(f['复习时间']).padEnd(12)
+ ' 阶段=' + String(f['复习阶段']).padEnd(6)
+ ' 间隔=' + String(f['下次复习间隔']).padEnd(4)
+ ' 题=' + JSON.stringify(f['关联题目'] || []));
});
window.__DUE_IDS = due.map(r => r.id);
post(log.join('\\n'));
} catch(e){ fail(e); }
};
// ============ ③ 标记完成 ============
window.__markDone = async function(tableId, idList, upStage, gradChk){
const log = []; const push = s => { log.push(s); prog(log.join('\\n')); };
try {
const app = App();
const today = todayStr();
const all = await readAll(tableId);
const byId = {};
all.forEach(r => byId[r.id] = r);
let ids = idList;
if (!ids || !ids.length){
ids = all.filter(r => {
const f = r.fields || {};
return f['复习时间'] && !f['已毕业'] && String(f['复习时间']) <= today;
}).map(r => r.id);
}
if (!ids.length){ post('没有需要处理的记录'); return; }
push('=== 标记复习完成 ===');
push('目标 ' + ids.length + ' 条 今天=' + today
+ ' 升阶=' + (upStage ? '是' : '否') + ' 毕业=' + (gradChk ? '是' : '否'));
push('');
const recs = [];
const plan = [];
ids.forEach(id => {
const r = byId[id];
if (!r){ push('⚠ 找不到记录 ' + id + ',跳过'); return; }
const f = r.fields || {};
const fields = { '本阶段完成时间': today };
let note = '日期→' + today;
if (upStage){
const cur = Number(f['复习阶段']) || 0;
let next = Math.round((cur + 0.01) * 100) / 100;
if (next > 0.05) next = 0.05;
fields['复习阶段'] = next;
note += ' 阶段 ' + cur + '→' + next;
if (gradChk && next >= 0.05){
fields['已毕业'] = true;
note += ' 【毕业】';
}
}
recs.push({ id: id, fields: fields });
plan.push([id, fields, note]);
});
if (!recs.length){ post('没有可提交的记录'); return; }
push('--- 提交计划 ---');
plan.forEach(([id, f, note]) => push(' ' + id.padEnd(6) + note));
await app.Record.UpdateRecords({ SheetId: tableId, Records: recs });
push('');
push('提交完成,接口未抛错。');
push('');
push('--- 回读校验 ---');
let okCount = 0, badCount = 0, formulaWarn = 0;
for (const [id, fields] of plan){
try {
const r = await app.Record.GetRecord(tableId, id);
const af = r.fields || {};
const checks = [];
let ok = true;
for (const k of Object.keys(fields)){
const same = JSON.stringify(af[k]) === JSON.stringify(fields[k]);
if (!same) ok = false;
checks.push(k + '=' + JSON.stringify(af[k]) + (same ? '✓' : '✗期望' + JSON.stringify(fields[k])));
}
const compDate = af['本阶段完成时间'];
const nextDue = af['复习时间'];
// 公式字段(复习时间)重算有延迟:写完立即回读仍是旧快照,属正常现象,
// 等几秒或刷新页面会按 完成时间+间隔 自动重算。仅作提示,不判失败。
if (compDate && nextDue && String(nextDue) <= String(compDate)){
checks.push('新复习时间=' + nextDue + ' (公式重算中,稍后刷新页面会更新)');
formulaWarn++;
} else {
checks.push('新复习时间=' + nextDue);
}
push((ok ? '✅ ' : '⚠ ') + id.padEnd(6) + checks.join(' '));
ok ? okCount++ : badCount++;
} catch(e){ push('ERR ' + id + ' 回读失败: ' + (e.message||e)); badCount++; }
}
push('');
push('');
let summary = '===== 成功 ' + okCount + ' 条,异常 ' + badCount + ' 条';
if (formulaWarn > 0) summary += ',另 ' + formulaWarn + ' 条公式重算延迟(提示:刷新页复核即可,非失败)';
push(summary + ' =====');
post(log.join('\\n'));
} catch(e){ fail(e); }
};
waitApi();
})();
`;
const s = document.createElement('script');
s.textContent = injectCode;
document.head.appendChild(s);
const parseIds = str => (str || '').split(/[,,\s;;]+/).map(x => x.trim()).filter(Boolean);
$('delBtn').onclick = () => {
if (!apiReady) return;
const id = $('delId').value.trim();
if (!id) return;
if (!confirm('确认删除记录 ' + id + ' ?此操作不可撤销。')) return;
outBox.innerText = '删除中...';
window.__deleteRec(Number($('tableId').value), id);
};
$('dueBtn').onclick = () => { if (!apiReady) return; outBox.innerText = '读取中...'; window.__listDue(Number($('tableId').value)); };
$('doneBtn').onclick = () => {
if (!apiReady) return;
const ids = parseIds($('idList').value);
const n = ids.length;
const msg = n ? ('确认把 ' + n + ' 条记录标记为今天复习完成?') : ('没填 id,将处理【全部今天到期】的记录,确认?');
if (!confirm(msg)) return;
outBox.innerText = '处理中...';
window.__markDone(Number($('tableId').value), ids, $('upStage').checked, $('gradChk').checked);
};
})();