133 lines
5.9 KiB
JavaScript
133 lines
5.9 KiB
JavaScript
const DAY = 86_400_000;
|
|
|
|
export function createId(cryptoSource = globalThis.crypto) {
|
|
if (typeof cryptoSource?.randomUUID === 'function') return cryptoSource.randomUUID();
|
|
const bytes = cryptoSource.getRandomValues(new Uint8Array(16));
|
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2,'0')).join('');
|
|
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
|
|
}
|
|
|
|
export function parseDate(value) {
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) throw new Error('请输入有效日期');
|
|
const [year, month, day] = value.split('-').map(Number);
|
|
const date = new Date(0);
|
|
date.setUTCFullYear(year, month - 1, day);
|
|
date.setUTCHours(0, 0, 0, 0);
|
|
if (year < 1900 || year > 9999 || date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) throw new Error('请输入有效日期');
|
|
return date;
|
|
}
|
|
|
|
const isoDate = date => date.toISOString().slice(0, 10);
|
|
|
|
export function todayString(timeZone = 'Asia/Shanghai', now = new Date()) {
|
|
const parts = new Intl.DateTimeFormat('en-CA', {timeZone, year:'numeric', month:'2-digit', day:'2-digit'}).formatToParts(now);
|
|
const get = type => parts.find(part => part.type === type).value;
|
|
return `${get('year')}-${get('month')}-${get('day')}`;
|
|
}
|
|
|
|
export function addDays(value, count) {
|
|
if (!Number.isInteger(count)) throw new Error('周期必须为整数');
|
|
const date = parseDate(value);
|
|
date.setUTCDate(date.getUTCDate() + count);
|
|
const result = isoDate(date);
|
|
parseDate(result);
|
|
return result;
|
|
}
|
|
|
|
export function addCycle(value, interval, unit) {
|
|
if (!Number.isInteger(interval) || interval < 1) throw new Error('周期必须为正整数');
|
|
if (unit === 'days') return addDays(value, interval);
|
|
if (unit !== 'months') throw new Error('请选择有效周期单位');
|
|
const date = parseDate(value);
|
|
const day = date.getUTCDate();
|
|
date.setUTCDate(1);
|
|
date.setUTCMonth(date.getUTCMonth() + interval);
|
|
const end = new Date(date);
|
|
end.setUTCMonth(end.getUTCMonth() + 1, 0);
|
|
date.setUTCDate(Math.min(day, end.getUTCDate()));
|
|
const result = isoDate(date);
|
|
parseDate(result);
|
|
return result;
|
|
}
|
|
|
|
export function daysUntil(value, today = todayString()) {
|
|
return Math.round((parseDate(value) - parseDate(today)) / DAY);
|
|
}
|
|
|
|
export function nextAfterCompletion(rule, completedAt) {
|
|
parseDate(completedAt);
|
|
parseDate(rule.dueDate);
|
|
if (rule.anchor !== 'scheduled') return addCycle(completedAt, rule.interval, rule.unit);
|
|
const origin = rule.anchorDate || rule.dueDate;
|
|
const after = completedAt > rule.dueDate ? completedAt : rule.dueDate;
|
|
// Calculate every occurrence from the original anchor, preserving month-end intent.
|
|
for (let cycle = 1; cycle < 50_000; cycle++) {
|
|
const candidate = addCycle(origin, rule.interval * cycle, rule.unit);
|
|
if (candidate > after) return candidate;
|
|
}
|
|
throw new Error('周期跨度过大,请调整规则');
|
|
}
|
|
|
|
export function completeRenewal(card, ruleId, completedAt, today, note = '') {
|
|
parseDate(completedAt);
|
|
parseDate(today);
|
|
const rule = card.rules.find(item => item.id === ruleId);
|
|
if (!rule) throw new Error('找不到这条续期规则');
|
|
if (card.archived) throw new Error('请先恢复这张卡片,再记录续期');
|
|
if (completedAt > today) throw new Error('不能记录未来的完成日期');
|
|
if (completedAt < card.activatedAt) throw new Error('完成日期不能早于开通日期');
|
|
if (rule.lastCompletedAt && completedAt < rule.lastCompletedAt) throw new Error('完成日期不能早于上次完成日期');
|
|
if (rule.lastCompletedAt === completedAt) throw new Error('这一天已经记录过该操作');
|
|
const nextDueDate = nextAfterCompletion(rule, completedAt);
|
|
const updated = {...card, rules:card.rules.map(item => item.id === ruleId ? {...item, dueDate:nextDueDate, lastCompletedAt:completedAt} : {...item})};
|
|
const event = {
|
|
id:createId(),
|
|
cardId:card.id, ruleId, action:rule.action, completedAt,
|
|
oldDueDate:rule.dueDate, newDueDate:nextDueDate, note,
|
|
};
|
|
return {card:updated, event};
|
|
}
|
|
|
|
export function getTasks(cards) {
|
|
return cards.filter(card => !card.archived)
|
|
.flatMap(card => card.rules.map(rule => ({card, rule})))
|
|
.sort((a, b) => a.rule.dueDate.localeCompare(b.rule.dueDate) || a.card.name.localeCompare(b.card.name));
|
|
}
|
|
|
|
export function validateActivationChange(card, events, activatedAt, today) {
|
|
parseDate(activatedAt);
|
|
parseDate(today);
|
|
if (activatedAt > today) throw new Error('开通日期不能是未来的日期');
|
|
if (!card) return;
|
|
const conflictsWithRule = card.rules.some(rule => rule.dueDate < activatedAt || (rule.lastCompletedAt && rule.lastCompletedAt < activatedAt));
|
|
const conflictsWithHistory = events.some(event => event.cardId === card.id && event.completedAt < activatedAt);
|
|
if (conflictsWithRule || conflictsWithHistory) throw new Error('开通日期不能晚于已有的续期记录或计划日期');
|
|
}
|
|
|
|
export const ACTIONS = {
|
|
sms:{label:'发送短信', icon:'message'},
|
|
call:{label:'拨打电话', icon:'phone'},
|
|
purchase:{label:'购买套餐', icon:'bag'},
|
|
reset:{label:'重置有效期', icon:'refresh'},
|
|
topup:{label:'账户充值', icon:'wallet'},
|
|
custom:{label:'自定义操作', icon:'check'},
|
|
};
|
|
|
|
export function dueStatus(date, today = todayString()) {
|
|
const days = daysUntil(date, today);
|
|
if (days < 0) return {label:`逾期 ${-days} 天`, tone:'danger', days};
|
|
if (days === 0) return {label:'今天到期', tone:'danger', days};
|
|
if (days <= 7) return {label:`${days} 天后到期`, tone:'warning', days};
|
|
return {label:`${days} 天后到期`, tone:'success', days};
|
|
}
|
|
|
|
export const cycleText = rule => `每 ${rule.interval} ${rule.unit === 'months' ? '个月' : '天'}`;
|
|
|
|
export function formatDate(value, short = false) {
|
|
if (!value) return '尚未设置';
|
|
const date = parseDate(value);
|
|
return short ? `${date.getUTCMonth() + 1} 月 ${date.getUTCDate()} 日` : value.replaceAll('-', '.');
|
|
}
|