// ==UserScript==
// @name 腾讯文档-隐藏列内容查看器
// @namespace https://docs.qq.com/
// @version 0.4.19
// @description 在腾讯文档表格页显示列实际内容,并在原网页展开/还原这些列
// @author codex
// @match https://docs.qq.com/sheet/*
// @grant GM_setClipboard
// @grant unsafeWindow
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
const PANEL_ID = 'tm-hidden-col-panel';
const STYLE_ID = 'tm-hidden-col-style';
const LAUNCHER_ID = 'tm-hidden-col-launcher';
const DEFAULT_TARGET_COLS = [25, 26, 27, 28, 29, 30]; // Z~AE (0-based)
const PREVIEW_IMAGE_WIDTH = 320;
const COL_ORDER_STORAGE_PREFIX = '__TM_HIDDEN_COL_ORDER__';
const PAGE_WIN = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window;
const DRIVE_URL_CACHE = new Map();
let SHEET_WEBPACK_REQUIRE = null;
let OPS_FETCH_CHAIN = Promise.resolve();
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function getSheetWebpackRequire() {
if (typeof SHEET_WEBPACK_REQUIRE === 'function') return SHEET_WEBPACK_REQUIRE;
const chunk = PAGE_WIN.webpackChunk_tencent_sheet;
if (!Array.isArray(chunk) || typeof chunk.push !== 'function') return null;
let req = null;
try {
const marker = `tm_probe_${Date.now()}_${Math.random()}`;
chunk.push([[marker], {}, (r) => { req = r; }]);
} catch (_) {
return null;
}
if (typeof req !== 'function') return null;
SHEET_WEBPACK_REQUIRE = req;
return SHEET_WEBPACK_REQUIRE;
}
function runOpsFetchSerial(task) {
const run = OPS_FETCH_CHAIN.then(task, task);
OPS_FETCH_CHAIN = run.catch(() => { });
return run;
}
async function withTimeout(task, timeoutMs, onTimeoutValue) {
const ms = Math.max(1, Number(timeoutMs) || 1);
let timer = null;
try {
return await Promise.race([
Promise.resolve().then(task),
new Promise((resolve) => {
timer = setTimeout(() => resolve(onTimeoutValue), ms);
})
]);
} finally {
if (timer) clearTimeout(timer);
}
}
async function waitForRevisionRuntime(app, timeoutMs = 4000) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const fcs = app?.formulaCalcService;
let calc = null;
try {
calc = fcs?.cellHistoryCalculator;
} catch (_) {
calc = null;
}
if (
fcs &&
calc &&
typeof fcs.startServiceCheck === 'function' &&
typeof calc._cTC === 'function' &&
typeof calc._cTd === 'function' &&
typeof calc.clearCache === 'function'
) {
return { fcs, calc };
}
await sleep(80);
}
return null;
}
function getColumnOrderStorageKey(app) {
const padId = String(PAGE_WIN?.docInfo?.padInfo?.padId || '').trim();
let sheetId = '';
try {
sheetId = String(app?.view?.getActiveCell?.()?.sheetId || '').trim();
} catch (_) { }
if (!sheetId) {
try {
const u = new URL(String(location.href || ''));
sheetId = String(u.searchParams.get('tab') || '').trim();
} catch (_) { }
}
return `${COL_ORDER_STORAGE_PREFIX}:${padId || location.pathname}:${sheetId || 'default'}`;
}
function loadColumnOrder(key) {
if (!key) return [];
try {
const raw = localStorage.getItem(key);
if (!raw) return [];
const arr = JSON.parse(raw);
if (!Array.isArray(arr)) return [];
const out = [];
const seen = new Set();
for (const x of arr) {
const n = Number(x);
if (!Number.isInteger(n) || seen.has(n)) continue;
seen.add(n);
out.push(n);
}
return out;
} catch (_) {
return [];
}
}
function saveColumnOrder(key, cols) {
if (!key) return;
try {
const out = [];
const seen = new Set();
for (const x of Array.from(cols || [])) {
const n = Number(x);
if (!Number.isInteger(n) || n < 0 || seen.has(n)) continue;
seen.add(n);
out.push(n);
}
localStorage.setItem(key, JSON.stringify(out));
} catch (_) { }
}
function waitForSpreadsheetApp(timeoutMs = 30000) {
return new Promise((resolve, reject) => {
const started = Date.now();
const timer = setInterval(() => {
const app = PAGE_WIN.SpreadsheetApp;
const sheet = app?.workbook?.activeSheet;
if (sheet && typeof sheet.getCellDataAtPosition === 'function') {
clearInterval(timer);
resolve({ app, sheet });
return;
}
if (Date.now() - started > timeoutMs) {
clearInterval(timer);
reject(new Error('SpreadsheetApp not ready'));
}
}, 500);
});
}
function colToLetter(col0) {
let n = col0 + 1;
let s = '';
while (n > 0) {
const m = (n - 1) % 26;
s = String.fromCharCode(65 + m) + s;
n = Math.floor((n - 1) / 26);
}
return s;
}
function textOfCell(cell) {
if (!cell) return '';
const fv = cell.formattedValue;
if (fv && typeof fv === 'object' && fv.value != null) return String(fv.value);
if (typeof fv === 'string' || typeof fv === 'number' || typeof fv === 'boolean') return String(fv);
const v = cell.value;
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return String(v);
if (v && typeof v === 'object') {
if (v.value != null) return String(v.value);
if (v.text != null) return String(v.text);
if (v.formulaResult != null) return String(v.formulaResult);
}
return '';
}
function escapeHtml(str) {
return String(str)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('\"', '"')
.replaceAll("'", ''');
}
function getTokXsrf() {
const m = document.cookie.match(/(?:^|;\s*)TOK=([^;]+)/);
return m ? decodeURIComponent(m[1]) : '';
}
function parseDriveObjKey(driveUrl) {
const m = String(driveUrl || '').match(/^drive:\/\/([^/]+)\//);
return m ? m[1] : '';
}
async function resolveDriveUrl(driveUrl) {
const key = String(driveUrl || '').trim();
if (!key) return '';
if (DRIVE_URL_CACHE.has(key)) return DRIVE_URL_CACHE.get(key);
const objKey = parseDriveObjKey(key);
const fileId = PAGE_WIN?.docInfo?.padInfo?.padId || '';
const xsrf = getTokXsrf();
if (!objKey || !fileId || !xsrf) {
DRIVE_URL_CACHE.set(key, key);
return key;
}
try {
const resp = await fetch(`/v2/drive/fileInfo?xsrf=${encodeURIComponent(xsrf)}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ file_id: fileId, xsrf, obj_key: objKey })
});
const json = await resp.json();
const thumb = json?.result?.fileMeta?.thumbnailUrl || '';
const raw = thumb ? thumb.replace(/&imageMogr2[\s\S]*$/, '') : '';
const finalUrl = raw || key;
DRIVE_URL_CACHE.set(key, finalUrl);
return finalUrl;
} catch (_) {
DRIVE_URL_CACHE.set(key, key);
return key;
}
}
async function resolveUrls(urls) {
if (!Array.isArray(urls) || !urls.length) return [];
const out = [];
for (const u of urls) {
if (!u) continue;
if (/^drive:\/\//i.test(u)) {
out.push(await resolveDriveUrl(u));
} else {
out.push(u);
}
}
return [...new Set(out.filter(Boolean))];
}
function isPreviewableImageUrl(url) {
if (typeof url !== 'string') return false;
const u = url.trim();
if (!u) return false;
if (/^https?:\/\/docimg\d*\.docs\.qq\.com\/image\//i.test(u)) return true;
if (/^https?:\/\/drive\.docs\.qq\.com\//i.test(u)) return true;
if (/\.(png|jpe?g|gif|webp|bmp|svg)([?#].*)?$/i.test(u)) return true;
return false;
}
function valueHtml(item) {
if (Array.isArray(item.resolvedUrls) && item.resolvedUrls.length) {
const imageUrls = item.resolvedUrls.filter(isPreviewableImageUrl);
if (imageUrls.length) {
return `
${imageUrls
.map((u) => {
const s = escapeHtml(u);
return `
图片预览(点击打开)`;
})
.join('')}
`;
}
return item.resolvedUrls
.map((u) => `${escapeHtml(u)}`)
.join('
');
}
return escapeHtml(item.value || '');
}
function actionTypeLabel(actionType) {
const n = Number(actionType);
if (n === 1) return '新增';
if (n === 2) return '删除';
if (n === 3) return '修改';
return Number.isFinite(n) ? `类型${n}` : '未知';
}
function formatLocalTime(ts) {
const n = Number(ts);
if (!Number.isFinite(n) || n <= 0) return '';
try {
return new Date(n).toLocaleString('zh-CN', { hour12: false });
} catch (_) {
return '';
}
}
function cellDeltaText(delta) {
if (delta == null) return '';
if (typeof delta === 'string' || typeof delta === 'number' || typeof delta === 'boolean') return String(delta);
if (typeof delta !== 'object') return '';
const fv = delta.formattedValue;
if (fv && typeof fv === 'object' && fv.value != null) return String(fv.value);
if (typeof fv === 'string' || typeof fv === 'number' || typeof fv === 'boolean') return String(fv);
const v = delta.value;
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return String(v);
if (v && typeof v === 'object') {
if (v.value != null) return String(v.value);
if (v.text != null) return String(v.text);
if (v.formulaResult != null) return String(v.formulaResult);
}
return '';
}
function opDetailText(item) {
const before = String(item.beforeValue || '').trim();
const after = String(item.afterValue || '').trim();
if (before || after) return `${before || '(空)'} -> ${after || '(空)'}`;
return '';
}
function mutationActionType(typeName) {
const t = String(typeName || '').toLowerCase();
if (/insert|add|create/.test(t)) return 1;
if (/delete|remove|clear/.test(t)) return 2;
return 3;
}
function mutationTypeName(typeMap, type) {
const n = Number(type);
if (!Number.isFinite(n)) return '类型?';
if (typeMap && typeof typeMap === 'object') {
for (const [k, v] of Object.entries(typeMap)) {
if (Number(v) === n) return k;
}
}
return `类型${n}`;
}
function isRowInRange(row0, startRow, endRow) {
const sr = Number(startRow);
const er = Number(endRow);
if (!Number.isFinite(sr) || !Number.isFinite(er)) return false;
if (er > sr) return row0 >= sr && row0 < er;
return row0 === sr;
}
function parseMutationRowCells(mutation, row0, currentSheetId) {
const cells = [];
if (!mutation || typeof mutation !== 'object') return cells;
const expectedSheetId = String(currentSheetId || '').trim();
const g = mutation.gridRangeData || {};
const mutationSheetId = String(
g?.sheetId != null
? g.sheetId
: (typeof mutation.getTargetSheetId === 'function' ? mutation.getTargetSheetId() : '')
).trim();
if (expectedSheetId && mutationSheetId && mutationSheetId !== expectedSheetId) return cells;
if (Array.isArray(mutation.cellAtPositions)) {
for (const at of mutation.cellAtPositions) {
const r = Number(at?.rowIndex);
if (r !== row0) continue;
const c = Number(at?.colIndex);
cells.push({
colIndex0: Number.isFinite(c) ? c : -1,
value: textOfCell(at?.cell)
});
}
}
if (cells.length) return cells;
const sr = Number(g.startRowIndex);
const sc = Number(g.startColIndex);
if (
mutation.cell &&
Number.isFinite(sr) &&
isRowInRange(row0, g.startRowIndex, g.endRowIndex)
) {
cells.push({
colIndex0: Number.isFinite(sc) ? sc : -1,
value: textOfCell(mutation.cell)
});
}
return cells;
}
async function fetchCurrentRowOperationRecordsByChanges({
requireFn, axios, domain, padId, xsrf, row0, latestRev, sheetId
}) {
try {
const revUtils = requireFn(473546);
if (!revUtils?.tq || !revUtils?.oB) {
return { ok: false, error: '变更流解析器不可用' };
}
const mutationMap = requireFn(633935)?.D || {};
let cursor = Number(latestRev);
if (!Number.isFinite(cursor) || cursor <= 0) {
try {
const { data } = await axios.get(`//${domain}/dop-api/get/history`, {
params: { padId, page: 1, _r: Math.floor(Math.random() * 10000), xsrf }
});
cursor = Number(data?.history?.items?.[0]?.i || 0);
} catch (_) {
cursor = 0;
}
}
if (!Number.isFinite(cursor) || cursor <= 0) {
return { ok: false, error: '变更流无可用修订号' };
}
const out = [];
const seen = new Set();
const windowSize = 50;
const maxWindows = 8;
const maxRecords = 120;
const maxDurationMs = 12000;
const startedAt = Date.now();
let to = cursor;
for (
let w = 0;
w < maxWindows &&
to > 0 &&
out.length < maxRecords &&
(Date.now() - startedAt) < maxDurationMs;
w += 1
) {
const from = Math.max(1, to - windowSize + 1);
let result = null;
for (let attempt = 0; attempt < 2 && !result; attempt += 1) {
try {
const { data } = await axios.get(`//${domain}/dop-api/get/changes`, {
params: { from, to, xsrf, padId, needs_author_info: true }
});
result = data?.result || null;
} catch (_) {
if (attempt < 1) await sleep(120);
}
}
const authors = result?.authors || {};
const changes = Array.isArray(result?.changes) ? result.changes : [];
for (const it of changes) {
const d = it?.data || {};
if (!d?.changeset) continue;
let mutations = [];
try {
const rev = revUtils.tq(String(d.changeset || ''));
mutations = revUtils.oB(rev) || [];
} catch (_) {
continue;
}
for (const mut of mutations) {
const rowCells = parseMutationRowCells(mut, row0, sheetId);
if (!rowCells.length) continue;
const typeName = mutationTypeName(mutationMap, mut?.type);
const authorId = String(d?.author || '');
const authorName = String(
authors?.[authorId]?.Name ||
authors?.[authorId]?.name ||
authorId ||
'-'
);
const byCol = new Map();
for (const c of rowCells) {
const key = Number.isFinite(c?.colIndex0) ? c.colIndex0 : -1;
if (!byCol.has(key)) byCol.set(key, []);
byCol.get(key).push(String(c?.value || '').trim());
}
for (const [colIndex0, values] of byCol.entries()) {
const uniqValues = [...new Set(values.filter((x) => x !== ''))];
const detail = uniqValues.length ? uniqValues.join(' | ') : typeName;
const key = [
row0, colIndex0, Number(d?.time || 0), Number(mut?.type || 0), authorId, detail
].join('|');
if (seen.has(key)) continue;
seen.add(key);
out.push({
rowIndex0: row0,
colIndex0,
modifiedDateTime: Number(d?.time || 0),
actionType: mutationActionType(typeName),
authorId,
authorName,
beforeValue: '',
afterValue: detail
});
}
}
}
if (from <= 1) break;
to = from - 1;
}
out.sort((a, b) => (a.modifiedDateTime || 0) - (b.modifiedDateTime || 0));
return {
ok: true,
records: out,
exactCount: out.length,
totalCount: out.length,
source: 'changes'
};
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
}
async function fetchCurrentRowOperationRecords(app, sheet, row0) {
try {
const requireFn = getSheetWebpackRequire();
const runtime = await waitForRevisionRuntime(app, 4500);
const fcs = runtime?.fcs;
const calc = runtime?.calc;
if (typeof requireFn !== 'function' || !fcs || !calc) {
return { ok: false, error: '修订服务未就绪' };
}
const axiosMod = requireFn(430327);
const axios = axiosMod?.default || axiosMod;
const cfgMod = requireFn(841569);
const globalCfgMod = requireFn(535512);
const xsrfMod = requireFn(281194);
if (!axios?.get) return { ok: false, error: '历史接口不可用' };
const domain = cfgMod?.Z?.getConfig?.('runtime.domainName') || location.host;
const padId = globalCfgMod?.q?.getConfig?.('globalPadId') || PAGE_WIN?.docInfo?.padInfo?.padId || '';
const xsrf = xsrfMod?.f?.() || getTokXsrf();
if (!padId || !xsrf) return { ok: false, error: '缺少文档身份信息' };
let latestRev = null;
try {
const { data } = await axios.get(`//${domain}/dop-api/get/history`, {
params: { padId, page: 1, _r: Math.floor(Math.random() * 10000), xsrf }
});
const rev = Number(data?.history?.items?.[0]?.i);
if (Number.isFinite(rev) && rev > 0) latestRev = rev;
} catch (_) { }
const workbook = app?.workbook;
const sheetId = workbook?.activeSheetId;
const colCount = sheet.getColCount?.() || 1;
if (sheetId == null || sheetId === '') return { ok: false, error: '工作表上下文不可用' };
const tryChangesFallback = async () => fetchCurrentRowOperationRecordsByChanges({
requireFn, axios, domain, padId, xsrf, row0, latestRev, sheetId
});
const buildFilterArgs = () => ({
recordStartTime: Date.now() - 180 * 24 * 3600 * 1000,
recordEndTime: Date.now(),
filterLimit: 300,
filterRangeSheetId: sheetId,
filterRangeStartRowIndex: row0,
filterRangeStartColIndex: 0,
filterRangeEndRowIndex: row0,
filterRangeEndColIndex: Math.max(0, colCount - 1)
});
const resetAndPrime = async () => {
await calc.clearCache();
if (latestRev != null && typeof calc._cTD === 'function') {
try { await calc._cTD(latestRev); } catch (_) { }
}
};
try { await fcs.startServiceCheck(sheetId); } catch (_) { }
await resetAndPrime();
const maxLoops = 16;
const cellHistoryStartedAt = Date.now();
const cellHistoryBudgetMs = 9000;
const seenTrace = new Set();
const all = [];
let lastSignature = '';
let invalidTraceRetry = 0;
let filterRuntimeRetry = 0;
const constructResult = async (traceVersion, step1Data) => {
// 主路径:activeSheetId 使用字符串 tabId;实测命中率高于 sheetIdKey(number)。
let s2 = await calc._cTd({ traceVersion, activeSheetId: sheetId });
// 兜底:仅在特定错误下再尝试 sheetIdKey。
if (s2?.isError && Number(s2?.code) === 10040) {
const key = step1Data?.currentSearchGridRange?.sheetIdKey;
if (Number.isFinite(Number(key))) {
try {
s2 = await calc._cTd({ traceVersion, activeSheetId: Number(key) });
} catch (_) { }
}
}
return s2;
};
for (let i = 0; i < maxLoops; i += 1) {
if ((Date.now() - cellHistoryStartedAt) > cellHistoryBudgetMs) break;
let step1 = await calc._cTC(buildFilterArgs());
if (step1?.isError) {
if (Number(step1?.code) === 10020 && filterRuntimeRetry < 3) {
filterRuntimeRetry += 1;
try { await fcs.startServiceCheck(sheetId); } catch (_) { }
await resetAndPrime();
await sleep(80 * filterRuntimeRetry);
continue;
}
const fallback = await tryChangesFallback();
if (fallback?.ok) return fallback;
return { ok: false, error: `_cTC(${step1?.code || ''})` };
}
filterRuntimeRetry = 0;
let traceVersion = Number(step1?.data?.traceVersion);
const filterCount = Number(step1?.data?.filterCount || 0);
if (!Number.isFinite(traceVersion) || traceVersion <= 0) {
if (filterCount > 0 && invalidTraceRetry < 3) {
invalidTraceRetry += 1;
await resetAndPrime();
await sleep(80 * invalidTraceRetry);
continue;
}
if (step1?.shouldContinueFilter && i < maxLoops - 1) {
await sleep(40);
continue;
}
break;
}
invalidTraceRetry = 0;
if (seenTrace.has(traceVersion)) {
if (step1?.shouldContinueFilter && i < maxLoops - 1) {
await sleep(30);
continue;
}
break;
}
seenTrace.add(traceVersion);
let step2 = await constructResult(traceVersion, step1?.data);
let retryTd10040 = 0;
while (step2?.isError && Number(step2?.code) === 10040 && retryTd10040 < 4) {
retryTd10040 += 1;
await resetAndPrime();
await sleep(50 * retryTd10040);
step1 = await calc._cTC(buildFilterArgs());
if (step1?.isError) {
if (Number(step1?.code) === 10020 && filterRuntimeRetry < 3) {
filterRuntimeRetry += 1;
try { await fcs.startServiceCheck(sheetId); } catch (_) { }
await resetAndPrime();
await sleep(80 * filterRuntimeRetry);
continue;
}
const fallback = await tryChangesFallback();
if (fallback?.ok) return fallback;
return { ok: false, error: `_cTC(${step1?.code || ''})` };
}
filterRuntimeRetry = 0;
traceVersion = Number(step1?.data?.traceVersion);
if (!Number.isFinite(traceVersion) || traceVersion <= 0) {
step2 = { isError: true, code: 10040 };
break;
}
if (seenTrace.has(traceVersion)) {
step2 = { isError: true, code: 10040 };
break;
}
seenTrace.add(traceVersion);
step2 = await constructResult(traceVersion, step1?.data);
}
if (step2?.isError) {
// 10040 多见于 trace 已失效;尝试继续下一轮拉取,不立即中断。
if (Number(step2?.code) === 10040) {
if (all.length) break;
if (i < maxLoops - 1) {
await sleep(60);
continue;
}
}
const fallback = await tryChangesFallback();
if (fallback?.ok) return fallback;
return { ok: false, error: `_cTd(${step2?.code || ''})` };
}
const list = Array.isArray(step2?.data?.cellHistoryList) ? step2.data.cellHistoryList : [];
const authorMap = step2?.data?.authorMap || {};
for (const x of list) {
const authorId = String(x?.author || '');
all.push({
rowIndex0: Number(x?.rowIndex),
colIndex0: Number(x?.colIndex),
modifiedDateTime: Number(x?.modifiedDateTime),
actionType: Number(x?.actionType),
authorId,
authorName: authorMap?.[authorId]?.name || authorId,
beforeValue: cellDeltaText(x?.beforeCellDelta),
afterValue: cellDeltaText(x?.afterCellDelta)
});
}
const sig = `${traceVersion}|${list.length}|${step1?.shouldContinueFilter ? 1 : 0}`;
if (sig === lastSignature) break;
lastSignature = sig;
if (!step1?.shouldContinueFilter) break;
}
const uniq = [];
const seen = new Set();
for (const x of all) {
const key = [
x.rowIndex0,
x.colIndex0,
x.modifiedDateTime,
x.actionType,
x.authorId,
x.beforeValue,
x.afterValue
].join('|');
if (seen.has(key)) continue;
seen.add(key);
uniq.push(x);
}
uniq.sort((a, b) => (a.modifiedDateTime || 0) - (b.modifiedDateTime || 0));
const exact = uniq.filter((x) => x.rowIndex0 === row0);
if (!exact.length) {
const fallback = await tryChangesFallback();
if (fallback?.ok) return fallback;
return { ok: true, records: [], exactCount: 0, totalCount: uniq.length, source: 'cellHistory' };
}
return { ok: true, records: exact, exactCount: exact.length, totalCount: uniq.length, source: 'cellHistory' };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
}
function extractCellUrls(cell) {
const urls = [];
const seen = new Set();
const pushUrl = (u) => {
if (typeof u !== 'string') return;
const t = u.trim();
if (!t) return;
if (!/^(https?:\/\/|drive:\/\/)/i.test(t)) return;
if (seen.has(t)) return;
seen.add(t);
urls.push(t);
};
const collectFromString = (raw) => {
if (typeof raw !== 'string') return;
const s = raw.replace(/\\\//g, '/');
const re = /(drive:\/\/[^\s"'\\\],]+|https?:\/\/[^\s"'\\\],]+)/gi;
let m = null;
while ((m = re.exec(s)) !== null) {
if (m[1]) pushUrl(m[1]);
}
};
const collectDeep = (node, depth = 0) => {
if (node == null || depth > 6) return;
if (typeof node === 'string') {
collectFromString(node);
return;
}
if (Array.isArray(node)) {
for (const x of node) collectDeep(x, depth + 1);
return;
}
if (typeof node === 'object') {
for (const v of Object.values(node)) collectDeep(v, depth + 1);
}
};
// Rich text link segments
if (cell && cell.value && typeof cell.value === 'object') {
if (Array.isArray(cell.value.r)) {
for (const seg of cell.value.r) {
pushUrl(seg?.hyperlink?.url);
}
}
// Fallback for plain hyperlink object
pushUrl(cell.value?.hyperlink?.url);
}
// Image-like cell payload used by Tencent Docs (e.g. AA~AC screenshots)
const ext = cell?.extendedValue;
if (typeof ext === 'string' && ext.trim()) {
// Try decode up to 2 layers for encoded JSON string payloads.
let node = ext;
for (let i = 0; i < 2; i += 1) {
if (typeof node !== 'string') break;
const t = node.trim();
if (!t) break;
if (!/^[\[{"]/.test(t)) break;
try {
node = JSON.parse(t);
} catch (_) {
break;
}
}
collectDeep(node);
}
// Last fallback: deep scan known cell payload sections.
collectDeep(cell?.value);
collectDeep(cell?.formattedValue);
return urls;
}
function isColHidden(sheet, c) {
try {
if (typeof sheet.isColHiddenByUser === 'function' && sheet.isColHiddenByUser(c)) return true;
} catch (_) { }
try {
if (typeof sheet.isColHidden === 'function' && sheet.isColHidden(c)) return true;
} catch (_) { }
try {
if (typeof sheet.getColWidthWithDefault === 'function') {
const w = sheet.getColWidthWithDefault(c);
if (typeof w === 'number' && w <= 0) return true;
}
} catch (_) { }
try {
if (typeof sheet.getColProperty === 'function') {
const p = sheet.getColProperty(c);
if (p && (p.hidden === true || p.isHidden === true)) return true;
}
} catch (_) { }
return false;
}
function getHeaderText(sheet, col) {
// 腾讯文档常见表头在第2行(0-based=1),同时兼容第1行(0-based=0)
const rowCandidates = [1, 0];
for (const r of rowCandidates) {
const cell = sheet.getCellDataAtPosition(r, col, { isIgnoreCustomSheetView: true });
const t = textOfCell(cell).trim();
if (t) return t;
}
return '';
}
function getTrackedColumns(sheet, selectedCols = DEFAULT_TARGET_COLS) {
const colCount = sheet.getColCount?.() || 0;
const out = [];
const seen = new Set();
for (const c of Array.from(selectedCols || [])) {
if (!Number.isInteger(c) || c < 0 || c >= colCount) continue;
if (seen.has(c)) continue;
seen.add(c);
out.push(c);
}
return out;
}
function detectMaskedColumns(sheet, maxScanRows = 3000) {
const colCount = sheet.getColCount?.() || 0;
const rowCount = sheet.getRowCount?.() || 0;
const endRow = Math.min(rowCount, maxScanRows);
const starCols = new Set();
const fallbackCols = new Set();
for (let r0 = 2; r0 < endRow; r0 += 1) {
for (let c0 = 0; c0 < colCount; c0 += 1) {
if (starCols.has(c0)) continue;
const cell = sheet.getCellDataAtPosition(r0, c0, { isIgnoreCustomSheetView: true });
const text = textOfCell(cell).trim();
const urls = extractCellUrls(cell);
const ext = String(cell?.extendedValue || '');
const hasStar = /^[**]{2,}$/.test(text);
if (hasStar) {
starCols.add(c0);
continue;
}
// 找不到“*列”时的兜底:通过截图/隐藏对象线索识别可能列
const hasDrive = urls.some((u) => /^drive:\/\//i.test(u));
const hasHiddenImage = /docimg\d*\.docs\.qq\.com\/image\/|drive:\/\/|drive\.docs\.qq\.com/i.test(ext);
const hasUrlButMaskedText = urls.length > 0 && text === '';
if (hasDrive || hasHiddenImage || hasUrlButMaskedText) fallbackCols.add(c0);
}
}
return [...(starCols.size ? starCols : fallbackCols)].sort((a, b) => a - b);
}
function detectAppealBlockColumns(sheet) {
const colCount = sheet.getColCount?.() || 0;
const normHeader = (col) => getHeaderText(sheet, col).replace(/\s+/g, '');
let start = -1;
for (let c = 0; c < colCount; c += 1) {
if (normHeader(c).includes('申诉或调整反馈')) {
start = c;
break;
}
}
if (start < 0) return [];
const out = [start];
const wanted = ['截图1', '截图2', '截图3', '截图', '申诉结果', '责任业务'];
for (let c = start + 1; c < Math.min(colCount, start + 12); c += 1) {
const h = normHeader(c);
if (!h) continue;
if (wanted.some((k) => h.includes(k))) out.push(c);
if (h.includes('责任业务')) break;
}
return [...new Set(out)].sort((a, b) => a - b);
}
function textVisualWidth(text) {
const s = String(text || '');
let w = 0;
for (const ch of s) {
w += /[\u0000-\u00ff]/.test(ch) ? 1 : 2;
}
return w;
}
function estimatePanelMetrics(rows) {
let maxColWidth = textVisualWidth('列');
for (const item of rows || []) {
maxColWidth = Math.max(maxColWidth, textVisualWidth(String(item.col || '')));
}
const colPx = Math.min(120, Math.max(46, 16 + maxColWidth * 9));
// “值”列宽度与图片预览宽度保持一致
const valuePx = PREVIEW_IMAGE_WIDTH;
const panelPx = Math.max(280, Math.min(window.innerWidth - 24, colPx + valuePx + 36));
return { colPx, valuePx, panelPx };
}
function getSelectionRect() {
const selectors = ['.single-selection', '.select-selection-border'];
for (const sel of selectors) {
const list = document.querySelectorAll(sel);
for (const el of list) {
const rect = el.getBoundingClientRect();
if (rect.width <= 1 || rect.height <= 1) continue;
if (rect.bottom < 0 || rect.top > window.innerHeight) continue;
if (rect.right < 0 || rect.left > window.innerWidth) continue;
return rect;
}
}
return null;
}
function placePanelNearSelection(panel) {
if (!panel || panel.classList.contains('hidden')) return false;
const selectionRect = getSelectionRect();
if (!selectionRect) {
panel.style.left = '';
panel.style.top = '';
panel.style.right = '16px';
panel.style.bottom = '16px';
return false;
}
const panelRect = panel.getBoundingClientRect();
const margin = 8;
const viewportW = window.innerWidth;
const viewportH = window.innerHeight;
const maxLeft = Math.max(margin, viewportW - panelRect.width - margin);
const maxTop = Math.max(margin, viewportH - panelRect.height - margin);
// 默认放在当前格右方
const rightLeft = selectionRect.right + margin;
const rightTop = Math.min(Math.max(margin, selectionRect.top), maxTop);
const canPlaceRight = rightLeft + panelRect.width <= viewportW - margin;
let left = 0;
let top = 0;
if (canPlaceRight) {
left = Math.min(Math.max(margin, rightLeft), maxLeft);
top = rightTop;
} else {
// 右侧放不下时,自动改到当前格上方
left = Math.min(Math.max(margin, selectionRect.left), maxLeft);
const aboveTop = selectionRect.top - panelRect.height - margin;
top = aboveTop >= margin ? aboveTop : margin;
}
panel.style.left = `${Math.round(left)}px`;
panel.style.top = `${Math.round(top)}px`;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
return true;
}
function createPanel() {
if (document.getElementById(PANEL_ID)) return document.getElementById(PANEL_ID);
if (!document.getElementById(STYLE_ID)) {
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
#${PANEL_ID} {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 999999;
width: 360px;
max-height: 60vh;
background: #ffffff;
color: #0f172a;
border: 1px solid #d9e2ec;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12);
font: 12px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
display: flex;
flex-direction: column;
overflow: hidden;
}
#${PANEL_ID}.hidden { display: none; }
#${PANEL_ID} .hd {
flex: 0 0 auto;
padding: 10px 12px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
#${PANEL_ID} .title { font-weight: 600; }
#${PANEL_ID} .ctrl { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
#${PANEL_ID} input[type="number"] {
width: 84px; padding: 4px 6px; border-radius: 6px;
border: 1px solid #cbd5e1; background: #ffffff; color: #0f172a;
}
#${PANEL_ID} button {
padding: 4px 8px; border-radius: 6px; border: 1px solid #cbd5e1;
background: #ffffff; color: #0f172a; cursor: pointer;
}
#${PANEL_ID} button:hover { background: #f1f5f9; }
#${PANEL_ID} .bd {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
padding: 8px 10px;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
scrollbar-width: none;
-ms-overflow-style: none;
}
#${PANEL_ID}:hover .bd { scrollbar-width: thin; }
#${PANEL_ID} .bd::-webkit-scrollbar { width: 0; height: 0; }
#${PANEL_ID}:hover .bd::-webkit-scrollbar { width: 8px; height: 0; }
#${PANEL_ID}:hover .bd::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 8px;
}
#${PANEL_ID}:hover .bd::-webkit-scrollbar-track { background: transparent; }
#${PANEL_ID} .tabbar {
display: flex;
gap: 6px;
margin-bottom: 8px;
}
#${PANEL_ID} .tab-btn {
padding: 4px 8px;
}
#${PANEL_ID} .tab-btn.active {
background: #e2e8f0;
border-color: #94a3b8;
font-weight: 600;
}
#${PANEL_ID} .tab-pane { display: none; }
#${PANEL_ID} .tab-pane.active { display: block; }
#${PANEL_ID} .tm-main-table { min-width: 0; width: 100%; border-collapse: collapse; table-layout: auto; }
#${PANEL_ID} .tm-main-table th, #${PANEL_ID} .tm-main-table td { border: 1px solid #e2e8f0; padding: 4px 6px; vertical-align: top; word-break: break-word; }
#${PANEL_ID} .tm-main-table th { background: #f8fafc; font-weight: 600; }
#${PANEL_ID} .tm-main-table th:nth-child(1), #${PANEL_ID} .tm-main-table td:nth-child(1) {
width: var(--tm-col-letter-width, 56px);
white-space: nowrap;
word-break: normal;
}
#${PANEL_ID} .tm-main-table th:nth-child(2), #${PANEL_ID} .tm-main-table td:nth-child(2) {
width: var(--tm-col-value-width, 320px);
max-width: var(--tm-col-value-width, 320px);
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
#${PANEL_ID} .tm-op-note {
margin: 0 0 6px;
color: #64748b;
font-size: 11px;
line-height: 1.35;
min-height: 1.35em;
}
#${PANEL_ID} .tm-history-table {
min-width: 0;
width: 100%;
border-collapse: collapse;
table-layout: auto;
}
#${PANEL_ID} .tm-history-table th, #${PANEL_ID} .tm-history-table td {
border: 1px solid #e2e8f0;
padding: 4px 6px;
vertical-align: top;
word-break: break-word;
overflow-wrap: anywhere;
}
#${PANEL_ID} .tm-history-table th {
background: #f8fafc;
font-weight: 600;
}
#${PANEL_ID} .tm-history-table th:nth-child(1), #${PANEL_ID} .tm-history-table td:nth-child(1) { width: 128px; white-space: nowrap; }
#${PANEL_ID} .tm-history-table th:nth-child(2), #${PANEL_ID} .tm-history-table td:nth-child(2) { width: 44px; white-space: nowrap; }
#${PANEL_ID} .tm-history-table th:nth-child(3), #${PANEL_ID} .tm-history-table td:nth-child(3) { width: 44px; white-space: nowrap; }
#${PANEL_ID} .tm-history-table th:nth-child(4), #${PANEL_ID} .tm-history-table td:nth-child(4) { width: 52px; white-space: nowrap; }
#${PANEL_ID} .tm-history-table th:nth-child(5), #${PANEL_ID} .tm-history-table td:nth-child(5) { width: 96px; white-space: normal; }
#${PANEL_ID} .col-picker {
margin-top: 8px;
border: 1px solid #d9e2ec;
border-radius: 8px;
background: #ffffff;
overflow: hidden;
}
#${PANEL_ID} .col-picker > summary {
cursor: pointer;
list-style: none;
padding: 6px 8px;
background: #f8fafc;
user-select: none;
display: flex;
align-items: center;
justify-content: space-between;
}
#${PANEL_ID} .col-picker > summary::-webkit-details-marker { display: none; }
#${PANEL_ID} .col-picker > summary::after {
content: '▾';
color: #64748b;
font-size: 12px;
transition: transform 0.16s ease;
transform-origin: 50% 50%;
}
#${PANEL_ID} .col-picker[open] > summary::after { transform: rotate(180deg); }
#${PANEL_ID} .col-list {
max-height: 160px;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
scrollbar-width: none;
-ms-overflow-style: none;
display: grid;
grid-template-columns: 1fr;
gap: 4px 8px;
padding: 8px;
border-top: 1px solid #e2e8f0;
}
#${PANEL_ID} .col-list:hover { scrollbar-width: thin; }
#${PANEL_ID} .col-list::-webkit-scrollbar { width: 0; height: 0; }
#${PANEL_ID} .col-list:hover::-webkit-scrollbar { width: 8px; height: 0; }
#${PANEL_ID} .col-list:hover::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 8px;
}
#${PANEL_ID} .col-list label {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
cursor: grab;
padding: 2px 4px;
border-radius: 6px;
}
#${PANEL_ID} .col-list label.dragging { opacity: 0.55; }
#${PANEL_ID} .col-list label.drag-over {
background: #f8fafc;
outline: 1px dashed #94a3b8;
}
#${PANEL_ID} .col-list label.drag-over-before { box-shadow: inset 0 2px 0 #3b82f6; }
#${PANEL_ID} .col-list label.drag-over-after { box-shadow: inset 0 -2px 0 #3b82f6; }
#${PANEL_ID} .col-list .drag-handle {
color: #94a3b8;
font-size: 12px;
line-height: 1;
user-select: none;
flex: none;
}
#${PANEL_ID} .col-list .txt {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#${PANEL_ID} .col-actions {
display: flex;
gap: 6px;
padding: 0 8px 8px;
}
#${PANEL_ID} .col-actions button { padding: 3px 8px; }
#${PANEL_ID} .tm-preview-wrap { display: flex; flex-direction: column; gap: 6px; }
#${PANEL_ID} .tm-img-link {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
width: fit-content;
max-width: 100%;
border: 1px solid #d9e2ec;
border-radius: 6px;
overflow: hidden;
text-decoration: none;
background: #ffffff;
}
#${PANEL_ID} .tm-img-preview {
display: block;
width: var(--tm-preview-width, 320px);
height: auto;
max-width: 100%;
max-height: none;
object-fit: contain;
background: #f8fafc;
}
#${PANEL_ID} .tm-img-caption {
display: block;
width: 100%;
box-sizing: border-box;
padding: 4px 6px;
font-size: 11px;
line-height: 1.35;
color: #475569;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#${LAUNCHER_ID} {
position: fixed;
right: 16px;
bottom: 72px;
z-index: 2147483647;
width: 36px;
height: 36px;
border-radius: 18px;
border: 1px solid #cbd5e1;
background: #ffffff;
color: #0f172a;
font: 12px/36px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
text-align: center;
cursor: pointer;
user-select: none;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
}
#${PANEL_ID}:not(.hidden) ~ #${LAUNCHER_ID} { display: none; }
`;
document.head.appendChild(style);
}
const panel = document.createElement('section');
panel.id = PANEL_ID;
panel.innerHTML = `
隐藏列内容查看器
目标列(多选)
`;
document.body.appendChild(panel);
if (!document.getElementById(LAUNCHER_ID)) {
const launcher = document.createElement('div');
launcher.id = LAUNCHER_ID;
launcher.title = '显示/隐藏 隐藏列内容查看器';
launcher.textContent = '列';
launcher.addEventListener('click', () => {
panel.classList.toggle('hidden');
if (!panel.classList.contains('hidden')) {
requestAnimationFrame(() => placePanelNearSelection(panel));
}
});
document.body.appendChild(launcher);
}
return panel;
}
function safeJsonStringify(v) {
try { return JSON.stringify(v, null, 2); } catch (_) { return String(v); }
}
function tryExtractRowFromSelectionPayload(payload, maxRow) {
const seen = new WeakSet();
const weightedRows = [];
function pushRow(v, weight) {
if (typeof v !== 'number') return;
if (!Number.isFinite(v)) return;
const row = Math.trunc(v);
if (row < 0 || row >= maxRow) return;
weightedRows.push({ row, weight });
}
function walk(node, depth) {
if (!node || depth > 8) return;
if (typeof node !== 'object') return;
if (seen.has(node)) return;
seen.add(node);
const keys = Object.keys(node);
// 这些字段更可能是当前选中行。
const strongKeys = ['row', 'rowIndex', 'startRow', 'startRowIndex', 'r'];
for (const k of strongKeys) pushRow(node[k], 4);
// 这些字段通常是范围终点,权重低一些。
const weakKeys = ['endRow', 'endRowIndex'];
for (const k of weakKeys) pushRow(node[k], 2);
for (const k of keys) {
const v = node[k];
if (typeof v === 'number') {
if (/row/i.test(k)) pushRow(v, 1);
} else if (v && typeof v === 'object') {
walk(v, depth + 1);
}
}
}
walk(payload, 0);
if (!weightedRows.length) return null;
const score = new Map();
for (const { row, weight } of weightedRows) {
score.set(row, (score.get(row) || 0) + weight);
}
let bestRow = null;
let bestScore = -1;
for (const [row, s] of score.entries()) {
if (s > bestScore) {
bestScore = s;
bestRow = row;
}
}
return bestRow;
}
function parseA1Row0(raw, maxRow) {
if (typeof raw !== 'string') return null;
const text = raw.trim();
if (!text || text.length > 40) return null;
// A1 或 A1:B8 这类名称框格式
const m = text.match(/^\$?[A-Za-z]{1,4}\$?(\d{1,7})(?::\$?[A-Za-z]{1,4}\$?(\d{1,7}))?$/);
if (!m) return null;
const row0 = Number(m[1]) - 1;
if (!Number.isFinite(row0) || row0 < 0 || row0 >= maxRow) return null;
return row0;
}
function tryExtractRowFromDom(maxRow) {
const checked = new Set();
const candidates = [];
if (document.activeElement) candidates.push(document.activeElement);
candidates.push(...document.querySelectorAll('input, [role="textbox"], textarea'));
for (const el of candidates) {
if (!el || checked.has(el)) continue;
checked.add(el);
const value =
(typeof el.value === 'string' ? el.value : '') ||
(typeof el.textContent === 'string' ? el.textContent : '');
const row0 = parseA1Row0(value, maxRow);
if (typeof row0 === 'number') return row0;
}
return null;
}
function tryExtractRowFromView(app, maxRow) {
const pickRow = (v) => {
if (typeof v !== 'number' || !Number.isFinite(v)) return null;
const row = Math.trunc(v);
if (row < 0 || row >= maxRow) return null;
return row;
};
try {
const row = pickRow(app?.view?.getActiveCell?.()?.rowIndex);
if (typeof row === 'number') return row;
} catch (_) { }
try {
const sel = app?.view?.getSelection?.();
const row =
pickRow(sel?.activeCell?.rowIndex) ??
pickRow(sel?.rangeSelections?.[0]?.startRowIndex) ??
pickRow(sel?.rangeSelections?.[0]?.rowIndex);
if (typeof row === 'number') return row;
} catch (_) { }
try {
const ranges = app?.view?.getSelectionRanges?.();
const row =
pickRow(ranges?.[0]?.startRowIndex) ??
pickRow(ranges?.[0]?.rowIndex);
if (typeof row === 'number') return row;
} catch (_) { }
return null;
}
function install() {
const panel = createPanel();
const panelBody = panel.querySelector('.bd');
const rowInputBoot = panel.querySelector('#tm-row-input');
const btnRefreshBoot = panel.querySelector('#tm-refresh');
const btnToggleBoot = panel.querySelector('#tm-toggle');
const btnDetectBoot = panel.querySelector('#tm-col-detect');
const btnColAllBoot = panel.querySelector('#tm-col-all');
const btnColClearBoot = panel.querySelector('#tm-col-clear');
let initialized = false;
let doRefresh = () => {
tryInit();
};
let doDetectCols = () => {
tryInit();
};
let doSelectAllCols = () => {};
let doClearCols = () => {};
let doToggle = () => {
panel.classList.add('hidden');
};
let doReposition = () => {
placePanelNearSelection(panel);
};
let repositionRaf = 0;
const scheduleReposition = () => {
if (panel.classList.contains('hidden')) return;
if (repositionRaf) return;
repositionRaf = requestAnimationFrame(() => {
repositionRaf = 0;
doReposition();
});
};
btnRefreshBoot?.addEventListener('click', () => doRefresh());
btnToggleBoot?.addEventListener('click', () => doToggle());
btnDetectBoot?.addEventListener('click', () => doDetectCols());
btnColAllBoot?.addEventListener('click', () => doSelectAllCols());
btnColClearBoot?.addEventListener('click', () => doClearCols());
rowInputBoot?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') doRefresh();
});
window.addEventListener('resize', () => scheduleReposition(), { passive: true });
// 避免面板内部滚动触发全局重定位,导致滚动偶发失效
window.addEventListener('scroll', () => scheduleReposition(), { passive: true });
if (typeof ResizeObserver === 'function') {
const resizeObserver = new ResizeObserver(() => scheduleReposition());
resizeObserver.observe(panel);
if (panelBody) resizeObserver.observe(panelBody);
}
panel.addEventListener('wheel', (e) => {
if (panel.classList.contains('hidden')) return;
const target = e.target;
if (!(target instanceof Element)) return;
const bdEl = panel.querySelector('.bd');
const listEl = panel.querySelector('#tm-col-list');
if (!bdEl) return;
let scrollEl = null;
if (listEl && listEl.contains(target) && listEl.scrollHeight > listEl.clientHeight + 1) {
scrollEl = listEl;
} else {
scrollEl = bdEl;
}
const max = scrollEl.scrollHeight - scrollEl.clientHeight;
if (max <= 0) return;
const delta = Number(e.deltaY) || 0;
if (delta === 0) return;
const atTop = scrollEl.scrollTop <= 0;
const atBottom = scrollEl.scrollTop >= max - 1;
const shouldHandle = (delta < 0 && !atTop) || (delta > 0 && !atBottom);
if (!shouldHandle) return;
scrollEl.scrollTop += delta;
e.preventDefault();
e.stopPropagation();
}, { capture: true, passive: false });
const initDataLayer = ({ app, sheet }) => {
if (initialized) return;
initialized = true;
const rowInput = panel.querySelector('#tm-row-input');
const tbody = panel.querySelector('#tm-tbody');
const opTbody = panel.querySelector('#tm-op-tbody');
const opNote = panel.querySelector('#tm-op-note');
const colList = panel.querySelector('#tm-col-list');
const colSummary = panel.querySelector('#tm-col-summary');
const btnDetect = panel.querySelector('#tm-col-detect');
const tabColsBtn = panel.querySelector('#tm-tab-cols-btn');
const tabOpsBtn = panel.querySelector('#tm-tab-ops-btn');
const tabCols = panel.querySelector('#tm-tab-cols');
const tabOps = panel.querySelector('#tm-tab-ops');
let currentRow1 = 2;
let pendingOpsRow0 = 1;
let candidateCols = [];
let selectedCols = new Set();
let draggingCol = null;
let activeTab = 'cols';
let opRenderSeq = 0;
let refreshOpsForCurrentRow = () => { };
const colOrderStorageKey = getColumnOrderStorageKey(app);
panel.style.setProperty('--tm-preview-width', `${PREVIEW_IMAGE_WIDTH}px`);
const maxRowForInit = sheet.getRowCount?.() || 1;
const initialRow0 =
tryExtractRowFromView(app, maxRowForInit) ??
tryExtractRowFromDom(maxRowForInit);
if (typeof initialRow0 === 'number') currentRow1 = initialRow0 + 1;
const switchTab = (tab) => {
activeTab = tab === 'ops' ? 'ops' : 'cols';
if (tabColsBtn) tabColsBtn.classList.toggle('active', activeTab === 'cols');
if (tabOpsBtn) tabOpsBtn.classList.toggle('active', activeTab === 'ops');
if (tabCols) tabCols.classList.toggle('active', activeTab === 'cols');
if (tabOps) tabOps.classList.toggle('active', activeTab === 'ops');
if (activeTab === 'ops') refreshOpsForCurrentRow();
};
tabColsBtn?.addEventListener('click', () => switchTab('cols'));
tabOpsBtn?.addEventListener('click', () => switchTab('ops'));
switchTab('cols');
const resolveInitialRow1 = async () => {
const started = Date.now();
while (Date.now() - started < 2200) {
const row0 =
tryExtractRowFromView(app, maxRowForInit) ??
tryExtractRowFromDom(maxRowForInit);
if (typeof row0 === 'number') return row0 + 1;
await new Promise((r) => setTimeout(r, 120));
}
return currentRow1;
};
const getOrderedSelectedCols = () => {
const ordered = [];
const used = new Set();
for (const c of candidateCols) {
if (!selectedCols.has(c)) continue;
if (used.has(c)) continue;
used.add(c);
ordered.push(c);
}
for (const c of selectedCols) {
if (used.has(c)) continue;
used.add(c);
ordered.push(c);
}
return getTrackedColumns(sheet, ordered);
};
const applySavedOrder = (cols) => {
const normalized = getTrackedColumns(sheet, cols);
const saved = loadColumnOrder(colOrderStorageKey);
if (!saved.length) return normalized;
const set = new Set(normalized);
const ordered = [];
for (const c of saved) {
if (!set.has(c)) continue;
ordered.push(c);
set.delete(c);
}
for (const c of normalized) {
if (!set.has(c)) continue;
ordered.push(c);
set.delete(c);
}
return ordered;
};
const persistCandidateOrder = () => {
saveColumnOrder(colOrderStorageKey, candidateCols);
};
const reorderCandidateCols = (fromCol, toCol, placeAfter = false) => {
if (!Number.isInteger(fromCol) || !Number.isInteger(toCol) || fromCol === toCol) return;
const fromIdx = candidateCols.indexOf(fromCol);
const toIdx = candidateCols.indexOf(toCol);
if (fromIdx < 0 || toIdx < 0) return;
const next = candidateCols.slice();
const [moved] = next.splice(fromIdx, 1);
let insertIdx = next.indexOf(toCol);
if (insertIdx < 0) insertIdx = next.length;
else if (placeAfter) insertIdx += 1;
next.splice(insertIdx, 0, moved);
candidateCols = next;
persistCandidateOrder();
};
const updateColSummary = () => {
const letters = getOrderedSelectedCols().map(colToLetter);
colSummary.textContent = `目标列(多选): ${letters.length ? letters.join(',') : '(未选择)'}`;
};
const renderColList = () => {
colList.innerHTML = '';
if (!candidateCols.length) {
const empty = document.createElement('div');
empty.textContent = '未识别到可选列';
colList.appendChild(empty);
updateColSummary();
return;
}
const clearDragVisuals = () => {
colList.querySelectorAll('label.dragging, label.drag-over, label.drag-over-before, label.drag-over-after').forEach((el) => {
el.classList.remove('dragging', 'drag-over', 'drag-over-before', 'drag-over-after');
delete el.dataset.dropPos;
});
};
for (const c of candidateCols) {
const label = document.createElement('label');
label.draggable = true;
label.dataset.col = String(c);
label.title = '拖拽调整显示顺序';
label.addEventListener('dragstart', (e) => {
draggingCol = c;
label.classList.add('dragging');
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(c));
}
});
label.addEventListener('dragover', (e) => {
if (draggingCol == null || draggingCol === c) return;
e.preventDefault();
const rect = label.getBoundingClientRect();
const placeAfter = e.clientY >= rect.top + rect.height / 2;
clearDragVisuals();
label.classList.add('drag-over');
label.classList.add(placeAfter ? 'drag-over-after' : 'drag-over-before');
label.dataset.dropPos = placeAfter ? 'after' : 'before';
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
});
label.addEventListener('dragleave', () => {
label.classList.remove('drag-over', 'drag-over-before', 'drag-over-after');
delete label.dataset.dropPos;
});
label.addEventListener('drop', (e) => {
e.preventDefault();
const placeAfter = label.dataset.dropPos === 'after';
label.classList.remove('drag-over', 'drag-over-before', 'drag-over-after');
delete label.dataset.dropPos;
const from = draggingCol;
draggingCol = null;
if (!Number.isInteger(from) || from === c) return;
reorderCandidateCols(from, c, placeAfter);
renderColList();
doRefresh();
});
label.addEventListener('dragend', () => {
draggingCol = null;
clearDragVisuals();
});
const handle = document.createElement('span');
handle.className = 'drag-handle';
handle.textContent = '⋮⋮';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.draggable = false;
cb.checked = selectedCols.has(c);
cb.addEventListener('change', () => {
if (cb.checked) selectedCols.add(c);
else selectedCols.delete(c);
updateColSummary();
doRefresh();
});
const txt = document.createElement('span');
txt.className = 'txt';
const h = getHeaderText(sheet, c);
txt.textContent = `${colToLetter(c)}${h ? ` (${h})` : ''}`;
label.appendChild(handle);
label.appendChild(cb);
label.appendChild(txt);
colList.appendChild(label);
}
updateColSummary();
};
const detectAndApplyCols = () => {
const appealCols = detectAppealBlockColumns(sheet);
if (appealCols.length) {
candidateCols = appealCols;
} else {
const cols = detectMaskedColumns(sheet);
candidateCols = cols.length ? cols : getTrackedColumns(sheet, DEFAULT_TARGET_COLS);
}
candidateCols = applySavedOrder(candidateCols);
const candidateSet = new Set(candidateCols);
if (!selectedCols.size) selectedCols = new Set(candidateCols);
selectedCols = new Set([...selectedCols].filter((c) => candidateSet.has(c)));
if (!selectedCols.size && candidateCols.length) selectedCols = new Set(candidateCols);
persistCandidateOrder();
renderColList();
};
const renderCurrentRowOps = async (row0) => {
pendingOpsRow0 = Math.max(0, Number(row0) || 0);
const seq = ++opRenderSeq;
if (opNote) opNote.textContent = '正在读取当前行修订记录...';
if (opTbody) opTbody.innerHTML = '| 正在读取... |
';
const result = await runOpsFetchSerial(() => withTimeout(
() => fetchCurrentRowOperationRecords(app, sheet, row0),
30000,
{ ok: false, error: '修订读取超时' }
));
if (seq !== opRenderSeq) return;
if (!opTbody) return;
if (!result?.ok) {
if (opNote) opNote.textContent = `读取失败: ${result?.error || '未知错误'}`;
opTbody.innerHTML = '| 未获取到当前行修订记录 |
';
return;
}
const records = Array.isArray(result.records) ? result.records : [];
if (!records.length) {
if (opNote) opNote.textContent = '当前行暂无修订记录';
opTbody.innerHTML = '| 当前行暂无修订记录 |
';
return;
}
if (opNote) {
const sourceTag = result.source === 'changes' ? '(变更流)' : '';
opNote.textContent = `当前行命中 ${records.length} 条操作记录${sourceTag}`;
}
const rowsHtml = records.map((item) => {
const rowText = Number.isFinite(item.rowIndex0) ? String(item.rowIndex0 + 1) : '-';
const colText = Number.isFinite(item.colIndex0) && item.colIndex0 >= 0 ? colToLetter(item.colIndex0) : '-';
const detail = opDetailText(item);
return `
| ${escapeHtml(formatLocalTime(item.modifiedDateTime) || '-')} |
${escapeHtml(rowText)} |
${escapeHtml(colText)} |
${escapeHtml(actionTypeLabel(item.actionType))} |
${escapeHtml(item.authorName || item.authorId || '-')} |
${escapeHtml(detail || '(空)')} |
`;
}).join('');
opTbody.innerHTML = rowsHtml || '| 当前行暂无修订记录 |
';
};
refreshOpsForCurrentRow = () => renderCurrentRowOps(pendingOpsRow0);
const render = async (row1) => {
const maxRow = sheet.getRowCount?.() || 0;
const row0 = Math.max(0, Math.min(maxRow - 1, row1 - 1));
currentRow1 = row0 + 1;
pendingOpsRow0 = row0;
rowInput.value = String(currentRow1);
const bdEl = panel.querySelector('.bd');
if (bdEl) bdEl.scrollTop = 0;
if (activeTab === 'ops') renderCurrentRowOps(row0);
const trackedCols = getOrderedSelectedCols();
if (!trackedCols.length) {
tbody.innerHTML = '| 请先在“目标列(多选)”中选择列 |
';
const m = estimatePanelMetrics([]);
panel.style.setProperty('--tm-col-letter-width', `${m.colPx}px`);
panel.style.setProperty('--tm-col-value-width', `${m.valuePx}px`);
panel.style.setProperty('--tm-preview-width', `${m.valuePx}px`);
panel.style.width = `${m.panelPx}px`;
scheduleReposition();
return;
}
const mapped = trackedCols.map((c) => {
const colName = colToLetter(c);
const header = getHeaderText(sheet, c);
const cell = sheet.getCellDataAtPosition(row0, c, { isIgnoreCustomSheetView: true });
const urls = extractCellUrls(cell);
const text = textOfCell(cell).trim();
return {
col0: c,
col: colName,
header,
value: text,
urls
};
});
const rowData = await Promise.all(mapped.map(async (item) => {
const resolvedUrls = await resolveUrls(item.urls);
const value = resolvedUrls.length ? resolvedUrls.join(' | ') : item.value;
return { ...item, resolvedUrls, value };
}));
const visibleData = rowData.filter((item) => {
if (Array.isArray(item.resolvedUrls) && item.resolvedUrls.length) return true;
return String(item.value || '').trim() !== '';
});
tbody.innerHTML = '';
if (!visibleData.length) {
const tr = document.createElement('tr');
tr.innerHTML = '当前行无非空内容 | ';
tbody.appendChild(tr);
const m = estimatePanelMetrics([]);
panel.style.setProperty('--tm-col-letter-width', `${m.colPx}px`);
panel.style.setProperty('--tm-col-value-width', `${m.valuePx}px`);
panel.style.setProperty('--tm-preview-width', `${m.valuePx}px`);
panel.style.width = `${m.panelPx}px`;
scheduleReposition();
return;
}
for (const item of visibleData) {
const tr = document.createElement('tr');
tr.innerHTML = `${item.col} | ${valueHtml(item)} | `;
tbody.appendChild(tr);
}
const m = estimatePanelMetrics(visibleData);
panel.style.setProperty('--tm-col-letter-width', `${m.colPx}px`);
panel.style.setProperty('--tm-col-value-width', `${m.valuePx}px`);
panel.style.setProperty('--tm-preview-width', `${m.valuePx}px`);
panel.style.width = `${m.panelPx}px`;
scheduleReposition();
};
doRefresh = () => {
const row = Number(rowInput.value) || currentRow1;
render(row);
};
doDetectCols = () => {
if (btnDetect) btnDetect.disabled = true;
try {
detectAndApplyCols();
doRefresh();
} finally {
if (btnDetect) btnDetect.disabled = false;
}
};
doSelectAllCols = () => {
selectedCols = new Set(candidateCols);
renderColList();
doRefresh();
};
doClearCols = () => {
selectedCols = new Set();
renderColList();
doRefresh();
};
doToggle = () => {
panel.classList.add('hidden');
};
doReposition = () => {
placePanelNearSelection(panel);
};
document.addEventListener('keydown', (e) => {
// Alt + H 切换面板
if (e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey && (e.key === 'h' || e.key === 'H')) {
panel.classList.toggle('hidden');
if (!panel.classList.contains('hidden')) scheduleReposition();
}
// Alt + R 刷新当前行
if (e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey && (e.key === 'r' || e.key === 'R')) {
doRefresh();
}
});
// 保留“点击表格行 => 面板自动显示对应行”的联动能力
try {
if (typeof app.view?.onSelectionChange === 'function') {
app.view.onSelectionChange((payload) => {
const maxRow = sheet.getRowCount?.() || 1;
const row0 =
tryExtractRowFromSelectionPayload(payload, maxRow) ??
tryExtractRowFromView(app, maxRow) ??
tryExtractRowFromDom(maxRow);
if (typeof row0 === 'number') render(row0 + 1);
else doReposition();
});
}
} catch (_) { }
detectAndApplyCols();
resolveInitialRow1().then((row1) => {
currentRow1 = row1;
render(currentRow1);
});
};
const tryInit = () => {
waitForSpreadsheetApp(5000).then(initDataLayer).catch((err) => {
console.warn('[hidden-col-viewer] retry init:', err?.message || err);
setTimeout(tryInit, 2000);
});
};
tryInit();
}
if (document.readyState === 'complete') install();
else window.addEventListener('load', install, { once: true });
})();