baseline: 薪酬逻辑收口到 shared/payroll.js 后的当前全量状态
这是第一个包含完整工作区的提交,也是后续「拆 SFC / 上 Vite」的回滚基线。 此前的提交均为 index.html 单文件历史快照,由 .bak 文件重建。 当前状态(提交前已实测): - index.html 内联薪酬算法已删除,改调 WHTPayroll - shared/payroll.js 为唯一薪酬实现,浏览器与 Node 共用 - 对外接口未变:aggregateMonth / isCutEdge / payDayKind 等保留同名薄包装 未入库内容见 .gitignore:依赖、Chrome 验证 profile、验证截图、 运行时 sqlite 数据与日志、时间戳备份、.trash。
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
// 生成/校验基准快照:把**当前** index.html 的薪酬输出固化成 JSON。
|
||||
//
|
||||
// 这是整张测试网的锚点。重构(抽模块、上 Vite、拆 SFC)之后再跑一遍对照,
|
||||
// 任何一个数字变了都会被 test/app-parity.test.js 抓出来。
|
||||
//
|
||||
// node tools/gen-baseline.js # 只校验,不写(默认,安全)
|
||||
// node tools/gen-baseline.js --check # 同上,显式写法
|
||||
// node tools/gen-baseline.js --write # 真的重写基准(需要明确意图)
|
||||
//
|
||||
// ⚠️ 为什么这个文件不能放在 test/ 目录里:
|
||||
// Node 的 test runner 在不给路径时会按 `**/test/**/*.?(c|m)js` 收集文件,
|
||||
// 于是 `test/gen-baseline.js` 会被当成一个测试文件**直接执行**(实测确认)。
|
||||
// 它当年的默认行为是「写基准」,等于 `node --test` 一跑就把安全网重新描一遍——
|
||||
// 哪怕代码算错了钱,基准也会被改成错的然后「测试全绿」。
|
||||
// 双重保险:① 挪出 test/,runner 收集不到;② 默认只校验,写要带 --write。
|
||||
//
|
||||
// 什么时候才允许重写基准:业务规则真的变了,而且你能逐条说清每个差异为什么该变。
|
||||
// 「重构后测试红了就重新生成基准」等于把安全网剪断,那还不如没有。
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { loadApp } = require('../test/lib/app-harness.js');
|
||||
const { probeApp } = require('../test/lib/probe.js');
|
||||
const { SCENARIOS, NOW } = require('../test/lib/scenarios.js');
|
||||
|
||||
const OUT_DIR = path.join(__dirname, '..', 'test', 'baseline');
|
||||
const OUT = path.join(OUT_DIR, 'payroll-baseline.json');
|
||||
|
||||
function build() {
|
||||
const cases = SCENARIOS.map((sc) => {
|
||||
const { app } = loadApp({
|
||||
records: sc.records, year: sc.year, month: sc.month,
|
||||
settings: sc.settings, adjust: sc.adjust, now: NOW
|
||||
});
|
||||
return { name: sc.name, probe: probeApp(app) };
|
||||
});
|
||||
return { frozenNow: NOW, scenarioCount: cases.length, cases };
|
||||
}
|
||||
|
||||
const write = process.argv.includes('--write');
|
||||
const data = build();
|
||||
const json = JSON.stringify(data, null, 2) + '\n';
|
||||
|
||||
if (!write) {
|
||||
if (!fs.existsSync(OUT)) {
|
||||
console.error('基准文件不存在:' + OUT + '\n首次生成请跑 node tools/gen-baseline.js --write');
|
||||
process.exit(1);
|
||||
}
|
||||
const old = fs.readFileSync(OUT, 'utf8');
|
||||
if (old === json) {
|
||||
console.log(`基准一致,${data.scenarioCount} 个场景无差异`);
|
||||
process.exit(0);
|
||||
}
|
||||
console.error('基准与当前 index.html 输出不一致。跑 npm test 看具体差在哪。');
|
||||
console.error('确认每处差异都该变之后,再 node tools/gen-baseline.js --write');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
fs.writeFileSync(OUT, json);
|
||||
console.log(`已写入基准:${OUT}`);
|
||||
console.log(` 场景数 ${data.scenarioCount}`);
|
||||
console.log(` 冻结时刻 ${data.frozenNow}`);
|
||||
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env python3
|
||||
# 一次性重构脚本:把 index.html 内联的薪酬算法换成调 shared/payroll.js。
|
||||
# 每处替换都必须在原文里出现且仅出现一次,否则整个脚本不落盘 —— 宁可不改,不能改错。
|
||||
import sys, io
|
||||
|
||||
PATH = '/root/.openclaw/workspace/work-hours-tracker/index.html'
|
||||
src = io.open(PATH, encoding='utf-8').read()
|
||||
orig = src
|
||||
|
||||
E = [] # (标签, 旧, 新)
|
||||
|
||||
E.append(('工时口径+cfg', ''' const HOUR_MIN = 6, HOUR_MAX = 12, HOUR_STEP = 0.5;
|
||||
const hourOptions = [];
|
||||
for (let h = HOUR_MIN; h <= HOUR_MAX + 1e-9; h += HOUR_STEP) hourOptions.push(Math.round(h * 10) / 10);
|
||||
|
||||
// 整数部与小数部拆开渲染(仅用于日历格子):小数位占固定宽度槽位,
|
||||
// 这样 8 与 8.5 的整数部左右对齐,不会形成错位。
|
||||
const hInt = (v) => Math.floor(Number(v) || 0);
|
||||
const hFrac = (v) => (Number(v) || 0) % 1 === 0.5 ? '.5' : '';
|
||||
|
||||
// 仅用于归一「设置里的班次默认工时」,保证默认值落在可选范围内。
|
||||
// 不对已保存的打卡记录做任何改写。
|
||||
const clampHours = (v, fallback) => {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return fallback === undefined ? DEFAULTS.dayHours : fallback;
|
||||
const snapped = Math.round(n / HOUR_STEP) * HOUR_STEP;
|
||||
return Math.min(HOUR_MAX, Math.max(HOUR_MIN, snapped));
|
||||
};
|
||||
|
||||
// ===== 数值兜底:输入框清空 / 脏数据时回落默认值,不让计算变 0 或 NaN =====
|
||||
// 注意 Number(null) 与 Number('') 都等于 0,必须先判空,否则清空输入框会把时薪静默变成 0。
|
||||
const sn = (v, d) => {
|
||||
if (v === null || v === undefined || v === '') return d;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) && n >= 0 ? n : d;
|
||||
};
|
||||
const cfg = computed(() => ({
|
||||
rate: sn(settings.rate, DEFAULTS.rate),
|
||||
fullDays: Math.round(sn(settings.fullDays, DEFAULTS.fullDays)) || DEFAULTS.fullDays,
|
||||
fullBonusPerHour: sn(settings.fullBonusPerHour, DEFAULTS.fullBonusPerHour),
|
||||
foundBonus: sn(settings.foundBonus, DEFAULTS.foundBonus),
|
||||
missedPenalty: sn(settings.missedPenalty, DEFAULTS.missedPenalty),
|
||||
cutoff: Math.min(28, Math.max(1, Math.round(sn(settings.cutoff, DEFAULTS.cutoff)))),
|
||||
// payDayA/payDayB 原本只用于文案展示,现在参与日历发薪日判定,必须进 cfg 做同样的洗值,
|
||||
// 否则 payDayKind() 拿到 undefined,比较永远不命中,角标与内描边都不会出现。
|
||||
payDayA: Math.min(31, Math.max(1, Math.round(sn(settings.payDayA, DEFAULTS.payDayA)))),
|
||||
payDayB: Math.min(31, Math.max(1, Math.round(sn(settings.payDayB, DEFAULTS.payDayB)))),
|
||||
dayHours: clampHours(sn(settings.dayHours, DEFAULTS.dayHours), DEFAULTS.dayHours),
|
||||
nightHours: clampHours(sn(settings.nightHours, DEFAULTS.nightHours), DEFAULTS.nightHours),
|
||||
defaultShift: settings.defaultShift === 'night' ? 'night' : 'day'
|
||||
}));''', ''' // hInt/hFrac 专供日历格子拆开渲染:小数位占固定宽度槽位,8 与 8.5 的整数部才能左右对齐。
|
||||
// clampHours 只归一「设置里的班次默认工时」,不改已保存的打卡记录
|
||||
// (旧数据里 14h 这类越界值必须原样保留,用例已钉住)。
|
||||
// sn 必须先判空:Number(null) 与 Number('') 都等于 0,少这一步「清空时薪输入框」会把时薪静默变成 0。
|
||||
const { HOUR_MIN, HOUR_MAX, HOUR_STEP, hourOptions, hInt, hFrac, clampHours, sn } = P;
|
||||
|
||||
// 洗过的有效配置:所有算钱逻辑只认这个值,不直接读原始 settings。
|
||||
// 包成 computed 是为了让 settings 每次改动都重洗一遍并触发下游重算。
|
||||
// payDayA/payDayB 也必须一起洗 —— 它们现在参与日历发薪日判定,
|
||||
// 拿到 undefined 时 payDayKind() 比较永不命中,角标与内描边会整个消失。
|
||||
const cfg = computed(() => P.normalizeSettings(settings));'''))
|
||||
|
||||
E.append(('newDraft', ''' function newDraft(shift) {
|
||||
return {
|
||||
status: 'work',
|
||||
shift_type: shift,
|
||||
hours: shift === 'night' ? cfg.value.nightHours : cfg.value.dayHours,
|
||||
contraband_found: 0,
|
||||
contraband_missed: 0,
|
||||
other_penalty: null,
|
||||
penalty_reason: ''
|
||||
};
|
||||
}''', ''' const newDraft = (shift) => P.newDraft(shift, cfg.value);'''))
|
||||
|
||||
E.append(('日期辅助', ''' const daysInMonth = computed(() => new Date(currentYear.value, currentMonth.value + 1, 0).getDate());
|
||||
const firstDayOfWeek = computed(() => new Date(currentYear.value, currentMonth.value, 1).getDay());
|
||||
const monthPrefix = (y, m) => `${y}-${String(m + 1).padStart(2, '0')}-`;
|
||||
const keyOf = (y, m, d) => monthPrefix(y, m) + String(d).padStart(2, '0');
|
||||
const monthKeyOf = (y, m) => `${y}-${String(m + 1).padStart(2, '0')}`;
|
||||
const getMonthAdjust = (y, m) => monthAdjust.value[monthKeyOf(y, m)] || null;
|
||||
const getRecord = (day) => records.value[keyOf(currentYear.value, currentMonth.value, day)];
|
||||
const prevMonthOf = (y, m) => m === 0 ? { year: y - 1, month: 11 } : { year: y, month: m - 1 };
|
||||
const nextMonthOf = (y, m) => m === 11 ? { year: y + 1, month: 0 } : { year: y, month: m + 1 };
|
||||
|
||||
const isToday = (day) => {
|
||||
const n = new Date();
|
||||
return n.getFullYear() === currentYear.value && n.getMonth() === currentMonth.value && n.getDate() === day;
|
||||
};
|
||||
const isCurrentMonth = computed(() => {
|
||||
const n = new Date();
|
||||
return n.getFullYear() === currentYear.value && n.getMonth() === currentMonth.value;
|
||||
});''', ''' const { monthPrefix, keyOf, monthKeyOf, prevMonthOf, nextMonthOf } = P;
|
||||
const daysInMonth = computed(() => P.daysInMonth(currentYear.value, currentMonth.value));
|
||||
const firstDayOfWeek = computed(() => P.firstDayOfWeek(currentYear.value, currentMonth.value));
|
||||
const getMonthAdjust = (y, m) => P.getMonthAdjust(monthAdjust.value, y, m);
|
||||
const getRecord = (day) => records.value[keyOf(currentYear.value, currentMonth.value, day)];
|
||||
|
||||
// 这两个要读「今天」。模块里不允许自己取 now(否则测试基准每天都不一样),
|
||||
// 所以由调用方决定:页面上就是省略实参,让模块回落到 new Date()。
|
||||
const isToday = (day) => P.isSameDay(currentYear.value, currentMonth.value, day);
|
||||
const isCurrentMonth = computed(() => P.isCurrentMonth(currentYear.value, currentMonth.value));'''))
|
||||
|
||||
E.append(('格式化', ''' const fmt = (n) => (Number(n) || 0).toFixed(1);
|
||||
const money = (n) => '¥' + (Number(n) || 0).toFixed(1);
|
||||
const signed = (n) => { const v = Number(n) || 0; return (v >= 0 ? '+¥' : '-¥') + Math.abs(v).toFixed(1); };''',
|
||||
''' // moneyParts 把金额拆成 符号/整数部/小数部,供看板主数字分层渲染(大字只放整数部)。
|
||||
// 它必须从 toFixed(1) 的结果上切,不能用 Math.floor 另算,
|
||||
// 否则 99.96 会被 money() 进为 100.0、而拆分出 99.0,两处数字对不上。
|
||||
const { fmt, money, signed, moneyParts } = P;'''))
|
||||
|
||||
E.append(('aggregateMonth', ''' // ===== 核心聚合:本月 / 上月共用同一套口径 =====
|
||||
// 夜班跨天:工时全部归入开始打卡的那一天。
|
||||
const aggregateMonth = (year, month) => {
|
||||
const c = cfg.value;
|
||||
const prefix = monthPrefix(year, month);
|
||||
let h1 = 0, h2 = 0, workDays = 0, dayShifts = 0, nightShifts = 0, restDays = 0, leaveDays = 0;
|
||||
let foundCount = 0, missedCount = 0, otherPenalties = 0;
|
||||
|
||||
for (const key in records.value) {
|
||||
if (key.indexOf(prefix) !== 0) continue;
|
||||
const rec = records.value[key];
|
||||
if (!rec) continue;
|
||||
if (rec.status === 'rest') { restDays++; continue; }
|
||||
if (rec.status === 'leave') { leaveDays++; continue; }
|
||||
if (rec.status !== 'work') continue;
|
||||
|
||||
const day = Number(key.slice(prefix.length));
|
||||
const hours = Number(rec.hours || 0);
|
||||
if (day <= c.cutoff) h1 += hours; else h2 += hours;
|
||||
|
||||
workDays++;
|
||||
if (rec.shift_type === 'night') nightShifts++; else dayShifts++;
|
||||
foundCount += Number(rec.contraband_found || 0);
|
||||
missedCount += Number(rec.contraband_missed || 0);
|
||||
otherPenalties += Math.max(0, Number(rec.other_penalty || 0));
|
||||
}
|
||||
|
||||
const totalHours = h1 + h2;
|
||||
// 满勤奖只由出勤天数客观判定,不参与人工审核修正
|
||||
const fullBonus = workDays >= c.fullDays ? totalHours * c.fullBonusPerHour : 0;
|
||||
const foundBonus = foundCount * c.foundBonus;
|
||||
const missedPenalty = missedCount * c.missedPenalty;
|
||||
|
||||
// 日常累计奖惩 = 查获 - 漏查 - 其他扣款(不含满勤奖)。
|
||||
// 这只是「记录」,不是可发金额:查获多少件当月只记账,实际奖金要等次月初组长
|
||||
// 审核提交、扣掉漏查与其他扣款后才确定。
|
||||
const rawReview = foundBonus - missedPenalty - otherPenalties;
|
||||
|
||||
// 与「满勤未达标 → 满勤奖按 0 计」同一个道理:
|
||||
// 奖惩未定稿 → 一律按 0 计,不进任何应得/发放口径;只有定稿后才用审核终稿金额。
|
||||
const adj = getMonthAdjust(year, month);
|
||||
const finalized = !!(adj && adj.status === 'final' && Number.isFinite(Number(adj.finalAmount)));
|
||||
const reviewAmount = finalized ? Number(adj.finalAmount) : 0;
|
||||
const reviewDelta = finalized ? reviewAmount - rawReview : 0;
|
||||
// 计入应得的奖惩净额:未定稿恒为 0,页面单列这一项并标明「未定稿不计」。
|
||||
const reviewNet = reviewAmount;
|
||||
// 发放净额 = 满勤奖 + 已定稿奖惩。次月 payDayB 这两笔与下半月工时一起发,
|
||||
// 「挣得」与「结转」两套口径都复用它,保证同名同数。
|
||||
const adjustNet = fullBonus + reviewNet;
|
||||
|
||||
return {
|
||||
h1, h2, totalHours, workDays, dayShifts, nightShifts, restDays, leaveDays,
|
||||
foundCount, missedCount, otherPenalties,
|
||||
fullBonus, foundBonus, missedPenalty, adjustNet, reviewNet,
|
||||
rawReview, reviewAmount, reviewDelta, finalized,
|
||||
adjustStatus: adj ? adj.status : null,
|
||||
adjustNote: adj && adj.note ? adj.note : '',
|
||||
finalizedAt: adj && adj.finalizedAt ? adj.finalizedAt : '',
|
||||
basePay: totalHours * c.rate
|
||||
};
|
||||
};''', ''' // ===== 核心聚合:本月 / 上月共用同一套口径 =====
|
||||
// 口径本体在 shared/payroll.js。这里只把响应式数据打包成它要的 ctx,
|
||||
// 保留同名薄包装是刻意的:模板与下面十几处调用一行都不用改。
|
||||
//
|
||||
// 口径要点(细节与用例见 payroll.js 顶部注释):
|
||||
// 夜班跨天工时整段归开始打卡那天;满勤未达标 → 满勤奖按 0;
|
||||
// 奖惩未经组长审核定稿 → 一律按 0(同一个道理)。
|
||||
const payrollCtx = () => ({ records: records.value, monthAdjust: monthAdjust.value, cfg: cfg.value });
|
||||
const aggregateMonth = (year, month) => P.aggregateMonth(year, month, payrollCtx());'''))
|
||||
|
||||
E.append(('flows', ''' // 发放口径:
|
||||
// 当月 payDayA = 当月 1–cutoff 工时 × rate
|
||||
// 当月 payDayB = 上月 cutoff+1–月底 工时 × rate + 上月奖惩净额
|
||||
const payoutThisA = computed(() => Math.max(0, monthStats.value.h1 * cfg.value.rate));
|
||||
const payoutThisB = computed(() => Math.max(0, prevStats.value.h2 * cfg.value.rate + prevStats.value.adjustNet));
|
||||
const payoutNextB = computed(() => Math.max(0, monthStats.value.h2 * cfg.value.rate + monthStats.value.adjustNet));''',
|
||||
''' // 发放口径与两套记账口径一次算齐(细节见 payroll.js 的 flowsOf 注释):
|
||||
// 当月 payDayA = 当月 1–cutoff 工时 × rate
|
||||
// 当月 payDayB = 上月 cutoff+1–月底 工时 × rate + 上月奖惩净额
|
||||
// 必须走同一个 flowsOf:结转与「次月实际到账」若各算一遍,迟早漂成两个数。
|
||||
const flows = computed(() => P.flowsOf({ cur: monthStats.value, prev: prevStats.value, cfg: cfg.value }));
|
||||
const payoutThisA = computed(() => flows.value.thisA);
|
||||
const payoutThisB = computed(() => flows.value.thisB);
|
||||
const payoutNextB = computed(() => flows.value.nextB);'''))
|
||||
|
||||
E.append(('earned/carry', ''' const earnedTotal = computed(() => monthStats.value.basePay + monthStats.value.adjustNet);
|
||||
const cashTotal = computed(() => payoutThisB.value + payoutThisA.value);''',
|
||||
''' const earnedTotal = computed(() => flows.value.earnedTotal);
|
||||
const cashTotal = computed(() => flows.value.cashTotal);'''))
|
||||
|
||||
E.append(('earnedCarry/split', ''' const earnedCarry = computed(() => payoutNextB.value);
|
||||
const earnedSplit = computed(() => ({ paid: payoutThisA.value, carry: earnedCarry.value }));''',
|
||||
''' const earnedCarry = computed(() => flows.value.carry);
|
||||
const earnedSplit = computed(() => flows.value.split);'''))
|
||||
|
||||
E.append(('payDayAPassed', ''' const payDayAPassed = computed(() => {
|
||||
if (!isCurrentMonth.value) {
|
||||
const n = new Date();
|
||||
return currentYear.value < n.getFullYear() ||
|
||||
(currentYear.value === n.getFullYear() && currentMonth.value < n.getMonth());
|
||||
}
|
||||
return new Date().getDate() >= cfg.value.payDayA;
|
||||
});''', ''' const payDayAPassed = computed(() => P.payDayAPassed(currentYear.value, currentMonth.value, cfg.value));'''))
|
||||
|
||||
E.append(('earnedFloored', ''' const earnedFloored = computed(() => monthStats.value.h2 * cfg.value.rate + monthStats.value.adjustNet < 0);''',
|
||||
''' const earnedFloored = computed(() => flows.value.floored);'''))
|
||||
|
||||
E.append(('penaltyDetails', ''' const penaltyDetails = computed(() => {
|
||||
const c = cfg.value;
|
||||
const prefix = monthPrefix(currentYear.value, currentMonth.value);
|
||||
const out = [];
|
||||
for (const key in records.value) {
|
||||
if (key.indexOf(prefix) !== 0) continue;
|
||||
const r = records.value[key];
|
||||
if (!r || r.status !== 'work') continue;
|
||||
const missed = Number(r.contraband_missed || 0);
|
||||
const other = Math.max(0, Number(r.other_penalty || 0));
|
||||
const amount = missed * c.missedPenalty + other;
|
||||
if (amount <= 0) continue;
|
||||
const parts = [];
|
||||
if (missed > 0) parts.push(`漏查${missed}件`);
|
||||
if (other > 0) parts.push(r.penalty_reason ? r.penalty_reason : '其他扣款');
|
||||
out.push({ day: Number(key.slice(prefix.length)), reason: parts.join('·'), amount });
|
||||
}
|
||||
return out.sort((a, b) => a.day - b.day);
|
||||
});''', ''' const penaltyDetails = computed(() => P.penaltyDetails(currentYear.value, currentMonth.value, payrollCtx()));'''))
|
||||
|
||||
E.append(('fullDaysLeft', ''' const fullDaysLeft = computed(() => Math.max(0, cfg.value.fullDays - monthStats.value.workDays));''',
|
||||
''' const fullDaysLeft = computed(() => P.fullDaysLeft(monthStats.value, cfg.value));'''))
|
||||
|
||||
E.append(('payDayKind', ''' const payDayKind = (day) => {
|
||||
const c = cfg.value;
|
||||
if (day === c.payDayA) return 'a';
|
||||
if (day === c.payDayB) return 'b';
|
||||
return null;
|
||||
};''', ''' const payDayKind = (day) => P.payDayKind(day, cfg.value);'''))
|
||||
|
||||
E.append(('isCutEdge', ''' const isCutEdge = (day) => {
|
||||
const c = cfg.value.cutoff;
|
||||
return day <= c && day + 7 > c;
|
||||
};''', ''' const isCutEdge = (day) => P.isCutEdge(day, cfg.value);'''))
|
||||
|
||||
E.append(('saveRecord 归一', ''' const op = Number(draftRecord.value.other_penalty);
|
||||
draftRecord.value.other_penalty = (Number.isFinite(op) && op > 0) ? op : null;
|
||||
if (draftRecord.value.status !== 'work') {
|
||||
draftRecord.value.contraband_found = 0;
|
||||
draftRecord.value.contraband_missed = 0;
|
||||
draftRecord.value.other_penalty = null;
|
||||
draftRecord.value.penalty_reason = '';
|
||||
}
|
||||
const dayKey''', ''' // 原地归一:非正扣款回落 null;非「上班」日清空全部奖惩字段
|
||||
P.normalizeDraft(draftRecord.value);
|
||||
const dayKey'''))
|
||||
|
||||
bad = []
|
||||
for tag, old, new in E:
|
||||
n = src.count(old)
|
||||
if n != 1:
|
||||
bad.append(f'{tag}: 命中 {n} 次(要求恰好 1 次)')
|
||||
continue
|
||||
src = src.replace(old, new, 1)
|
||||
|
||||
if bad:
|
||||
print('❌ 未落盘,以下替换不唯一或找不到:')
|
||||
for b in bad:
|
||||
print(' ' + b)
|
||||
sys.exit(1)
|
||||
|
||||
io.open(PATH, 'w', encoding='utf-8').write(src)
|
||||
print(f'✅ {len(E)} 处替换全部命中并落盘')
|
||||
print(f' {len(orig)} → {len(src)} 字节(净减 {len(orig) - len(src)})')
|
||||
Reference in New Issue
Block a user