用油猴脚本读写 WPS 多维表原始 JSON —— 探针探路与逐项实测


用油猴脚本读写 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 出来。核心机制有三步:

  1. 油猴往页面 head 注入一段 <script>;
  2. 注入脚本里 waitApi() 轮询 window.WPSOpenApi.Application 是否就绪(最多等 30s);
  3. 注入脚本和油猴面板不在同一沙箱,必须用 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);
    };
})();

文章作者: 摸鱼
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 摸鱼 !
赏
  目录