(() => {
const config = window.jioForum || {}
// 论坛路由根节点是 #forum;论坛小工具放在任意侧栏时没有该节点,
// 此时把委托挂到 body,让 follow / apply 等小工具按钮共用同一套动作。
const root = document.getElementById('forum') || document.body
if (!root) return
const send = async (path, method = 'GET', data = {}, idempotencyKey = '') => {
const headers = { 'Content-Type': 'application/json', 'X-WP-Nonce': config.nonce }
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey
const response = await fetch(config.api + path, {
method,
credentials: 'same-origin',
headers,
body: method === 'GET' ? undefined : JSON.stringify(data),
})
const payload = await response.json()
if (!response.ok || payload.success === false) throw new Error(payload.message || '操作失败')
return payload
}
const message = (element, value) => {
const target = element.querySelector('.jio-forum__message') || element.closest('.jio-forum__single, .jio-forum__replies, .jio-forum__page-head')?.querySelector('.jio-forum__message')
if (target) target.textContent = value
else if (value) window.alert(value)
}
const modal = (title, fields, submitText = '确认') => new Promise((resolve) => { const dialog = document.createElement('dialog')
dialog.className = 'jio-forum__dialog'
const form = document.createElement('form')
form.innerHTML = '
'
form.querySelector('h2').textContent = title
form.querySelector('[type="submit"]').textContent = submitText
const body = form.querySelector('.jio-forum__dialog-fields')
fields.forEach(({ name, label, type = 'text', options = [], value = '' }) => {
const wrapper = document.createElement('label')
wrapper.textContent = label
const control = type === 'select' ? document.createElement('select') : document.createElement(type === 'textarea' ? 'textarea' : 'input')
control.name = name
control.required = true
if (type === 'select') options.forEach(({ value: optionValue, label: optionLabel }) => {
const option = document.createElement('option')
option.value = optionValue
option.textContent = optionLabel
control.append(option)
})
else if (type === 'text') control.type = 'text'
control.value = value
wrapper.append(control)
body.append(wrapper)
})
dialog.append(form)
root.append(dialog)
const close = (value) => { dialog.close(); dialog.remove(); resolve(value) }
form.querySelector('[data-cancel]').addEventListener('click', () => close(null))
dialog.addEventListener('cancel', (event) => { event.preventDefault(); close(null) })
form.addEventListener('submit', (event) => { event.preventDefault(); close(Object.fromEntries(new FormData(form))) })
dialog.showModal()
})
const checkout = async (kind, id) => {
const preview = (await send(`${kind}s/${id}/checkout`)).data
const methods = (preview.methods || []).map((value) => ({ value, label: {
points: '积分', balance: '站内余额', alipay: '支付宝', wechatpay: '微信支付', paypal: 'PayPal', stripe: '银行卡',
}[value] || value }))
const data = await modal(`解锁 ${preview.title} · ${preview.price}${preview.mode === 'points' ? '积分' : '元'}`, [
{ name: 'payment_method', label: '支付方式', type: 'select', options: methods },
], '确认支付')
if (!data) return
const key = crypto.randomUUID()
const result = (await send(`${kind}s/${id}/checkout`, 'POST', data, key)).data
if (result.mode === 'redirect' && result.payment_url) window.location.href = result.payment_url
else if (result.mode === 'form' && result.form_html) {
const wrapper = document.createElement('div')
wrapper.innerHTML = result.form_html
document.body.append(wrapper)
wrapper.querySelector('form')?.submit()
} else if (result.mode === 'qrcode' && result.code_url && window.QRCode) {
const dialog = document.createElement('dialog')
dialog.className = 'jio-forum__dialog'
dialog.innerHTML = '扫码支付
支付完成后刷新页面
'
root.append(dialog)
new window.QRCode(dialog.querySelector('.jio-forum__qr'), { text: result.code_url, width: 180, height: 180 })
dialog.querySelector('button').addEventListener('click', () => window.location.reload())
dialog.showModal()
} else if (result.mode) window.location.href = config.orders
else window.location.reload()
}
const typeSelect = root.querySelector('select[name="type"]')
const syncType = () => {
const vote = root.querySelector('[data-forum-type="vote"]')
if (vote) vote.hidden = typeSelect?.value !== 'vote'
}
typeSelect?.addEventListener('change', syncType)
syncType()
// 发帖页:下拉式设置项(阅读权限 / 版块 / 话题 / 标签 / 类型)统一走隐藏输入。
const syncHidden = (name, value) => {
root.querySelectorAll(`input[type="hidden"][name="${name}"]`).forEach((input) => { input.value = value })
}
const syncTypeBox = () => {
const box = root.querySelector('.forum-type-vote')
const current = root.querySelector('input[type="hidden"][name="type"]')?.value || 'normal'
if (box) box.hidden = current !== 'vote'
}
root.addEventListener('click', (event) => {
const type = event.target.closest('[data-forum-type-set]')
if (type) {
root.querySelectorAll('[data-forum-type-set]').forEach((item) => item.classList.toggle('active', item === type))
syncHidden('type', type.dataset.forumTypeSet)
syncTypeBox()
return
}
const view = event.target.closest('[data-forum-view]')
if (view) {
root.querySelectorAll('[data-forum-view]').forEach((item) => item.classList.toggle('active', item === view))
syncHidden('view_limit', view.dataset.forumView)
const label = root.querySelector('span[name="view_limit"]')
if (label) label.textContent = view.textContent.trim()
return
}
const plate = event.target.closest('[data-forum-plate]')
if (plate) {
root.querySelectorAll('[data-forum-plate]').forEach((item) => item.classList.toggle('active', item === plate))
syncHidden('plate_id', plate.dataset.forumPlate)
const label = root.querySelector('span[name="plate_id"]')
if (label) label.textContent = plate.textContent.trim()
return
}
const status = event.target.closest('[data-forum-status]')
if (status) {
root.querySelectorAll('[data-forum-status]').forEach((item) => item.classList.toggle('active', item === status))
syncHidden('post_status', status.dataset.forumStatus)
return
}
const hideType = event.target.closest('[data-forum-hide-type]')
if (hideType) {
root.querySelectorAll('[data-forum-hide-type]').forEach((item) => item.classList.toggle('active', item === hideType))
syncHidden('hide_type', hideType.dataset.forumHideType)
return
}
const topic = event.target.closest('[data-forum-topic],[data-forum-topic-none]')
if (topic) {
const name = topic.dataset.forumTopic || ''
root.querySelectorAll('[data-forum-topic],[data-forum-topic-none]').forEach((item) => item.classList.toggle('active', item === topic))
syncHidden('topics', name)
const label = root.querySelector('[name="topics"]')
if (label) label.lastChild.textContent = name || '话题'
return
}
const pick = event.target.closest('[data-forum-cover-pick]')
if (pick) {
root.querySelector('[data-forum-cover-file]')?.click()
return
}
const draft = event.target.closest('[data-forum-draft]')
if (draft) {
const form = draft.closest('[data-forum-form]')
if (form) {
syncHidden('post_status', 'draft')
form.requestSubmit()
}
}
})
root.addEventListener('change', (event) => {
const term = event.target.closest('[data-forum-topics],[data-forum-tags]')
if (term) {
const field = term.matches('[data-forum-topics]') ? 'topics' : 'tags'
const values = [...root.querySelectorAll(`[data-forum-${field}]:checked`)].map((input) => input.value)
syncHidden(field, values.join(','))
term.closest('label')?.classList.toggle('active', term.checked)
const label = root.querySelector(`[data-forum-${field}-label]`)
if (label) label.textContent = values.length ? values.slice(0, 2).join('、') : '请选择'
}
})
root.addEventListener('change', async (event) => {
const cover = event.target.closest('[data-forum-cover-file]')
if (!cover?.files?.[0]) return
const data = new FormData()
data.append('file', cover.files[0])
try {
const response = await fetch(config.api + 'upload/image', { method: 'POST', credentials: 'same-origin', headers: { 'X-WP-Nonce': config.nonce }, body: data })
const payload = await response.json()
if (!response.ok || !payload.success) throw new Error(payload.message || '上传失败')
const preview = root.querySelector('.forum-cover-preview')
if (preview) {
preview.classList.remove('is-empty')
preview.innerHTML = '
'
}
const hidden = root.querySelector('input[name="featured_image_id"]')
if (hidden) hidden.value = payload.data.id || ''
} catch (error) {
message(cover.closest('.edit-posts-featured') || root, error.message)
} finally {
cover.value = ''
}
})
const insertMedia = (kind, url, name) => {
const element = document.createElement(kind === 'image' ? 'img' : kind === 'video' ? 'video' : 'a')
if (kind === 'resource') { element.href = url; element.textContent = name || '下载附件' }
else element.src = url
if (kind === 'video') element.controls = true
if (kind === 'image') element.alt = name || ''
const html = element.outerHTML
const editor = window.tinymce?.get('jio_forum_content')
if (editor && !editor.isHidden()) editor.insertContent(html)
else {
const textarea = root.querySelector('textarea[name="content"]')
if (textarea) textarea.setRangeText(html, textarea.selectionStart, textarea.selectionEnd, 'end')
}
}
root.addEventListener('change', async (event) => {
const input = event.target.closest('[data-forum-file]')
if (!input?.files?.[0]) return
const kind = input.dataset.kind
const data = new FormData()
data.append('file', input.files[0])
try {
const response = await fetch(config.api + `upload/${kind}`, { method: 'POST', credentials: 'same-origin', headers: { 'X-WP-Nonce': config.nonce }, body: data })
const payload = await response.json()
if (!response.ok || !payload.success) throw new Error(payload.message || '上传失败')
insertMedia(kind, payload.data.url, payload.data.name)
} catch (error) {
message(input.closest('.jio-forum__form') || root, error.message)
} finally {
input.value = ''
}
})
root.addEventListener('submit', async (event) => {
const form = event.target.closest('[data-forum-form]')
if (!form) return
event.preventDefault()
const button = form.querySelector('[type="submit"]')
const kind = form.dataset.forumForm
const id = Number(form.dataset.id || 0)
if (kind === 'post') window.tinymce?.get('jio_forum_content')?.save()
const body = Object.fromEntries(new FormData(form).entries())
if (kind === 'vote' && form.querySelector('input[name="option[]"]')) {
body.options = new FormData(form).getAll('option[]')
}
button.disabled = true
try {
const result = kind === 'vote' ? await send(`posts/${id}/vote`, 'POST', body)
: kind === 'plate' ? await send(id ? `plates/${id}` : 'plates', id ? 'PATCH' : 'POST', body)
: kind === 'term' ? await send('terms', 'POST', body)
: kind === 'moderator' ? await send(`plates/${id}/moderators`, 'POST', { ...body, active: true })
: kind === 'reply' ? await send(`posts/${id}/replies`, 'POST', body)
: await send(id ? `posts/${id}` : 'posts', id ? 'PATCH' : 'POST', body)
if (result.data?.url) window.location.href = result.data.url
else if (kind === 'moderator') window.location.reload()
else if (kind === 'term') window.location.reload()
else if (kind === 'vote') window.location.reload()
else if (kind === 'reply' && result.data?.status === 'publish') window.location.reload()
else message(form, result.message || '已保存')
} catch (error) {
message(form, error.message)
} finally {
button.disabled = false
}
})
root.addEventListener('click', async (event) => {
const upload = event.target.closest('[data-forum-upload]')
if (upload) {
const input = root.querySelector('[data-forum-file]')
if (input) {
input.dataset.kind = upload.dataset.forumUpload
input.accept = { image: 'image/*', video: 'video/*', resource: '' }[input.dataset.kind] || ''
input.click()
}
return
}
const open = event.target.closest('[data-forum-open]')
if (open) {
const dialog = root.querySelector(`[data-forum-dialog="${open.dataset.forumOpen}"]`)
const category = dialog?.querySelector('select[name="category_id"]')
if (category && open.dataset.category) category.value = open.dataset.category
dialog?.showModal()
return
}
const close = event.target.closest('[data-forum-close]')
if (close) { close.closest('dialog')?.close(); return }
const reply = event.target.closest('[data-forum-reply-to]')
if (reply) {
const form = root.querySelector('[data-forum-form="reply"]')
if (form) { form.querySelector('[name="parent_id"]').value = reply.dataset.forumReplyTo; form.scrollIntoView({ behavior: 'smooth' }); form.querySelector('textarea')?.focus() }
return
}
const button = event.target.closest('[data-forum-action]')
if (!button) return
const action = button.dataset.forumAction
const id = Number(button.dataset.id || 0)
if (action === 'delete' && !window.confirm('确定删除此内容?')) return
button.disabled = true
try {
let result
if (action === 'unlock') { await checkout('post', id); return }
if (action === 'plate-unlock') { await checkout('plate', id); return }
if (action === 'plate-audit') {
result = await send(`plates/${id}/audit`, 'POST', { status: 'publish' })
button.closest('.forum-pending-plate')?.remove()
} else if (action === 'plate-delete') {
const data = await modal('删除版块', [
{ name: 'posts_action', label: '关联帖子', type: 'select', options: [{ value: 'trash', label: '一并移入回收站' }, { value: 'move', label: '移动到其他版块' }] },
{ name: 'target_plate_id', label: '目标版块', type: 'select', options: [{ value: '0', label: '无需选择' }, ...(config.plates || []).filter((plate) => plate.id !== id).map((plate) => ({ value: String(plate.id), label: plate.title }))] },
], '确认删除')
if (!data) return
result = await send(`plates/${id}/delete`, 'POST', data)
button.dataset.forumAction = 'plate-restore'
button.textContent = '撤销删除'
} else if (action === 'plate-restore') {
result = await send(`plates/${id}/restore`, 'POST')
button.dataset.forumAction = 'plate-delete'
button.textContent = '删除版块'
} else if (action === 'reply-like' || action === 'reply-hot') {
result = await send(`replies/${id}/${action === 'reply-like' ? 'like' : 'hot'}`, 'POST')
} else if (action === 'reply-delete') {
if (!window.confirm('确定删除此回复?')) return
result = await send(`replies/${id}/delete`, 'POST')
button.closest('.jio-forum__reply')?.remove()
} else if (action === 'follow') {
try { result = await send(`plates/${id}/follow`, 'POST') }
catch (error) { if (error.message.includes('付费')) { await checkout('plate', id); return } throw error }
} else if (action === 'adopt') result = await send(`posts/${id}/adopt`, 'POST', { comment_id: Number(button.dataset.commentId) })
else if (action === 'report') {
const data = await modal('举报帖子', [{ name: 'category', label: '类别', type: 'select', options: [{ value: '违规内容', label: '违规内容' }, { value: '侵权', label: '侵权' }, { value: '其他', label: '其他' }] }, { name: 'reason', label: '原因', type: 'textarea' }], '提交举报')
if (!data) return
result = await send(`posts/${id}/report`, 'POST', data)
} else if (action === 'apply') {
const data = await modal('申请版主', [{ name: 'reason', label: '申请理由', type: 'textarea' }], '提交申请')
if (!data) return
result = await send(`plates/${id}/apply`, 'POST', data)
} else if (action === 'move') {
const data = await modal('移动帖子', [{ name: 'plate_id', label: '目标版块', type: 'select', options: (config.plates || []).map((plate) => ({ value: String(plate.id), label: plate.title })) }], '确认移动')
if (!data) return
result = await send(`posts/${id}/move`, 'POST', data)
} else if (action === 'application') {
result = await send(`plates/${id}/applications/${Number(button.dataset.userId)}`, 'POST', { status: button.dataset.status })
button.closest('.jio-forum__application')?.remove()
} else if (action === 'moderator-remove') {
result = await send(`plates/${id}/moderators`, 'POST', { user_id: Number(button.dataset.userId), active: false })
button.closest('.jio-forum__application')?.remove()
} else if (action === 'comments') {
const status = button.textContent.includes('关闭') ? 'closed' : 'open'
result = await send(`posts/${id}/comments`, 'POST', { status })
button.textContent = status === 'closed' ? '开放回复' : '关闭回复'
} else result = await send(`posts/${id}/${action}`, 'POST', action === 'score' ? { direction: button.dataset.direction || 'up' } : {})
if (action === 'delete') {
button.dataset.forumAction = 'restore'
button.textContent = '撤销删除'
} else if (action === 'adopt' || action === 'audit') window.location.reload()
else if (action === 'follow') {
const following = result.data?.following === true
button.classList.toggle('active', following)
const label = button.querySelector('text')
if (label) label.textContent = following ? '已关注' : '关注'
else button.textContent = result.data?.label || button.textContent
} else if (action === 'favorite') {
const active = result.data?.active === true
button.classList.toggle('active', active)
const label = button.querySelector('text')
if (label) label.textContent = active ? '已收藏' : '收藏'
} else if (action === 'score') {
// Card score group: flip the active arrow and refresh the shared counter.
const group = button.closest('.score-btns')
if (group) {
const isUp = button.dataset.direction !== 'down'
const userScore = Number(result.data?.user_score || 0)
group.querySelectorAll('.btn-score').forEach((item) => {
const own = (item.dataset.direction !== 'down') === isUp
item.classList.toggle('active', own && userScore !== 0)
})
const counter = group.querySelector('text')
if (counter) counter.textContent = Number(result.data?.score || 0) > 0 ? String(result.data.score) : '评分'
}
} else if (button.querySelector('text')) {
button.querySelector('text').textContent = result.data?.label || result.message || button.querySelector('text').textContent
} else {
button.textContent = result.data?.label || result.message || button.textContent
}
message(button, result.message || '')
} catch (error) {
message(button, error.message)
if (error.message.includes('登录') && config.login) window.location.href = config.login
} finally {
button.disabled = false
}
})
// 论坛首页栏目:点击切换 + 懒加载该栏目的 ajaxpager 内容
const refresh = (pane) => {
if (typeof window.auto_fun === 'function') window.auto_fun()
if (typeof window.jioInitForumMedia === 'function') window.jioInitForumMedia(pane)
}
const loadTab = async (pane, href) => {
if (!pane || pane.dataset.loaded === '1' || pane.dataset.loading === '1') return
pane.dataset.loading = '1'
pane.querySelector('.post_ajax_loader')?.classList.add('is-loading')
try {
const response = await fetch(href, { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
if (!response.ok) throw new Error('加载失败')
const doc = new DOMParser().parseFromString(await response.text(), 'text/html')
const source = doc.getElementById(pane.id)
if (!source) throw new Error('内容不存在')
pane.innerHTML = source.innerHTML
pane.dataset.loaded = '1'
refresh(pane)
} catch (error) {
pane.querySelector('.post_ajax_loader')?.classList.remove('is-loading')
message(pane, error.message)
} finally {
pane.dataset.loading = ''
}
}
const loadMore = async (link, pane) => {
if (pane.dataset.loading === '1') return
pane.dataset.loading = '1'
const more = link.closest('.theme-pagination') || link.closest('.ajax-pag')
const loader = [...pane.children].find((item) => item.classList.contains('post_ajax_loader'))
more?.classList.add('is-loading')
loader?.classList.add('is-loading')
try {
const response = await fetch(link.getAttribute('href'), { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
if (!response.ok) throw new Error('加载失败')
const doc = new DOMParser().parseFromString(await response.text(), 'text/html')
const source = doc.getElementById(pane.id)
if (!source) throw new Error('内容不存在')
const anchor = more || null
const incoming = [...source.children].filter((item) => !item.classList.contains('forum-tab-pane') && !item.classList.contains('theme-pagination') && !item.classList.contains('post_ajax_loader'))
incoming.forEach((item) => pane.insertBefore(item, anchor))
const next = source.querySelector('[data-forum-more]')
if (next && anchor) anchor.querySelector('[data-forum-more]')?.setAttribute('href', next.getAttribute('href'))
else anchor?.remove()
refresh(pane)
} catch (error) {
message(pane, error.message)
} finally {
pane.dataset.loading = ''
more?.classList.remove('is-loading')
loader?.classList.remove('is-loading')
}
}
root.addEventListener('click', (event) => {
const tab = event.target.closest('[data-forum-tab]')
if (tab) {
event.preventDefault()
const target = tab.getAttribute('data-forum-tab')
const pane = root.querySelector(target)
root.querySelectorAll('[data-forum-tab]').forEach((link) => link.closest('li')?.classList.toggle('active', link === tab))
root.querySelectorAll('.forum-tab-pane').forEach((item) => {
const current = `#${item.id}` === target
item.classList.toggle('active', current)
item.classList.toggle('in', current)
})
if (pane) {
loadTab(pane, pane.dataset.href || tab.getAttribute('href'))
if (pane.dataset.href && window.history?.replaceState) window.history.replaceState(null, document.title, pane.dataset.href)
}
return
}
const more = event.target.closest('[data-forum-more]')
if (more) {
// The wrapper also carries .ajax-next for the theme's pagination styles;
// stop the generic handler from firing a second request.
event.preventDefault()
event.stopPropagation()
const pane = more.closest('.forum-tab-pane')
if (pane) loadMore(more, pane)
}
})
})()