mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-12 02:20:58 +08:00
fix: add cron.js to git tracking (was in .git/info/exclude)
This commit is contained in:
491
src/pages/cron.js
Normal file
491
src/pages/cron.js
Normal file
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* 定时任务管理
|
||||
* 通过 Gateway WebSocket RPC 直接管理计划任务(cron.list / cron.add / cron.update / cron.remove / cron.run)
|
||||
*/
|
||||
import { toast } from '../components/toast.js'
|
||||
import { showContentModal, showConfirm } from '../components/modal.js'
|
||||
import { icon } from '../lib/icons.js'
|
||||
import { onGatewayChange } from '../lib/app-state.js'
|
||||
import { wsClient } from '../lib/ws-client.js'
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
|
||||
let _unsub = null
|
||||
|
||||
// ── Cron 表达式快捷预设 ──
|
||||
|
||||
const CRON_SHORTCUTS = [
|
||||
{ expr: '*/5 * * * *', text: '每 5 分钟' },
|
||||
{ expr: '*/15 * * * *', text: '每 15 分钟' },
|
||||
{ expr: '0 * * * *', text: '每小时整点' },
|
||||
{ expr: '0 9 * * *', text: '每天 9:00' },
|
||||
{ expr: '0 18 * * *', text: '每天 18:00' },
|
||||
{ expr: '0 9 * * 1', text: '每周一 9:00' },
|
||||
{ expr: '0 9 1 * *', text: '每月 1 号 9:00' },
|
||||
]
|
||||
|
||||
// ── 页面生命周期 ──
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
page.className = 'page'
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">定时任务</h1>
|
||||
<p class="page-desc">创建计划任务,让 AI 按设定时间自动执行指令</p>
|
||||
</div>
|
||||
<div id="cron-gw-warn" style="display:none"></div>
|
||||
<div id="cron-stats" class="stat-cards" style="margin-bottom:var(--space-lg)"></div>
|
||||
<div class="config-actions" style="margin-bottom:var(--space-md)">
|
||||
<button class="btn btn-primary btn-sm" id="btn-new-task">+ 创建任务</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-refresh-tasks">刷新</button>
|
||||
</div>
|
||||
<div id="cron-list"></div>
|
||||
`
|
||||
|
||||
const state = { jobs: [], loading: false }
|
||||
|
||||
page.querySelector('#btn-new-task').onclick = () => openTaskDialog(null, page, state)
|
||||
page.querySelector('#btn-refresh-tasks').onclick = () => fetchJobs(page, state)
|
||||
|
||||
// 监听 Gateway 状态变化
|
||||
if (_unsub) _unsub()
|
||||
_unsub = onGatewayChange(() => {
|
||||
updateGatewayWarning(page)
|
||||
fetchJobs(page, state)
|
||||
})
|
||||
|
||||
updateGatewayWarning(page)
|
||||
await fetchJobs(page, state)
|
||||
|
||||
return page
|
||||
}
|
||||
|
||||
export function cleanup() {
|
||||
if (_unsub) { _unsub(); _unsub = null }
|
||||
}
|
||||
|
||||
// ── Gateway 连接检查 ──
|
||||
|
||||
function isGatewayUp() {
|
||||
return wsClient && wsClient._gatewayReady
|
||||
}
|
||||
|
||||
function updateGatewayWarning(page) {
|
||||
const el = page.querySelector('#cron-gw-warn')
|
||||
if (!el) return
|
||||
if (isGatewayUp()) {
|
||||
el.style.display = 'none'
|
||||
} else {
|
||||
el.style.display = ''
|
||||
el.innerHTML = `
|
||||
<div class="config-section" style="border-color:var(--warning);margin-bottom:var(--space-md)">
|
||||
<div style="display:flex;align-items:center;gap:8px;color:var(--warning);font-size:var(--font-size-sm)">
|
||||
${icon('alert-circle', 16)}
|
||||
Gateway 未连接,定时任务功能需要 Gateway 在线才能使用
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
// ── 数据加载(直连 Gateway RPC) ──
|
||||
|
||||
async function fetchJobs(page, state) {
|
||||
if (!isGatewayUp()) {
|
||||
state.jobs = []
|
||||
renderStats(page, state)
|
||||
renderList(page, state)
|
||||
return
|
||||
}
|
||||
|
||||
state.loading = true
|
||||
renderList(page, state)
|
||||
|
||||
try {
|
||||
const res = await wsClient.request('cron.list', { includeDisabled: true })
|
||||
let jobs = res?.jobs || res
|
||||
if (!Array.isArray(jobs)) jobs = []
|
||||
|
||||
// 映射 Gateway CronJob 格式到 UI 格式
|
||||
state.jobs = jobs.map(j => ({
|
||||
id: j.id,
|
||||
name: j.name || '未命名',
|
||||
description: j.description || '',
|
||||
message: j.payload?.message || j.payload?.text || '',
|
||||
payloadKind: j.payload?.kind || 'agentTurn',
|
||||
schedule: j.schedule || {},
|
||||
enabled: j.enabled !== false,
|
||||
agentId: j.agentId || null,
|
||||
// 运行状态
|
||||
lastRunStatus: j.state?.lastRunStatus || j.state?.lastStatus || null,
|
||||
lastRunAtMs: j.state?.lastRunAtMs || null,
|
||||
lastError: j.state?.lastError || null,
|
||||
lastDurationMs: j.state?.lastDurationMs || null,
|
||||
nextRunAtMs: j.state?.nextRunAtMs || null,
|
||||
consecutiveErrors: j.state?.consecutiveErrors || 0,
|
||||
updatedAtMs: j.updatedAtMs || null,
|
||||
}))
|
||||
} catch (e) {
|
||||
toast('获取任务列表失败: ' + e, 'error')
|
||||
state.jobs = []
|
||||
}
|
||||
|
||||
state.loading = false
|
||||
renderStats(page, state)
|
||||
renderList(page, state)
|
||||
}
|
||||
|
||||
// ── 统计卡片 ──
|
||||
|
||||
function renderStats(page, state) {
|
||||
const el = page.querySelector('#cron-stats')
|
||||
const total = state.jobs.length
|
||||
const active = state.jobs.filter(j => j.enabled).length
|
||||
const paused = total - active
|
||||
const failed = state.jobs.filter(j => j.lastRunStatus === 'error').length
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header"><span class="stat-card-label">总任务</span></div>
|
||||
<div class="stat-card-value">${total}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header"><span class="stat-card-label">运行中</span></div>
|
||||
<div class="stat-card-value" style="color:var(--success)">${active}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header"><span class="stat-card-label">已暂停</span></div>
|
||||
<div class="stat-card-value" style="color:var(--text-tertiary)">${paused}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card-header"><span class="stat-card-label">近期失败</span></div>
|
||||
<div class="stat-card-value" style="color:${failed ? 'var(--error)' : 'var(--text-tertiary)'}">${failed}</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// ── 任务列表渲染 ──
|
||||
|
||||
function renderList(page, state) {
|
||||
const el = page.querySelector('#cron-list')
|
||||
|
||||
if (state.loading) {
|
||||
el.innerHTML = `
|
||||
<div class="config-section"><div class="stat-card loading-placeholder" style="height:80px"></div></div>
|
||||
<div class="config-section"><div class="stat-card loading-placeholder" style="height:80px"></div></div>
|
||||
`
|
||||
return
|
||||
}
|
||||
|
||||
if (!state.jobs.length) {
|
||||
el.innerHTML = `
|
||||
<div style="text-align:center;padding:40px 0;color:var(--text-tertiary)">
|
||||
<div style="margin-bottom:12px;color:var(--text-tertiary)">${icon('clock', 48)}</div>
|
||||
<div style="font-size:var(--font-size-md);margin-bottom:6px">暂无定时任务</div>
|
||||
<div style="font-size:var(--font-size-sm)">点击「+ 创建任务」添加你的第一个计划任务</div>
|
||||
</div>
|
||||
`
|
||||
return
|
||||
}
|
||||
|
||||
el.innerHTML = state.jobs.map(job => {
|
||||
const scheduleText = describeCronFull(job.schedule)
|
||||
const lastRunOk = job.lastRunStatus === 'ok' || job.lastRunStatus === 'skipped'
|
||||
const lastRunHtml = job.lastRunAtMs ? `
|
||||
<span style="font-size:var(--font-size-xs);color:${lastRunOk ? 'var(--success)' : 'var(--error)'}">
|
||||
${lastRunOk ? icon('check', 12) : icon('x', 12)} ${relativeTime(job.lastRunAtMs)}
|
||||
</span>
|
||||
` : ''
|
||||
|
||||
return `
|
||||
<div class="config-section cron-job-card ${job.enabled ? '' : 'disabled'}" data-jid="${job.id}">
|
||||
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
|
||||
<span style="font-weight:600">${escapeHtml(job.name)}</span>
|
||||
<span class="cron-badge ${job.enabled ? 'active' : 'paused'}">${job.enabled ? '运行中' : '已暂停'}</span>
|
||||
${lastRunHtml}
|
||||
</div>
|
||||
<div style="font-size:var(--font-size-sm);color:var(--text-tertiary);margin-bottom:6px">
|
||||
${icon('clock', 12)} ${scheduleText}${job.agentId ? ` · Agent: ${escapeHtml(job.agentId)}` : ''}
|
||||
</div>
|
||||
<div style="font-size:var(--font-size-sm);color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:500px">
|
||||
${escapeHtml(job.message)}
|
||||
</div>
|
||||
${job.lastRunStatus === 'error' && job.lastError ? `
|
||||
<div style="margin-top:6px;font-size:var(--font-size-xs);color:var(--error);background:var(--error-muted, #fee2e2);padding:4px 8px;border-radius:var(--radius-sm)">
|
||||
${escapeHtml(job.lastError)}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;flex-shrink:0">
|
||||
<button class="btn btn-sm btn-secondary" data-action="trigger" title="立即执行">${icon('play', 14)}</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="toggle">${job.enabled ? icon('pause', 14) : icon('play', 14)}</button>
|
||||
<button class="btn btn-sm btn-secondary" data-action="edit">${icon('edit', 14)}</button>
|
||||
<button class="btn btn-sm btn-danger" data-action="delete">${icon('trash', 14)}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
// 绑定事件
|
||||
el.querySelectorAll('.cron-job-card').forEach(card => {
|
||||
const jid = card.dataset.jid
|
||||
const job = state.jobs.find(j => j.id === jid)
|
||||
if (!job) return
|
||||
|
||||
card.querySelector('[data-action="trigger"]').onclick = async (e) => {
|
||||
const btn = e.currentTarget
|
||||
btn.disabled = true
|
||||
try {
|
||||
await wsClient.request('cron.run', { id: jid, mode: 'force' })
|
||||
toast('任务已触发执行', 'success')
|
||||
setTimeout(() => fetchJobs(page, state), 2000)
|
||||
} catch (err) { toast('触发失败: ' + err, 'error') }
|
||||
finally { btn.disabled = false }
|
||||
}
|
||||
|
||||
card.querySelector('[data-action="toggle"]').onclick = async (e) => {
|
||||
const btn = e.currentTarget
|
||||
btn.disabled = true
|
||||
btn.innerHTML = icon('refresh-cw', 14)
|
||||
try {
|
||||
await wsClient.request('cron.update', { id: jid, patch: { enabled: !job.enabled } })
|
||||
toast(job.enabled ? '已暂停' : '已启用', 'info')
|
||||
await fetchJobs(page, state)
|
||||
} catch (err) { toast('操作失败: ' + err, 'error'); btn.disabled = false; btn.innerHTML = job.enabled ? icon('pause', 14) : icon('play', 14) }
|
||||
}
|
||||
|
||||
card.querySelector('[data-action="edit"]').onclick = () => openTaskDialog(job, page, state)
|
||||
|
||||
card.querySelector('[data-action="delete"]').onclick = async (e) => {
|
||||
const yes = await showConfirm(`确定删除任务「${job.name}」?`)
|
||||
if (!yes) return
|
||||
const btn = e.currentTarget
|
||||
btn.disabled = true
|
||||
try {
|
||||
await wsClient.request('cron.remove', { id: jid })
|
||||
toast('已删除', 'info')
|
||||
await fetchJobs(page, state)
|
||||
} catch (err) { toast('删除失败: ' + err, 'error'); btn.disabled = false }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── 创建/编辑任务弹窗 ──
|
||||
|
||||
async function openTaskDialog(job, page, state) {
|
||||
if (!isGatewayUp()) {
|
||||
toast('Gateway 未连接,无法管理任务', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
const isEdit = !!job
|
||||
const initSchedule = extractCronExpr(job?.schedule) || '0 9 * * *'
|
||||
const formId = 'cron-form-' + Date.now()
|
||||
|
||||
const shortcutsHtml = CRON_SHORTCUTS.map(s => {
|
||||
const selected = s.expr === initSchedule ? 'selected' : ''
|
||||
return `<button type="button" class="btn btn-sm ${selected ? 'btn-primary' : 'btn-secondary'} cron-shortcut" data-expr="${s.expr}">${s.text}</button>`
|
||||
}).join('')
|
||||
|
||||
// 加载 agent 列表用于选择器
|
||||
let agents = []
|
||||
try {
|
||||
const res = await api.listAgents()
|
||||
agents = Array.isArray(res) ? res : (res?.agents || [])
|
||||
} catch {}
|
||||
|
||||
const agentOptionsHtml = agents.length
|
||||
? `<option value="">默认 Agent</option>` + agents.map(a =>
|
||||
`<option value="${escapeAttr(a.id)}" ${job?.agentId === a.id ? 'selected' : ''}>${escapeHtml(a.name || a.id)}</option>`
|
||||
).join('')
|
||||
: `<option value="">默认 Agent(未检测到其他 Agent)</option>`
|
||||
|
||||
const content = `
|
||||
<form id="${formId}" style="display:flex;flex-direction:column;gap:var(--space-md)">
|
||||
<div class="form-group">
|
||||
<label class="form-label">任务名称 *</label>
|
||||
<input class="form-input" name="name" value="${escapeAttr(job?.name || '')}" placeholder="如:每日摘要推送" autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">执行指令 *</label>
|
||||
<textarea class="form-input" name="message" rows="3" placeholder="AI 将在触发时执行这段指令">${escapeHtml(job?.message || '')}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">指定 Agent</label>
|
||||
<select class="form-input" name="agentId">${agentOptionsHtml}</select>
|
||||
<div class="form-hint">不选则使用默认 Agent 执行</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">执行周期</label>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px">${shortcutsHtml}</div>
|
||||
<input class="form-input" name="schedule" value="${escapeAttr(initSchedule)}" placeholder="Cron 表达式,如 0 9 * * *">
|
||||
<div class="form-hint" id="cron-preview">${describeCron(initSchedule)}</div>
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;align-items:center;justify-content:space-between">
|
||||
<label class="form-label" style="margin:0">创建后立即启用</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" name="enabled" ${job?.enabled !== false ? 'checked' : ''}>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
`
|
||||
|
||||
const modal = showContentModal({
|
||||
title: isEdit ? '编辑任务' : '创建定时任务',
|
||||
content,
|
||||
buttons: [
|
||||
{ label: isEdit ? '保存修改' : '创建', className: 'btn btn-primary', id: 'btn-cron-save' },
|
||||
],
|
||||
width: 500,
|
||||
})
|
||||
|
||||
// 快捷预设按钮
|
||||
modal.querySelectorAll('.cron-shortcut').forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
modal.querySelectorAll('.cron-shortcut').forEach(b => {
|
||||
b.classList.remove('btn-primary')
|
||||
b.classList.add('btn-secondary')
|
||||
})
|
||||
btn.classList.remove('btn-secondary')
|
||||
btn.classList.add('btn-primary')
|
||||
const input = modal.querySelector('input[name="schedule"]')
|
||||
input.value = btn.dataset.expr
|
||||
modal.querySelector('#cron-preview').textContent = describeCron(btn.dataset.expr)
|
||||
}
|
||||
})
|
||||
|
||||
// 自定义表达式实时预览
|
||||
const schedInput = modal.querySelector('input[name="schedule"]')
|
||||
schedInput.oninput = () => {
|
||||
modal.querySelector('#cron-preview').textContent = describeCron(schedInput.value.trim())
|
||||
// 取消预设按钮高亮
|
||||
modal.querySelectorAll('.cron-shortcut').forEach(b => {
|
||||
b.classList.remove('btn-primary')
|
||||
b.classList.add('btn-secondary')
|
||||
if (b.dataset.expr === schedInput.value.trim()) {
|
||||
b.classList.remove('btn-secondary')
|
||||
b.classList.add('btn-primary')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 保存
|
||||
modal.querySelector('#btn-cron-save').onclick = async () => {
|
||||
const name = modal.querySelector('input[name="name"]').value.trim()
|
||||
const message = modal.querySelector('textarea[name="message"]').value.trim()
|
||||
const schedule = modal.querySelector('input[name="schedule"]').value.trim()
|
||||
const agentId = modal.querySelector('select[name="agentId"]').value || undefined
|
||||
const enabled = modal.querySelector('input[name="enabled"]').checked
|
||||
|
||||
if (!name) { toast('请输入任务名称', 'warning'); return }
|
||||
if (!message) { toast('请输入执行指令', 'warning'); return }
|
||||
if (!schedule) { toast('请设置执行周期', 'warning'); return }
|
||||
|
||||
const saveBtn = modal.querySelector('#btn-cron-save')
|
||||
saveBtn.disabled = true
|
||||
saveBtn.textContent = '保存中...'
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
const patch = { name, enabled }
|
||||
patch.schedule = { kind: 'cron', expr: schedule }
|
||||
patch.payload = { kind: 'agentTurn', message }
|
||||
if (agentId) patch.agentId = agentId
|
||||
await wsClient.request('cron.update', { id: job.id, patch })
|
||||
toast('任务已更新', 'success')
|
||||
} else {
|
||||
const params = {
|
||||
name,
|
||||
enabled,
|
||||
schedule: { kind: 'cron', expr: schedule },
|
||||
payload: { kind: 'agentTurn', message },
|
||||
}
|
||||
if (agentId) params.agentId = agentId
|
||||
await wsClient.request('cron.add', params)
|
||||
toast('任务已创建', 'success')
|
||||
}
|
||||
modal.close?.() || modal.remove?.()
|
||||
await fetchJobs(page, state)
|
||||
} catch (e) {
|
||||
toast('保存失败: ' + e, 'error')
|
||||
saveBtn.disabled = false
|
||||
saveBtn.textContent = isEdit ? '保存修改' : '创建'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 工具函数 ──
|
||||
|
||||
/** 从 Gateway 的 CronSchedule 对象或字符串中提取纯 cron 表达式 */
|
||||
function extractCronExpr(schedule) {
|
||||
if (!schedule) return null
|
||||
if (typeof schedule === 'string') return schedule
|
||||
if (typeof schedule === 'object' && schedule.expr) return schedule.expr
|
||||
if (typeof schedule === 'object' && schedule.kind === 'cron' && schedule.expr) return schedule.expr
|
||||
return null
|
||||
}
|
||||
|
||||
/** 将 cron 表达式转为可读文字 */
|
||||
function describeCron(raw) {
|
||||
const expr = typeof raw === 'string' ? raw : extractCronExpr(raw)
|
||||
if (!expr) return '未知周期'
|
||||
|
||||
const hit = CRON_SHORTCUTS.find(s => s.expr === expr)
|
||||
if (hit) return hit.text
|
||||
|
||||
const parts = expr.split(' ')
|
||||
if (parts.length !== 5) return expr
|
||||
|
||||
const [min, hr, dom, , dow] = parts
|
||||
if (min === '*' && hr === '*') return '每分钟'
|
||||
if (min.startsWith('*/')) return `每 ${min.slice(2)} 分钟`
|
||||
if (hr === '*' && min === '0') return '每小时整点'
|
||||
if (dow !== '*' && dom === '*') return `每周 ${dow} 的 ${hr}:${min.padStart(2, '0')}`
|
||||
if (dom !== '*') return `每月 ${dom} 号 ${hr}:${min.padStart(2, '0')}`
|
||||
if (hr !== '*') return `每天 ${hr}:${min.padStart(2, '0')}`
|
||||
|
||||
return expr
|
||||
}
|
||||
|
||||
/** 将 Gateway 返回的 CronSchedule 对象也处理成可读文字 */
|
||||
function describeCronFull(schedule) {
|
||||
if (!schedule) return '未知'
|
||||
if (typeof schedule === 'string') return describeCron(schedule)
|
||||
if (typeof schedule === 'object') {
|
||||
if (schedule.kind === 'every' && schedule.everyMs) {
|
||||
const ms = schedule.everyMs
|
||||
if (ms < 60000) return `每 ${Math.round(ms / 1000)} 秒`
|
||||
if (ms < 3600000) return `每 ${Math.round(ms / 60000)} 分钟`
|
||||
return `每 ${Math.round(ms / 3600000)} 小时`
|
||||
}
|
||||
if (schedule.kind === 'at' && schedule.at) {
|
||||
try { return '一次性: ' + new Date(schedule.at).toLocaleString() } catch { return schedule.at }
|
||||
}
|
||||
if (schedule.kind === 'cron' && schedule.expr) return describeCron(schedule.expr)
|
||||
}
|
||||
return String(schedule)
|
||||
}
|
||||
|
||||
/** 相对时间描述 */
|
||||
function relativeTime(ts) {
|
||||
if (!ts) return ''
|
||||
const t = typeof ts === 'number' ? ts : new Date(ts).getTime()
|
||||
const diff = Date.now() - t
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return Math.floor(diff / 60000) + ' 分钟前'
|
||||
if (diff < 86400000) return Math.floor(diff / 3600000) + ' 小时前'
|
||||
return Math.floor(diff / 86400000) + ' 天前'
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return (str || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function escapeAttr(str) {
|
||||
return (str || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
Reference in New Issue
Block a user