feat(forum): add complete forum workflows
This commit is contained in:
1 parent
a254cbd3e9
commit
ee65fc7fd2
39 files changed
+3425
-4
No files matched your search
@@ -0,0 +1,27 @@
|
||||
import { renderFaIcon, schemaPage } from './helpers'
|
||||
|
||||
const pages = [
|
||||
['general', '全局设置'],
|
||||
['home', '首页与列表'],
|
||||
['publishing', '发布与审核'],
|
||||
['access', '权限与版主'],
|
||||
['replies', '回复与互动'],
|
||||
['payment', '付费与积分'],
|
||||
['urls', '链接设置'],
|
||||
]
|
||||
|
||||
export const forumSection = {
|
||||
key: 'forum',
|
||||
label: '社区论坛',
|
||||
icon: renderFaIcon('fa fas fa-comments'),
|
||||
route: '/forum',
|
||||
children: pages.map(([key, label]) => schemaPage({
|
||||
key: `forum-${key}`,
|
||||
name: `Forum_${key}`,
|
||||
label,
|
||||
title: label,
|
||||
route: `/forum/${key}`,
|
||||
canonicalDomain: 'forum',
|
||||
section: key,
|
||||
})),
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { extensionsSection } from './extensions'
|
||||
import { themeInfoSection } from './theme-info'
|
||||
import { docsSection } from './docs'
|
||||
import { toolsSection } from './tools'
|
||||
import { forumSection } from './forum'
|
||||
|
||||
const SECTION_DEFAULT_CHILDREN = {
|
||||
global: 'global-common',
|
||||
@@ -18,6 +19,7 @@ const SECTION_DEFAULT_CHILDREN = {
|
||||
payment: 'payment-knowledge',
|
||||
extensions: 'extensions-system',
|
||||
tools: 'tools-import-export',
|
||||
forum: 'forum-general',
|
||||
'theme-info': 'theme-info-dashboard',
|
||||
docs: 'docs-dashboard',
|
||||
}
|
||||
@@ -43,6 +45,7 @@ export const ADMIN_SECTIONS = [
|
||||
attachSectionDefaultRoute(permissionsSection),
|
||||
attachSectionDefaultRoute(usersSection),
|
||||
attachSectionDefaultRoute(paymentSection),
|
||||
attachSectionDefaultRoute(forumSection),
|
||||
attachSectionDefaultRoute(extensionsSection),
|
||||
attachSectionDefaultRoute(toolsSection),
|
||||
attachSectionDefaultRoute(themeInfoSection),
|
||||
|
||||
@@ -170,7 +170,7 @@ function resolveSectionFromPath(path) {
|
||||
|
||||
/** Load schema from the Vue-side registry */
|
||||
const pageMeta = computed(() => {
|
||||
const schema = (isEmailPage.value || resolvedDomain.value === 'page_display')
|
||||
const schema = (isEmailPage.value || ['page_display', 'forum'].includes(resolvedDomain.value))
|
||||
? serverSchema.value
|
||||
: getSchema(resolvedDomain.value, resolvedSection.value)
|
||||
if (schema && schema.groups && schema.groups.length > 0) {
|
||||
@@ -192,7 +192,7 @@ const pageMeta = computed(() => {
|
||||
|
||||
/** Default values from schema */
|
||||
const defaults = computed(() => {
|
||||
const schema = (isEmailPage.value || resolvedDomain.value === 'page_display')
|
||||
const schema = (isEmailPage.value || ['page_display', 'forum'].includes(resolvedDomain.value))
|
||||
? serverSchema.value
|
||||
: getSchema(resolvedDomain.value, resolvedSection.value)
|
||||
if (!schema) return {}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
#forum{--forum-ink:var(--key-color,#24282c);--forum-muted:var(--muted-color,#737b82);--forum-line:var(--muted-4-color,#e6e9eb);--forum-accent:var(--focus-color,#2869d1);color:var(--forum-ink)}
|
||||
#forum .jio-forum__wrap{width:min(1200px,calc(100% - 32px));margin:22px auto 70px}
|
||||
#forum .jio-forum__breadcrumbs{display:flex;gap:9px;align-items:center;color:var(--forum-muted);font-size:13px;margin-bottom:18px}
|
||||
#forum .jio-forum__breadcrumbs a{color:inherit}
|
||||
#forum .jio-forum__layout{display:grid;grid-template-columns:minmax(0,1fr) 286px;gap:24px;align-items:start}
|
||||
#forum .jio-forum__main{min-width:0}
|
||||
#forum .jio-forum__hero,#forum .jio-forum__page-head{padding:20px 0;border-bottom:1px solid var(--forum-line)}
|
||||
#forum h1{font-size:27px;line-height:1.25;margin:0 0 10px}
|
||||
#forum .jio-forum__hero p,#forum .jio-forum__page-head p{margin:0;color:var(--forum-muted)}
|
||||
#forum .jio-forum__tabs{display:flex;gap:26px;overflow:auto;white-space:nowrap;border-bottom:1px solid var(--forum-line);margin-bottom:16px}
|
||||
#forum .jio-forum__tabs a{padding:14px 0;color:var(--forum-muted);border-bottom:2px solid transparent}
|
||||
#forum .jio-forum__tabs .is-active{color:var(--forum-accent);border-color:var(--forum-accent);font-weight:600}
|
||||
#forum .jio-forum__chips{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}
|
||||
#forum .jio-forum__chips a,#forum .jio-forum__badge,#forum .jio-forum__badges a,#forum .jio-forum__badges span{padding:4px 8px;background:#f1f5f7;color:#48717c;border-radius:4px;font-size:12px}
|
||||
#forum .jio-forum__post{display:flex;gap:13px;padding:20px 2px;border-bottom:1px solid var(--forum-line)}
|
||||
#forum .jio-forum__avatar{flex:0 0 44px}#forum .jio-forum__avatar img{width:44px;height:44px;border-radius:50%}
|
||||
#forum .jio-forum__post-body{min-width:0;flex:1}#forum .jio-forum__post-top{display:flex;gap:12px;color:var(--forum-muted);font-size:12px}
|
||||
#forum .jio-forum__post h2{font-size:17px;line-height:1.45;margin:7px 0}#forum .jio-forum__post h2 a{color:var(--forum-ink)}
|
||||
#forum .jio-forum__post p{color:var(--forum-muted);font-size:14px;line-height:1.6;margin:0 0 10px}
|
||||
#forum .jio-forum__post-meta{display:flex;align-items:center;gap:10px;color:var(--forum-muted);font-size:12px;flex-wrap:wrap}
|
||||
#forum .jio-forum__post-meta a{color:var(--forum-accent)}
|
||||
#forum .jio-forum__aside{border-left:1px solid var(--forum-line);padding-left:20px}
|
||||
#forum .jio-forum__new,#forum .jio-forum__primary{display:inline-flex;align-items:center;justify-content:center;background:var(--forum-accent);color:#fff;border:0;border-radius:4px;min-height:40px;padding:8px 16px;cursor:pointer}
|
||||
#forum .jio-forum__new{width:100%}#forum .jio-forum__aside-section{padding:22px 0;border-bottom:1px solid var(--forum-line)}
|
||||
#forum .jio-forum__aside-section h2{font-size:16px;margin:0 0 12px}#forum .jio-forum__aside-section a{display:block;padding:6px 0;color:var(--forum-ink)}
|
||||
#forum .jio-forum__plates{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
|
||||
#forum .jio-forum__plate{display:flex;gap:13px;padding:14px;border:1px solid var(--forum-line);border-radius:6px}
|
||||
#forum .jio-forum__plate-cover{width:80px;height:80px;flex:none;background:#edf2f5;border-radius:5px;overflow:hidden}
|
||||
#forum .jio-forum__plate-cover img{width:100%;height:100%;object-fit:cover}#forum .jio-forum__plate h2{font-size:16px;margin:3px 0 8px}
|
||||
#forum .jio-forum__plate p{font-size:13px;color:var(--forum-muted);margin:0}
|
||||
#forum .jio-forum__single{padding:20px 0}#forum .jio-forum__single-meta{display:flex;align-items:center;gap:10px;color:var(--forum-muted);font-size:13px}
|
||||
#forum .jio-forum__single-meta img{border-radius:50%}#forum .jio-forum__single-head h1{margin:18px 0}
|
||||
#forum .jio-forum__badges,#forum .jio-forum__actions{display:flex;gap:8px;flex-wrap:wrap}
|
||||
#forum .jio-forum__single-content{font-size:15px;line-height:1.8;padding:22px 0;overflow-wrap:anywhere}
|
||||
#forum .jio-forum__actions{border-top:1px solid var(--forum-line);padding:16px 0}
|
||||
#forum .jio-forum__action{border:1px solid var(--forum-line);border-radius:4px;background:transparent;color:var(--forum-muted);padding:7px 12px;cursor:pointer}
|
||||
#forum .jio-forum__action:hover{color:var(--forum-accent);border-color:var(--forum-accent)}
|
||||
#forum .jio-forum__form{display:grid;gap:15px;margin:20px 0}#forum .jio-forum__form label{display:grid;gap:6px;font-size:14px;font-weight:600}
|
||||
#forum .jio-forum__form input,#forum .jio-forum__form select,#forum .jio-forum__form textarea{width:100%;border:1px solid #d6dce1;border-radius:4px;padding:10px 12px;background:var(--body-bg,#fff);color:inherit;font:inherit}
|
||||
#forum .jio-forum__form-extra[hidden]{display:none}#forum .jio-forum__message{color:#b64343;min-height:20px}
|
||||
#forum .jio-forum__vote{border-top:1px solid var(--forum-line);padding:20px 0}#forum .jio-forum__vote h2{font-size:17px}
|
||||
#forum .jio-forum__vote label{display:flex;gap:12px;padding:12px;border:1px solid var(--forum-line);margin-bottom:8px;border-radius:4px}
|
||||
#forum .jio-forum__vote-count{margin-left:auto}#forum .jio-forum__locked{padding:30px;text-align:center;background:#f7f9fa}
|
||||
#forum .jio-forum__pagination ul{list-style:none;display:flex;gap:7px;padding:0;margin:24px 0}
|
||||
#forum .jio-forum__pagination a,#forum .jio-forum__pagination span{display:block;padding:6px 10px;border:1px solid var(--forum-line);border-radius:4px}
|
||||
#forum .jio-forum__empty{padding:36px;text-align:center;color:var(--forum-muted)}
|
||||
@media(max-width:800px){#forum .jio-forum__layout{grid-template-columns:1fr}#forum .jio-forum__aside{border-left:0;padding-left:0}#forum .jio-forum__plates{grid-template-columns:1fr}}
|
||||
@media(max-width:480px){#forum .jio-forum__wrap{width:calc(100% - 24px)}#forum h1{font-size:23px}#forum .jio-forum__post{padding:16px 0}#forum .jio-forum__tabs{gap:18px}}
|
||||
#forum .jio-forum__create-plate{width:100%;margin-top:10px}
|
||||
#forum .jio-forum__section-title{font-size:18px;margin:28px 0 0}
|
||||
#forum .jio-forum__reply{display:flex;gap:12px;padding:17px 0;border-bottom:1px solid var(--forum-line)}
|
||||
#forum .jio-forum__reply-avatar img{border-radius:50%}
|
||||
#forum .jio-forum__reply-main{flex:1;min-width:0}
|
||||
#forum .jio-forum__reply-head{display:flex;gap:12px;align-items:center;font-size:13px}
|
||||
#forum .jio-forum__reply-head time{color:var(--forum-muted)}#forum .jio-forum__reply-head strong{color:#147e70}
|
||||
#forum .jio-forum__reply-text{padding:10px 0;line-height:1.7;overflow-wrap:anywhere}
|
||||
#forum .jio-forum__reply-actions button{border:0;background:none;color:var(--forum-accent);padding:4px 10px 4px 0;cursor:pointer}
|
||||
#forum .jio-forum__reply-form{display:grid;gap:12px;margin:24px 0}
|
||||
#forum .jio-forum__reply-form h3{font-size:17px;margin:0}
|
||||
#forum .jio-forum__reply-form textarea{width:100%;border:1px solid var(--forum-line);padding:12px;border-radius:4px;background:var(--body-bg,#fff);color:inherit}
|
||||
#forum .jio-forum__reply-form button{justify-self:start}
|
||||
#forum .jio-forum__widget-link{display:flex;justify-content:space-between;gap:10px;padding:7px 0;color:var(--forum-ink)}
|
||||
#forum .jio-forum__widget-link span{color:var(--forum-muted)}
|
||||
.jio-forum__dialog{width:min(440px,calc(100% - 28px));max-height:90vh;border:1px solid #dce2e5;border-radius:6px;padding:22px;background:var(--body-bg,#fff);color:inherit;box-shadow:0 20px 60px #0003}
|
||||
.jio-forum__dialog::backdrop{background:#10182088}
|
||||
.jio-forum__dialog h2{font-size:20px;margin:0 0 18px;overflow-wrap:anywhere}
|
||||
.jio-forum__dialog form,.jio-forum__dialog-fields{display:grid;gap:14px}
|
||||
.jio-forum__dialog label{display:grid;gap:6px;font-size:14px}
|
||||
.jio-forum__dialog input,.jio-forum__dialog select,.jio-forum__dialog textarea{width:100%;min-height:39px;border:1px solid #cfd7dc;padding:9px;border-radius:4px;background:var(--body-bg,#fff);color:inherit}
|
||||
.jio-forum__dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:10px}
|
||||
.jio-forum__dialog-actions button{border:1px solid #d6dce0;border-radius:4px;padding:8px 14px;cursor:pointer}
|
||||
.jio-forum__qr{width:180px;height:180px;margin:auto}
|
||||
.jio-forum__user-panel h2{font-size:18px;margin:0 0 14px}
|
||||
.jio-forum__user-row{display:flex;justify-content:space-between;gap:12px;padding:12px 0;border-bottom:1px solid #e6e9eb}
|
||||
#forum .jio-forum__applications{margin:18px 0;padding:14px 0;border-top:1px solid var(--forum-line)}
|
||||
#forum .jio-forum__applications h2{font-size:16px;margin:0 0 8px}
|
||||
#forum .jio-forum__application{display:flex;align-items:center;gap:10px;padding:6px 0}
|
||||
#forum .jio-forum__application button{border:1px solid var(--forum-line);border-radius:4px;background:transparent;padding:4px 9px;cursor:pointer}
|
||||
#forum .jio-forum__reply-parent{color:var(--forum-muted);font-size:12px;margin:8px 0 0}
|
||||
#forum .jio-forum__hot-reply{margin:0 0 10px 58px;padding:9px 12px;background:#f5f8f9;color:var(--forum-muted);font-size:13px;border-radius:4px}
|
||||
#forum .jio-forum__pending{padding:16px 0;border-bottom:1px solid var(--forum-line)}
|
||||
#forum .jio-forum__pending h2{font-size:16px;margin:0 0 10px}
|
||||
#forum .jio-forum__pending>div{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:6px 0}
|
||||
#forum .jio-forum__pending button{border:1px solid var(--forum-line);background:transparent;border-radius:4px;padding:5px 9px;cursor:pointer}
|
||||
#forum .jio-forum__editor-tools{display:flex;gap:8px;flex-wrap:wrap}
|
||||
#forum .wp-editor-wrap{border:1px solid var(--forum-line)}
|
||||
body.dark-theme #forum .jio-forum__chips a,body.dark-theme #forum .jio-forum__badge,body.dark-theme #forum .jio-forum__badges a,body.dark-theme #forum .jio-forum__badges span{background:var(--muted-bg-2-color);color:var(--forum-accent)}
|
||||
body.dark-theme #forum .jio-forum__plate-cover{background:var(--muted-bg-2-color)}
|
||||
body.dark-theme #forum .jio-forum__locked,body.dark-theme #forum .jio-forum__hot-reply{background:var(--muted-bg-2-color)}
|
||||
body.dark-theme #forum .jio-forum__form input,body.dark-theme #forum .jio-forum__form select,body.dark-theme #forum .jio-forum__form textarea,body.dark-theme #forum .jio-forum__reply-form textarea{background:var(--main-bg-color);border-color:var(--forum-line);color:var(--forum-ink)}
|
||||
body.dark-theme .jio-forum__dialog{background:var(--main-bg-color);color:var(--key-color);border-color:var(--muted-4-color)}
|
||||
body.dark-theme .jio-forum__dialog input,body.dark-theme .jio-forum__dialog select,body.dark-theme .jio-forum__dialog textarea{background:var(--main-bg-color);color:var(--key-color);border-color:var(--muted-4-color)}
|
||||
body.dark-theme #forum .jio-forum__action{color:var(--forum-muted)}
|
||||
#forum .jio-forum__term-management{margin:16px 0;padding:16px 0;border-bottom:1px solid var(--forum-line)}
|
||||
#forum .jio-forum__term-management h2{font-size:16px}
|
||||
#forum .jio-forum__term-management form{display:flex;gap:8px;align-items:end;flex-wrap:wrap}
|
||||
#forum .jio-forum__term-management label{display:grid;gap:5px;font-size:13px}
|
||||
#forum .jio-forum__term-management input,#forum .jio-forum__term-management select{min-height:34px;padding:5px 8px;border:1px solid var(--forum-line);border-radius:4px;background:var(--main-bg-color);color:var(--forum-ink)}
|
||||
#forum .jio-forum__term-row{display:flex;gap:8px;align-items:center;padding:5px 0}
|
||||
#forum .jio-forum__term-row button{border:1px solid var(--forum-line);border-radius:4px;background:transparent;padding:4px 7px;cursor:pointer}
|
||||
@@ -0,0 +1,266 @@
|
||||
(() => {
|
||||
const config = window.jioForum || {}
|
||||
const root = document.getElementById('forum')
|
||||
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 = '<h2></h2><div class="jio-forum__dialog-fields"></div><div class="jio-forum__dialog-actions"><button type="button" data-cancel>取消</button><button type="submit" class="jio-forum__primary"></button></div>'
|
||||
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 = '<h2>扫码支付</h2><div class="jio-forum__qr"></div><p>支付完成后刷新页面</p><button type="button">完成</button>'
|
||||
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 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) { root.querySelector(`[data-forum-dialog="${open.dataset.forumOpen}"]`)?.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('.jio-forum__pending > div')?.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 === 'term-edit') {
|
||||
const name = button.closest('.jio-forum__term-row')?.querySelector('span')?.textContent || ''
|
||||
const data = await modal('编辑分类', [{ name: 'name', label: '名称', value: name }], '保存')
|
||||
if (!data) return
|
||||
result = await send(`terms/${id}`, 'PATCH', data)
|
||||
button.closest('.jio-forum__term-row').querySelector('span').textContent = data.name
|
||||
} else if (action === 'term-delete') {
|
||||
if (!window.confirm('确定删除这个分类?')) return
|
||||
result = await send(`terms/${id}`, 'DELETE')
|
||||
button.closest('.jio-forum__term-row')?.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 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
|
||||
}
|
||||
})
|
||||
})()
|
||||
@@ -94,6 +94,11 @@ if (!class_exists(\Jio\Core\DomainModel::class, true)) {
|
||||
// ============================================================
|
||||
require_once JIO_DIR . '/inc/bootstrap.php';
|
||||
|
||||
\Jio\Forum\Core::init();
|
||||
\Jio\Forum\Rest::init();
|
||||
\Jio\Forum\Commerce::init();
|
||||
\Jio\Forum\Integration::init();
|
||||
|
||||
// §3.4 custom_filter: register custom query vars for archive filter
|
||||
add_filter('query_vars', function ($vars) {
|
||||
$vars[] = 'jio_filter_cat';
|
||||
|
||||
@@ -160,6 +160,10 @@ function jio_get_search_type_options(): array {
|
||||
'post' => __('文章', 'jio'),
|
||||
'user' => __('用户', 'jio'),
|
||||
];
|
||||
if (class_exists('\\Jio\\Forum\\Core') && \Jio\Forum\Core::enabled() && jio_get_setting('jio_forum_search_enabled', true)) {
|
||||
$label_map['forum'] = __('帖子', 'jio');
|
||||
$label_map['plate'] = __('版块', 'jio');
|
||||
}
|
||||
|
||||
if (!class_exists('\Jio\Settings\Settings')) {
|
||||
return ['post' => $label_map['post']];
|
||||
@@ -175,6 +179,10 @@ function jio_get_search_type_options(): array {
|
||||
$options[$type] = $label_map[$type];
|
||||
}
|
||||
}
|
||||
if (isset($label_map['forum'])) {
|
||||
$options['forum'] = $label_map['forum'];
|
||||
$options['plate'] = $label_map['plate'];
|
||||
}
|
||||
|
||||
if (empty($options)) {
|
||||
$options['post'] = $label_map['post'];
|
||||
|
||||
@@ -417,8 +417,10 @@ function jio_notify_post_author_of_comment(WP_Comment $comment): void {
|
||||
if ($owner_id <= 0 || $owner_id === (int) $comment->user_id) return;
|
||||
jio_add_user_notification($owner_id, [
|
||||
'type' => 'comment',
|
||||
'title' => __('文章收到评论', 'jio'),
|
||||
'desc' => sprintf(__('%1$s 评论了你的文章《%2$s》。', 'jio'), get_comment_author($comment), get_the_title($post)),
|
||||
'title' => $post->post_type === \Jio\Forum\Core::POST ? __('帖子收到回复', 'jio') : __('文章收到评论', 'jio'),
|
||||
'desc' => $post->post_type === \Jio\Forum\Core::POST
|
||||
? sprintf(__('%1$s 回复了你的帖子《%2$s》。', 'jio'), get_comment_author($comment), get_the_title($post))
|
||||
: sprintf(__('%1$s 评论了你的文章《%2$s》。', 'jio'), get_comment_author($comment), get_the_title($post)),
|
||||
'url' => get_comment_link($comment),
|
||||
]);
|
||||
update_comment_meta($comment->comment_ID, 'jio_post_comment_notified', 1);
|
||||
|
||||
@@ -43,6 +43,12 @@ $_jio_special_templates = [
|
||||
'title' => '发布文章',
|
||||
'fallback' => '/submit/',
|
||||
],
|
||||
'forums' => [
|
||||
'file' => 'templates/pages/page-forums.php',
|
||||
'slug' => 'forums',
|
||||
'title' => '论坛',
|
||||
'fallback' => '/forums/',
|
||||
],
|
||||
];
|
||||
|
||||
/** @var array 缓存 key=模板文件路径 => value=WP_Post|null */
|
||||
|
||||
+10
@@ -131,6 +131,16 @@ if (is_active_sidebar('all_top_fluid') || is_active_sidebar('search_top_fluid'))
|
||||
echo jio_get_ajax_next_paginate($search_total, $user_page, 12, $user_next_url, 'text-center theme-pagination ajax-pag', 'next-page ajax-next'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
?>
|
||||
<?php elseif ($search_type === 'forum' && $wp_query->have_posts()) : ?>
|
||||
<div id="forum" class="jio-forum"><div class="jio-forum__list">
|
||||
<?php while ($wp_query->have_posts()) : $wp_query->the_post(); \Jio\Forum\View::post_card(get_post()); endwhile; wp_reset_postdata(); ?>
|
||||
</div></div>
|
||||
<?php jio_render_pagination($wp_query); ?>
|
||||
<?php elseif ($search_type === 'plate' && $wp_query->have_posts()) : ?>
|
||||
<div id="forum" class="jio-forum"><div class="jio-forum__plates">
|
||||
<?php while ($wp_query->have_posts()) : $wp_query->the_post(); \Jio\Forum\View::plate_card(get_post()); endwhile; wp_reset_postdata(); ?>
|
||||
</div></div>
|
||||
<?php jio_render_pagination($wp_query); ?>
|
||||
<?php elseif ($search_type === 'post' && $wp_query->have_posts()) : ?>
|
||||
<?php
|
||||
$config = function_exists('jio_archive_get_render_config') ? jio_archive_get_render_config(['context' => 'search', 'highlight_query' => $search_query, 'no_margin' => false]) : ['context' => 'search', 'mode' => 'list'];
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Jio\Admin\Schema;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class SchemaForum {
|
||||
public static function build(): array {
|
||||
return ['domain' => 'forum', 'title' => '社区论坛', 'sections' => [
|
||||
'general' => self::section('general', '全局设置', [
|
||||
self::field('jio_forum_enabled', '启用论坛', 'switcher', true),
|
||||
self::field('jio_forum_admin_users', '论坛管理员 ID', 'input', ''),
|
||||
self::field('jio_forum_forum_name', '论坛名称', 'input', '论坛'),
|
||||
self::field('jio_forum_plate_name', '版块名称', 'input', '版块'),
|
||||
self::field('jio_forum_post_name', '帖子名称', 'input', '帖子'),
|
||||
self::field('jio_forum_topic_name', '话题名称', 'input', '话题'),
|
||||
]),
|
||||
'home' => self::section('home', '首页与列表', [
|
||||
self::field('jio_forum_page_size', '每页帖子数', 'number', 20, ['min' => 1, 'max' => 50]),
|
||||
self::field('jio_forum_home_tabs', '首页栏目', 'checkbox', ['latest', 'popular', 'essence', 'question', 'vote', 'plates', 'follow'], ['options' => self::options(['latest' => '最新', 'popular' => '热门', 'essence' => '精华', 'question' => '问答', 'vote' => '投票', 'plates' => '版块', 'follow' => '已关注'])]),
|
||||
self::field('jio_forum_show_plate_cover', '显示版块封面', 'switcher', true),
|
||||
self::field('jio_forum_search_enabled', '搜索包含帖子', 'switcher', true),
|
||||
]),
|
||||
'publishing' => self::section('publishing', '发布与审核', [
|
||||
self::field('jio_forum_post_review', '新帖子需要审核', 'switcher', true),
|
||||
self::field('jio_forum_plate_review', '新版块需要审核', 'switcher', true),
|
||||
self::field('jio_forum_user_plates', '允许用户创建版块', 'switcher', true),
|
||||
self::field('jio_forum_user_topics', '允许用户创建话题', 'switcher', true),
|
||||
self::field('jio_forum_daily_post_limit', '每日发帖上限', 'number', 20, ['min' => 0, 'max' => 500]),
|
||||
self::field('jio_forum_vote_max', '投票选项上限', 'number', 10, ['min' => 2, 'max' => 20]),
|
||||
self::field('jio_forum_upload_image', '允许上传图片', 'switcher', true),
|
||||
self::field('jio_forum_upload_video', '允许上传视频', 'switcher', false),
|
||||
self::field('jio_forum_upload_file', '允许上传附件', 'switcher', false),
|
||||
]),
|
||||
'access' => self::section('access', '权限与版主', [
|
||||
self::field('jio_forum_moderator_apply', '允许申请版主', 'switcher', true),
|
||||
self::field('jio_forum_follow_required', '关注后允许发帖', 'switcher', false),
|
||||
self::field('jio_forum_allow_guest_read', '允许游客阅读', 'switcher', true),
|
||||
]),
|
||||
'replies' => self::section('replies', '回复与互动', [
|
||||
self::field('jio_forum_reply_page_size', '每页回复数', 'number', 20, ['min' => 1, 'max' => 100]),
|
||||
self::field('jio_forum_reply_review', '回复需要审核', 'switcher', false),
|
||||
self::field('jio_forum_vote_enabled', '启用投票', 'switcher', true),
|
||||
self::field('jio_forum_vote_number_type', '投票结果显示', 'radio', 'percentage', ['options' => self::options(['percentage' => '百分比', 'number' => '票数', 'hide' => '不显示'])]),
|
||||
self::field('jio_forum_question_enabled', '启用问答采纳', 'switcher', true),
|
||||
self::field('jio_forum_score_enabled', '启用评分', 'switcher', true),
|
||||
self::field('jio_forum_score_up_max', '单人最多加分', 'number', 5, ['min' => 0, 'max' => 100]),
|
||||
self::field('jio_forum_score_down_max', '单人最多减分', 'number', 5, ['min' => 0, 'max' => 100]),
|
||||
]),
|
||||
'payment' => self::section('payment', '付费与积分', [
|
||||
self::field('jio_forum_paid_content', '允许帖子付费解锁', 'switcher', true),
|
||||
self::field('jio_forum_paid_follow', '允许付费关注版块', 'switcher', true),
|
||||
self::field('jio_forum_points_enabled', '允许积分解锁', 'switcher', true),
|
||||
self::field('jio_forum_vip_price_enabled', '启用会员价', 'switcher', true),
|
||||
]),
|
||||
'urls' => self::section('urls', '链接设置', [
|
||||
self::field('jio_forum_slug_plate', '版块路径', 'input', 'forum'),
|
||||
self::field('jio_forum_slug_post', '帖子路径', 'input', 'forum-post'),
|
||||
self::field('jio_forum_slug_edit', '发帖路径', 'input', 'posts-edit'),
|
||||
]),
|
||||
]];
|
||||
}
|
||||
|
||||
private static function section(string $key, string $title, array $fields): array {
|
||||
return ['key' => $key, 'title' => $title, 'version' => '1.0.0', 'option_key' => 'jio_settings', 'groups' => [['key' => $key, 'fields' => $fields]]];
|
||||
}
|
||||
|
||||
private static function field(string $key, string $label, string $type, $default, array $extra = []): array {
|
||||
return array_merge(['key' => $key, 'label' => $label, 'type' => $type, 'default' => $default], $extra);
|
||||
}
|
||||
|
||||
private static function options(array $values): array {
|
||||
$result = [];
|
||||
foreach ($values as $value => $label) {
|
||||
$result[] = ['value' => $value, 'label' => $label];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ class SchemaProvider {
|
||||
'payment',
|
||||
'extensions',
|
||||
'tools',
|
||||
'forum',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -93,6 +94,7 @@ class SchemaProvider {
|
||||
'payment' => 'jio_settings',
|
||||
'extensions' => 'jio_settings',
|
||||
'tools' => 'jio_settings',
|
||||
'forum' => 'jio_settings',
|
||||
];
|
||||
|
||||
return $primary[$this->normalize_domain($domain)] ?? 'jio_settings';
|
||||
@@ -278,6 +280,21 @@ class SchemaProvider {
|
||||
}
|
||||
$errors = array_merge($errors, $this->validate_oauth_settings($merged, is_array($stored) ? $stored : []));
|
||||
}
|
||||
if ($domain === 'forum' && ($section === 'urls' || $section === '')) {
|
||||
$stored = get_option('jio_settings', []);
|
||||
$merged = array_merge(is_array($stored) ? $stored : [], $data);
|
||||
$slugs = [];
|
||||
foreach (['plate' => 'forum', 'post' => 'forum-post', 'edit' => 'posts-edit'] as $type => $default) {
|
||||
$slug = (string) ($merged['jio_forum_slug_' . $type] ?? $default);
|
||||
if ($slug === '' || $slug !== sanitize_title($slug) || in_array($slug, ['forums', 'user', 'author', 'wp-admin', 'wp-json'], true)) {
|
||||
$errors[] = __('论坛路径无效或与保留路径冲突', 'jio');
|
||||
}
|
||||
$slugs[] = $slug;
|
||||
}
|
||||
if (count(array_unique($slugs)) !== count($slugs)) {
|
||||
$errors[] = __('论坛路径不能重复', 'jio');
|
||||
}
|
||||
}
|
||||
|
||||
if ($domain === 'payment' && $section === 'withdrawal') {
|
||||
$stored = get_option('jio_settings', []);
|
||||
@@ -377,6 +394,7 @@ class SchemaProvider {
|
||||
'payment' => Schema\SchemaPayment::class,
|
||||
'extensions' => Schema\SchemaExtensions::class,
|
||||
'tools' => Schema\SchemaTools::class,
|
||||
'forum' => Schema\SchemaForum::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@ class DomainModel {
|
||||
public const ORDER_PRODUCT_VIP = 'vip';
|
||||
public const ORDER_PRODUCT_RECHARGE = 'recharge';
|
||||
public const ORDER_PRODUCT_POINTS = 'points';
|
||||
public const ORDER_PRODUCT_FORUM_POST = 'forum_post';
|
||||
public const ORDER_PRODUCT_FORUM_FOLLOW = 'forum_plate_follow';
|
||||
|
||||
/**
|
||||
* Human labels for jio_order.product_type (front-end order center).
|
||||
@@ -62,6 +64,8 @@ class DomainModel {
|
||||
self::ORDER_PRODUCT_VIP => __('购买会员', 'jio'),
|
||||
self::ORDER_PRODUCT_RECHARGE => __('余额充值', 'jio'),
|
||||
self::ORDER_PRODUCT_POINTS => __('购买积分', 'jio'),
|
||||
self::ORDER_PRODUCT_FORUM_POST => __('论坛帖子解锁', 'jio'),
|
||||
self::ORDER_PRODUCT_FORUM_FOLLOW => __('论坛版块关注', 'jio'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace Jio\Forum;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class Access {
|
||||
public static function is_admin(int $user_id): bool {
|
||||
if ($user_id <= 0) {
|
||||
return false;
|
||||
}
|
||||
$raw = jio_get_setting('jio_forum_admin_users', '');
|
||||
$admins = array_map('absint', is_array($raw) ? $raw : explode(',', (string) $raw));
|
||||
return user_can($user_id, 'manage_options') || in_array($user_id, $admins, true);
|
||||
}
|
||||
|
||||
public static function is_moderator(int $user_id, int $plate_id): bool {
|
||||
if (self::is_admin($user_id)) {
|
||||
return true;
|
||||
}
|
||||
if ($user_id <= 0 || get_post_type($plate_id) !== Core::PLATE) {
|
||||
return false;
|
||||
}
|
||||
$moderators = array_map('absint', (array) get_post_meta($plate_id, '_jio_forum_moderators', true));
|
||||
$owner = (int) get_post_field('post_author', $plate_id);
|
||||
if ($owner === $user_id || in_array($user_id, $moderators, true)) {
|
||||
return true;
|
||||
}
|
||||
$terms = wp_get_post_terms($plate_id, Core::CATEGORY, ['fields' => 'ids']);
|
||||
foreach (is_array($terms) ? $terms : [] as $term_id) {
|
||||
$ids = array_map('absint', (array) get_term_meta($term_id, '_jio_forum_moderators', true));
|
||||
if (in_array($user_id, $ids, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function can_manage_moderators(int $user_id, int $plate_id): bool {
|
||||
return self::is_admin($user_id) || ($user_id > 0 && get_post_type($plate_id) === Core::PLATE && (int) get_post_field('post_author', $plate_id) === $user_id);
|
||||
}
|
||||
|
||||
public static function can_manage_post(int $user_id, int $post_id): bool {
|
||||
if (get_post_type($post_id) !== Core::POST || $user_id <= 0) {
|
||||
return false;
|
||||
}
|
||||
return (int) get_post_field('post_author', $post_id) === $user_id
|
||||
|| self::is_moderator($user_id, (int) get_post_meta($post_id, '_jio_forum_plate_id', true));
|
||||
}
|
||||
|
||||
public static function can_publish(int $user_id, int $plate_id): bool {
|
||||
if ($user_id <= 0 || get_post_type($plate_id) !== Core::PLATE || get_post_status($plate_id) !== 'publish') {
|
||||
return false;
|
||||
}
|
||||
if (self::is_moderator($user_id, $plate_id)) {
|
||||
return true;
|
||||
}
|
||||
if (jio_get_setting('jio_forum_follow_required', false) && !self::follows($user_id, $plate_id)) {
|
||||
return false;
|
||||
}
|
||||
$limit = (string) get_post_meta($plate_id, '_jio_forum_publish_limit', true);
|
||||
return $limit === '' || $limit === 'all' || ($limit === 'follow' && self::follows($user_id, $plate_id));
|
||||
}
|
||||
|
||||
public static function follows(int $user_id, int $plate_id): bool {
|
||||
return $user_id > 0 && (bool) get_user_meta($user_id, '_jio_forum_follow_' . $plate_id, true);
|
||||
}
|
||||
|
||||
public static function can_view(int $user_id, int $post_id): bool {
|
||||
$post = get_post($post_id);
|
||||
if (!$post instanceof \WP_Post || $post->post_type !== Core::POST) {
|
||||
return false;
|
||||
}
|
||||
if ($post->post_status !== 'publish' && !self::can_manage_post($user_id, $post_id)) {
|
||||
return false;
|
||||
}
|
||||
if ($user_id <= 0 && !jio_get_setting('jio_forum_allow_guest_read', true)) {
|
||||
return false;
|
||||
}
|
||||
if (self::can_manage_post($user_id, $post_id)) {
|
||||
return true;
|
||||
}
|
||||
$plate = (int) get_post_meta($post_id, '_jio_forum_plate_id', true);
|
||||
$gate = (string) get_post_meta($post_id, '_jio_forum_view_limit', true);
|
||||
$plate_gate = (string) get_post_meta($plate, '_jio_forum_view_limit', true);
|
||||
foreach (['plate' => $plate_gate, 'post' => $gate] as $scope => $rule) {
|
||||
if ($rule === 'login' && $user_id <= 0) {
|
||||
return false;
|
||||
}
|
||||
if ($rule === 'follow' && !self::follows($user_id, $plate)) {
|
||||
return false;
|
||||
}
|
||||
if ($rule === 'points' || $rule === 'pay') {
|
||||
$allowed = $scope === 'plate'
|
||||
? Commerce::has_plate_entitlement($user_id, $plate) && self::follows($user_id, $plate)
|
||||
: $user_id > 0 && \Jio\Payment\Order::get_instance()->has_paid_access($user_id, 'forum_post', $post_id);
|
||||
if (!$allowed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Jio\Forum;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class Commerce {
|
||||
public static function init(): void {
|
||||
add_action('jio_order_paid', [self::class, 'on_paid'], 10, 2);
|
||||
add_action('jio_order_status_changed', [self::class, 'on_status_changed'], 10, 3);
|
||||
}
|
||||
|
||||
public static function has_plate_entitlement(int $user_id, int $plate_id): bool {
|
||||
if ($user_id <= 0 || $plate_id <= 0) return false;
|
||||
$number = (string) get_user_meta($user_id, '_jio_forum_plate_entitlement_' . $plate_id, true);
|
||||
return $number !== '' && \Jio\Payment\Order::get_instance()->has_paid_access($user_id, 'forum_plate_follow', $plate_id);
|
||||
}
|
||||
|
||||
public static function revoke_plate_entitlement(int $user_id, int $plate_id): void {
|
||||
delete_user_meta($user_id, '_jio_forum_plate_entitlement_' . $plate_id);
|
||||
}
|
||||
|
||||
public static function on_paid(string $order_number, array $order): void {
|
||||
if ((string) ($order['product_type'] ?? '') !== 'forum_plate_follow') {
|
||||
return;
|
||||
}
|
||||
$user = (int) ($order['user_id'] ?? 0);
|
||||
$plate = (int) ($order['product_id'] ?? 0);
|
||||
if ($user > 0 && get_post_type($plate) === Core::PLATE) {
|
||||
update_user_meta($user, '_jio_forum_plate_entitlement_' . $plate, $order_number);
|
||||
if (add_user_meta($user, '_jio_forum_follow_' . $plate, 1, true)) {
|
||||
jio_update_post_counter($plate, '_jio_forum_follow_count', 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function on_status_changed(string $order_number, string $status, array $order): void {
|
||||
if ($status !== 'refunded' || (string) ($order['product_type'] ?? '') !== 'forum_plate_follow') return;
|
||||
$user = (int) ($order['user_id'] ?? 0);
|
||||
$plate = (int) ($order['product_id'] ?? 0);
|
||||
if ($user > 0 && $plate > 0 && (string) get_user_meta($user, '_jio_forum_plate_entitlement_' . $plate, true) === $order_number) {
|
||||
self::revoke_plate_entitlement($user, $plate);
|
||||
if (get_user_meta($user, '_jio_forum_follow_' . $plate, true)) jio_update_post_counter($plate, '_jio_forum_follow_count', -1);
|
||||
delete_user_meta($user, '_jio_forum_follow_' . $plate);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
namespace Jio\Forum;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class Core {
|
||||
private const ROUTE_VERSION = 1;
|
||||
public const PLATE = 'jio_forum_plate';
|
||||
public const POST = 'jio_forum_post';
|
||||
public const CATEGORY = 'jio_forum_plate_cat';
|
||||
public const TOPIC = 'jio_forum_topic';
|
||||
public const TAG = 'jio_forum_tag';
|
||||
|
||||
public static function init(): void {
|
||||
add_action('init', [self::class, 'register'], 2);
|
||||
add_action('init', [self::class, 'routes'], 10);
|
||||
add_filter('query_vars', [self::class, 'query_vars']);
|
||||
add_filter('post_type_link', [self::class, 'permalink'], 10, 2);
|
||||
add_filter('template_include', [self::class, 'template']);
|
||||
add_filter('pre_handle_404', [self::class, 'edit_404'], 10, 2);
|
||||
add_action('after_switch_theme', [self::class, 'ensure_home']);
|
||||
add_action('admin_init', [self::class, 'ensure_home']);
|
||||
add_action('init', [self::class, 'ensure_home'], 20);
|
||||
add_action('wp_enqueue_scripts', [self::class, 'assets'], 30);
|
||||
add_action('widgets_init', static function (): void { register_widget(Widget::class); }, 11);
|
||||
add_action('wp_loaded', [self::class, 'maybe_flush_routes'], 30);
|
||||
add_action('trashed_post', [self::class, 'cascade_trashed_plate']);
|
||||
add_action('untrashed_post', [self::class, 'restore_trashed_plate']);
|
||||
add_filter('wp_untrash_post_status', [self::class, 'restore_status'], 10, 2);
|
||||
}
|
||||
|
||||
public static function enabled(): bool {
|
||||
return (bool) jio_get_setting('jio_forum_enabled', true);
|
||||
}
|
||||
|
||||
public static function slug(string $type): string {
|
||||
$defaults = ['plate' => 'forum', 'post' => 'forum-post', 'edit' => 'posts-edit'];
|
||||
$slug = sanitize_title((string) jio_get_setting('jio_forum_slug_' . $type, $defaults[$type] ?? ''));
|
||||
return $slug !== '' ? $slug : ($defaults[$type] ?? 'forum');
|
||||
}
|
||||
|
||||
public static function home_url(): string {
|
||||
return function_exists('jio_get_template_url') ? jio_get_template_url('forums') : home_url('/forums/');
|
||||
}
|
||||
|
||||
public static function edit_url(int $id = 0): string {
|
||||
return home_url('/' . self::slug('edit') . '/' . ($id > 0 ? $id . '/' : ''));
|
||||
}
|
||||
|
||||
public static function register(): void {
|
||||
if (!self::enabled()) {
|
||||
return;
|
||||
}
|
||||
$plate_name = sanitize_text_field((string) jio_get_setting('jio_forum_plate_name', '版块')) ?: '版块';
|
||||
$post_name = sanitize_text_field((string) jio_get_setting('jio_forum_post_name', '帖子')) ?: '帖子';
|
||||
$topic_name = sanitize_text_field((string) jio_get_setting('jio_forum_topic_name', '话题')) ?: '话题';
|
||||
register_post_type(self::PLATE, [
|
||||
'labels' => ['name' => '论坛' . $plate_name, 'singular_name' => $plate_name, 'add_new_item' => '创建' . $plate_name, 'edit_item' => '编辑' . $plate_name],
|
||||
'public' => true, 'show_ui' => true, 'show_in_rest' => false, 'show_in_menu' => true,
|
||||
'menu_icon' => 'dashicons-buddicons-forums', 'supports' => ['title', 'editor', 'excerpt', 'thumbnail', 'author'],
|
||||
'has_archive' => false, 'rewrite' => false, 'exclude_from_search' => true,
|
||||
'capability_type' => 'post', 'capabilities' => self::admin_capabilities(), 'map_meta_cap' => false,
|
||||
]);
|
||||
register_post_type(self::POST, [
|
||||
'labels' => ['name' => '论坛' . $post_name, 'singular_name' => $post_name, 'add_new_item' => '发布' . $post_name, 'edit_item' => '编辑' . $post_name],
|
||||
'public' => true, 'show_ui' => true, 'show_in_rest' => false, 'show_in_menu' => true,
|
||||
'menu_icon' => 'dashicons-buddicons-topics', 'supports' => ['title', 'editor', 'excerpt', 'thumbnail', 'author', 'comments'],
|
||||
'has_archive' => false, 'rewrite' => false, 'exclude_from_search' => false,
|
||||
'capability_type' => 'post', 'capabilities' => self::admin_capabilities(), 'map_meta_cap' => false,
|
||||
]);
|
||||
register_taxonomy(self::CATEGORY, [self::PLATE], [
|
||||
'labels' => ['name' => '版块分类', 'singular_name' => '版块分类'], 'public' => true,
|
||||
'show_ui' => true, 'show_in_rest' => false, 'show_admin_column' => true,
|
||||
'hierarchical' => true, 'rewrite' => ['slug' => 'plate_cat'],
|
||||
'capabilities' => self::term_capabilities(),
|
||||
]);
|
||||
foreach ([self::TOPIC => ['论坛' . $topic_name, 'forum_topic'], self::TAG => ['论坛标签', 'forum_tag']] as $taxonomy => [$label, $slug]) {
|
||||
register_taxonomy($taxonomy, [self::POST], [
|
||||
'labels' => ['name' => $label, 'singular_name' => $label], 'public' => true,
|
||||
'show_ui' => true, 'show_in_rest' => false, 'show_admin_column' => true,
|
||||
'hierarchical' => false, 'rewrite' => ['slug' => $slug],
|
||||
'capabilities' => self::term_capabilities(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private static function admin_capabilities(): array {
|
||||
return array_fill_keys([
|
||||
'edit_post', 'read_post', 'delete_post', 'edit_posts', 'edit_others_posts', 'publish_posts',
|
||||
'read_private_posts', 'delete_posts', 'delete_private_posts', 'delete_published_posts',
|
||||
'delete_others_posts', 'edit_private_posts', 'edit_published_posts', 'create_posts',
|
||||
], 'manage_options');
|
||||
}
|
||||
|
||||
private static function term_capabilities(): array {
|
||||
return ['manage_terms' => 'manage_options', 'edit_terms' => 'manage_options', 'delete_terms' => 'manage_options', 'assign_terms' => 'manage_options'];
|
||||
}
|
||||
|
||||
public static function cascade_trashed_plate(int $plate_id): void {
|
||||
if (get_post_type($plate_id) !== self::PLATE) return;
|
||||
$posts = get_posts(['post_type' => self::POST, 'post_status' => ['publish', 'pending', 'draft'], 'meta_key' => '_jio_forum_plate_id', 'meta_value' => $plate_id, 'posts_per_page' => -1, 'fields' => 'ids']);
|
||||
foreach ($posts as $post_id) {
|
||||
update_post_meta($post_id, '_jio_forum_trashed_with_plate', $plate_id);
|
||||
wp_trash_post($post_id);
|
||||
}
|
||||
}
|
||||
|
||||
public static function restore_trashed_plate(int $plate_id): void {
|
||||
if (get_post_type($plate_id) !== self::PLATE) return;
|
||||
$posts = get_posts(['post_type' => self::POST, 'post_status' => 'trash', 'meta_key' => '_jio_forum_trashed_with_plate', 'meta_value' => $plate_id, 'posts_per_page' => -1, 'fields' => 'ids']);
|
||||
foreach ($posts as $post_id) {
|
||||
wp_untrash_post($post_id);
|
||||
delete_post_meta($post_id, '_jio_forum_trashed_with_plate');
|
||||
}
|
||||
}
|
||||
|
||||
public static function restore_status(string $status, int $post_id): string {
|
||||
if (!in_array(get_post_type($post_id), [self::PLATE, self::POST], true)) return $status;
|
||||
$previous = (string) get_post_meta($post_id, '_wp_trash_meta_status', true);
|
||||
return in_array($previous, ['publish', 'pending', 'draft'], true) ? $previous : $status;
|
||||
}
|
||||
|
||||
public static function routes(): void {
|
||||
if (!self::enabled()) {
|
||||
return;
|
||||
}
|
||||
foreach ([self::PLATE => 'plate', self::POST => 'post'] as $post_type => $kind) {
|
||||
$slug = preg_quote(self::slug($kind), '#');
|
||||
add_rewrite_rule('^' . $slug . '/([0-9]+)\.html/?$', 'index.php?post_type=' . $post_type . '&p=$matches[1]', 'top');
|
||||
}
|
||||
$edit = preg_quote(self::slug('edit'), '#');
|
||||
add_rewrite_rule('^' . $edit . '/([0-9]+)/?$', 'index.php?jio_forum_edit=$matches[1]', 'top');
|
||||
add_rewrite_rule('^' . $edit . '/?$', 'index.php?jio_forum_edit=add', 'top');
|
||||
}
|
||||
|
||||
public static function maybe_flush_routes(): void {
|
||||
$hash = hash('sha256', wp_json_encode([
|
||||
self::ROUTE_VERSION, self::enabled(), self::slug('plate'), self::slug('post'), self::slug('edit'),
|
||||
]));
|
||||
if ((string) get_option('jio_forum_rewrite_hash', '') !== $hash) {
|
||||
flush_rewrite_rules();
|
||||
update_option('jio_forum_rewrite_hash', $hash, false);
|
||||
}
|
||||
}
|
||||
|
||||
public static function query_vars(array $vars): array {
|
||||
$vars[] = 'jio_forum_edit';
|
||||
return $vars;
|
||||
}
|
||||
|
||||
public static function permalink(string $link, \WP_Post $post): string {
|
||||
$kind = $post->post_type === self::PLATE ? 'plate' : ($post->post_type === self::POST ? 'post' : '');
|
||||
return $kind ? home_url('/' . self::slug($kind) . '/' . $post->ID . '.html') : $link;
|
||||
}
|
||||
|
||||
public static function edit_404($preempt, \WP_Query $query) {
|
||||
if ($query->is_main_query() && get_query_var('jio_forum_edit')) {
|
||||
$query->is_404 = false;
|
||||
return true;
|
||||
}
|
||||
return $preempt;
|
||||
}
|
||||
|
||||
public static function template(string $template): string {
|
||||
if (!self::enabled()) {
|
||||
return $template;
|
||||
}
|
||||
if (get_query_var('jio_forum_edit')) {
|
||||
return get_theme_file_path('templates/pages/page-forum-edit.php');
|
||||
}
|
||||
if (is_singular(self::POST)) {
|
||||
return get_theme_file_path('templates/pages/page-forum-post.php');
|
||||
}
|
||||
if (is_singular(self::PLATE) || is_tax([self::CATEGORY, self::TOPIC, self::TAG])) {
|
||||
return get_theme_file_path('templates/pages/page-forum-list.php');
|
||||
}
|
||||
return $template;
|
||||
}
|
||||
|
||||
public static function ensure_home(): void {
|
||||
if (self::enabled() && function_exists('jio_ensure_template_page')) {
|
||||
jio_ensure_template_page('forums');
|
||||
}
|
||||
}
|
||||
|
||||
public static function assets(): void {
|
||||
$account_route = get_query_var('jio_user_center_tab') === 'forum'
|
||||
|| (string) get_query_var('jio_author_identifier') !== ''
|
||||
|| is_author();
|
||||
if (!self::enabled() || !(is_singular([self::POST, self::PLATE]) || is_tax([self::CATEGORY, self::TOPIC, self::TAG]) || is_page_template('templates/pages/page-forums.php') || $account_route || get_query_var('jio_forum_edit') || (is_search() && in_array(sanitize_key((string) ($_GET['type'] ?? '')), ['forum', 'plate'], true)))) {
|
||||
return;
|
||||
}
|
||||
$css = get_theme_file_path('assets/css/routes/forum.css');
|
||||
$js = get_theme_file_path('assets/js/forum-runtime.js');
|
||||
wp_enqueue_style('jio-forum', get_theme_file_uri('assets/css/routes/forum.css'), ['jio-public'], (string) filemtime($css));
|
||||
wp_enqueue_script('jio-forum', get_theme_file_uri('assets/js/forum-runtime.js'), [], (string) filemtime($js), true);
|
||||
wp_enqueue_script('jio-qrcode');
|
||||
$plates = get_posts(['post_type' => self::PLATE, 'post_status' => 'publish', 'posts_per_page' => 100, 'fields' => 'ids']);
|
||||
wp_add_inline_script('jio-forum', 'window.jioForum=' . wp_json_encode([
|
||||
'api' => esc_url_raw(rest_url('jio/v1/forum/')),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'login' => function_exists('jio_get_template_url') ? jio_get_template_url('sign') : wp_login_url(),
|
||||
'orders' => function_exists('jio_get_user_center_url') ? jio_get_user_center_url('orders') : home_url('/user/orders/'),
|
||||
'plates' => array_map(static fn(int $id): array => ['id' => $id, 'title' => get_the_title($id)], $plates),
|
||||
]) . ';', 'before');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace Jio\Forum;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class Integration {
|
||||
public static function init(): void {
|
||||
add_filter('jio_user_center_panels', [self::class, 'center_panel']);
|
||||
add_filter('jio_user_home_tabs', [self::class, 'home_tab']);
|
||||
add_filter('jio_searchable_post_types', [self::class, 'searchable_types']);
|
||||
add_action('pre_get_posts', [self::class, 'search_query'], 30);
|
||||
add_filter('posts_clauses', [self::class, 'sort_forum_posts'], 10, 2);
|
||||
add_action('wp', [self::class, 'track_view']);
|
||||
add_filter('pre_comment_approved', [self::class, 'reply_review'], 10, 2);
|
||||
add_filter('preprocess_comment', [self::class, 'comment_guard']);
|
||||
add_filter('the_content', [self::class, 'content_guard'], 1);
|
||||
add_filter('get_the_excerpt', [self::class, 'excerpt_guard'], 1, 2);
|
||||
add_filter('jio_seo_head_payload', [self::class, 'seo_guard']);
|
||||
add_action('jio_forum_post_created', [self::class, 'post_created'], 10, 3);
|
||||
add_action('jio_forum_post_reviewed', [self::class, 'post_reviewed'], 10, 3);
|
||||
add_action('jio_forum_plate_reviewed', [self::class, 'plate_reviewed'], 10, 3);
|
||||
add_action('jio_forum_answer_adopted', [self::class, 'answer_adopted'], 10, 3);
|
||||
add_action('jio_forum_moderator_application_processed', [self::class, 'application_processed'], 10, 3);
|
||||
add_action('jio_forum_moderator_applied', [self::class, 'moderator_applied'], 10, 2);
|
||||
}
|
||||
|
||||
public static function center_panel(array $panels): array {
|
||||
if (Core::enabled()) {
|
||||
$panels['forum'] = ['label' => '论坛', 'icon' => 'comments', 'priority' => 22, 'type' => 'panel', 'template' => 'templates/parts/user-center/forum'];
|
||||
}
|
||||
return $panels;
|
||||
}
|
||||
|
||||
public static function home_tab(array $tabs): array {
|
||||
if (Core::enabled()) {
|
||||
$tabs['forum'] = ['label' => '论坛', 'icon' => 'comments', 'priority' => 15];
|
||||
}
|
||||
return $tabs;
|
||||
}
|
||||
|
||||
public static function searchable_types(array $types): array {
|
||||
if (Core::enabled() && jio_get_setting('jio_forum_search_enabled', true)) {
|
||||
$types[] = Core::POST;
|
||||
$types[] = Core::PLATE;
|
||||
}
|
||||
return $types;
|
||||
}
|
||||
|
||||
public static function search_query(\WP_Query $query): void {
|
||||
if (is_admin() || !$query->is_main_query() || !$query->is_search() || !Core::enabled()) {
|
||||
return;
|
||||
}
|
||||
$type = sanitize_key((string) ($_GET['type'] ?? ''));
|
||||
if (in_array($type, ['forum', 'plate'], true) && jio_get_setting('jio_forum_search_enabled', true)) {
|
||||
$query->set('post_type', $type === 'forum' ? Core::POST : Core::PLATE);
|
||||
$query->set('type', $type);
|
||||
}
|
||||
}
|
||||
|
||||
public static function sort_forum_posts(array $clauses, \WP_Query $query): array {
|
||||
$mode = sanitize_key((string) $query->get('jio_forum_list'));
|
||||
if (!in_array($mode, ['latest', 'popular', 'question', 'vote', 'essence', 'follow'], true)) return $clauses;
|
||||
global $wpdb;
|
||||
$clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} AS jio_forum_pin ON ({$wpdb->posts}.ID = jio_forum_pin.post_id AND jio_forum_pin.meta_key = '_jio_forum_pinned')";
|
||||
$pin = 'CAST(COALESCE(jio_forum_pin.meta_value, 0) AS SIGNED) DESC';
|
||||
if ($mode === 'popular') {
|
||||
$clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} AS jio_forum_up ON ({$wpdb->posts}.ID = jio_forum_up.post_id AND jio_forum_up.meta_key = '_jio_forum_score_up_total')";
|
||||
$clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} AS jio_forum_down ON ({$wpdb->posts}.ID = jio_forum_down.post_id AND jio_forum_down.meta_key = '_jio_forum_score_down_total')";
|
||||
$clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} AS jio_forum_fav ON ({$wpdb->posts}.ID = jio_forum_fav.post_id AND jio_forum_fav.meta_key = '_jio_forum_favorite_count')";
|
||||
$clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} AS jio_forum_views ON ({$wpdb->posts}.ID = jio_forum_views.post_id AND jio_forum_views.meta_key = '_jio_forum_views')";
|
||||
$rank = "({$wpdb->posts}.comment_count * 2 + CAST(COALESCE(jio_forum_up.meta_value, 0) AS SIGNED) * 10 - CAST(COALESCE(jio_forum_down.meta_value, 0) AS SIGNED) * 15 + CAST(COALESCE(jio_forum_fav.meta_value, 0) AS SIGNED) * 10 + CAST(COALESCE(jio_forum_views.meta_value, 0) AS SIGNED))";
|
||||
$clauses['orderby'] = $pin . ', ' . $rank . ' DESC, ' . $wpdb->posts . '.post_date DESC';
|
||||
} else {
|
||||
$clauses['orderby'] = $pin . ', ' . $wpdb->posts . '.post_date DESC';
|
||||
}
|
||||
return $clauses;
|
||||
}
|
||||
|
||||
public static function track_view(): void {
|
||||
if (!is_singular(Core::POST) || is_preview()) return;
|
||||
$id = (int) get_queried_object_id();
|
||||
if (!$id || !Access::can_view(get_current_user_id(), $id)) return;
|
||||
$key = 'jio_forum_view_' . $id;
|
||||
if (!empty($_COOKIE[$key])) return;
|
||||
if (function_exists('jio_update_post_counter')) jio_update_post_counter($id, '_jio_forum_views', 1);
|
||||
else update_post_meta($id, '_jio_forum_views', (int) get_post_meta($id, '_jio_forum_views', true) + 1);
|
||||
setcookie($key, '1', ['expires' => time() + DAY_IN_SECONDS, 'path' => '/', 'secure' => is_ssl(), 'httponly' => true, 'samesite' => 'Lax']);
|
||||
$_COOKIE[$key] = '1';
|
||||
}
|
||||
|
||||
public static function reply_review($approved, array $comment_data) {
|
||||
$id = (int) ($comment_data['comment_post_ID'] ?? 0);
|
||||
if (get_post_type($id) === Core::POST && jio_get_setting('jio_forum_reply_review', false) && !Access::is_moderator(get_current_user_id(), (int) get_post_meta($id, '_jio_forum_plate_id', true))) {
|
||||
return 0;
|
||||
}
|
||||
return $approved;
|
||||
}
|
||||
|
||||
public static function comment_guard(array $data): array {
|
||||
if (get_post_type((int) ($data['comment_post_ID'] ?? 0)) === Core::POST && !Rest::inside_reply()) {
|
||||
wp_die(esc_html__('请在帖子页面提交回复', 'jio'), '', ['response' => 403]);
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
public static function content_guard(string $content): string {
|
||||
$id = get_the_ID();
|
||||
return $id && get_post_type($id) === Core::POST && !Access::can_view(get_current_user_id(), $id) ? '<p>内容已隐藏</p>' : $content;
|
||||
}
|
||||
|
||||
public static function excerpt_guard(string $excerpt, \WP_Post $post): string {
|
||||
return $post->post_type === Core::POST && !Access::can_view(get_current_user_id(), $post->ID) ? '内容已隐藏' : $excerpt;
|
||||
}
|
||||
|
||||
public static function seo_guard(array $payload): array {
|
||||
$post = $payload['object'] ?? null;
|
||||
if ($post instanceof \WP_Post && $post->post_type === Core::POST && !Access::can_view(get_current_user_id(), $post->ID)) {
|
||||
$payload['description'] = '内容已隐藏';
|
||||
$payload['keywords'] = '';
|
||||
}
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public static function post_created(int $post_id, int $user_id, string $status): void {
|
||||
if ($status !== 'pending') {
|
||||
return;
|
||||
}
|
||||
$plate = (int) get_post_meta($post_id, '_jio_forum_plate_id', true);
|
||||
$owner = (int) get_post_field('post_author', $plate);
|
||||
if ($owner > 0 && $owner !== $user_id) {
|
||||
self::notify($owner, '帖子待审核', get_the_title($post_id), Core::edit_url($post_id));
|
||||
}
|
||||
}
|
||||
|
||||
public static function post_reviewed(int $post_id, string $status, int $actor): void {
|
||||
$owner = (int) get_post_field('post_author', $post_id);
|
||||
if ($owner > 0 && $owner !== $actor) {
|
||||
self::notify($owner, $status === 'publish' ? '帖子审核通过' : '帖子待审核', get_the_title($post_id), get_permalink($post_id));
|
||||
}
|
||||
}
|
||||
|
||||
public static function plate_reviewed(int $plate_id, string $status, int $actor): void {
|
||||
$owner = (int) get_post_field('post_author', $plate_id);
|
||||
if ($owner > 0 && $owner !== $actor) {
|
||||
self::notify($owner, $status === 'publish' ? '版块审核通过' : '版块待审核', get_the_title($plate_id), get_permalink($plate_id));
|
||||
}
|
||||
}
|
||||
|
||||
public static function answer_adopted(int $post_id, int $comment_id, int $actor): void {
|
||||
$comment = get_comment($comment_id);
|
||||
$user = $comment instanceof \WP_Comment ? (int) $comment->user_id : 0;
|
||||
if ($user > 0 && $user !== $actor) {
|
||||
self::notify($user, '回答被采纳', get_the_title($post_id), get_comment_link($comment_id));
|
||||
}
|
||||
}
|
||||
|
||||
public static function moderator_applied(int $plate_id, int $user_id): void {
|
||||
$owner = (int) get_post_field('post_author', $plate_id);
|
||||
if ($owner > 0 && $owner !== $user_id) {
|
||||
self::notify($owner, '收到版主申请', get_the_title($plate_id), get_permalink($plate_id));
|
||||
}
|
||||
}
|
||||
|
||||
public static function application_processed(int $plate_id, int $user_id, string $status): void {
|
||||
self::notify($user_id, $status === 'approved' ? '版主申请通过' : '版主申请未通过', get_the_title($plate_id), get_permalink($plate_id));
|
||||
}
|
||||
|
||||
private static function notify(int $user_id, string $title, string $desc, string $url): void {
|
||||
if (function_exists('jio_add_user_notification')) {
|
||||
jio_add_user_notification($user_id, ['type' => 'system', 'title' => $title, 'desc' => $desc, 'url' => $url]);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Jio\Forum;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class Lock {
|
||||
public static function acquire(string $scope, int $user_id, int $object_id): string {
|
||||
$key = self::key($scope, $user_id, $object_id);
|
||||
$token = wp_generate_uuid4();
|
||||
if (add_option($key, ['token' => $token, 'created' => time()], '', false)) return $token;
|
||||
$old = get_option($key);
|
||||
if (is_array($old) && time() - (int) ($old['created'] ?? 0) > 120) {
|
||||
delete_option($key);
|
||||
if (add_option($key, ['token' => $token, 'created' => time()], '', false)) return $token;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
public static function release(string $scope, int $user_id, int $object_id, string $token): void {
|
||||
$key = self::key($scope, $user_id, $object_id);
|
||||
$stored = get_option($key);
|
||||
if (is_array($stored) && (string) ($stored['token'] ?? '') === $token) delete_option($key);
|
||||
}
|
||||
|
||||
private static function key(string $scope, int $user_id, int $object_id): string {
|
||||
return 'jio_forum_lock_' . md5($scope . ':' . $user_id . ':' . $object_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace Jio\Forum;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class Rest {
|
||||
use RestPosts;
|
||||
use RestPlates;
|
||||
use RestActions;
|
||||
use RestCommerce;
|
||||
use RestReplies;
|
||||
use RestMedia;
|
||||
|
||||
public static function init(): void {
|
||||
add_action('rest_api_init', [self::class, 'routes']);
|
||||
}
|
||||
|
||||
public static function routes(): void {
|
||||
$base = 'jio/v1';
|
||||
$auth = [self::class, 'authorize'];
|
||||
$routes = [
|
||||
['/forum/posts', 'POST', 'create_post'],
|
||||
['/forum/posts/(?P<id>\d+)', 'PATCH', 'update_post'],
|
||||
['/forum/plates', 'POST', 'create_plate'],
|
||||
['/forum/plates/(?P<id>\d+)', 'PATCH', 'update_plate'],
|
||||
['/forum/plates/(?P<id>\d+)/follow', 'POST', 'follow_plate'],
|
||||
['/forum/plates/(?P<id>\d+)/audit', 'POST', 'audit_plate'],
|
||||
['/forum/plates/(?P<id>\d+)/delete', 'POST', 'delete_plate'],
|
||||
['/forum/plates/(?P<id>\d+)/restore', 'POST', 'restore_plate'],
|
||||
['/forum/posts/(?P<id>\d+)/score', 'POST', 'score'],
|
||||
['/forum/posts/(?P<id>\d+)/favorite', 'POST', 'favorite'],
|
||||
['/forum/posts/(?P<id>\d+)/vote', 'POST', 'vote'],
|
||||
['/forum/posts/(?P<id>\d+)/adopt', 'POST', 'adopt'],
|
||||
['/forum/posts/(?P<id>\d+)/delete', 'POST', 'delete_post'],
|
||||
['/forum/posts/(?P<id>\d+)/restore', 'POST', 'restore_post'],
|
||||
['/forum/posts/(?P<id>\d+)/audit', 'POST', 'audit_post'],
|
||||
['/forum/posts/(?P<id>\d+)/pin', 'POST', 'pin_post'],
|
||||
['/forum/posts/(?P<id>\d+)/essence', 'POST', 'essence_post'],
|
||||
['/forum/posts/(?P<id>\d+)/move', 'POST', 'move_post'],
|
||||
['/forum/posts/(?P<id>\d+)/comments', 'POST', 'comments_status'],
|
||||
['/forum/posts/(?P<id>\d+)/report', 'POST', 'report_post'],
|
||||
['/forum/posts/(?P<id>\d+)/replies', 'POST', 'reply'],
|
||||
['/forum/replies/(?P<id>\d+)/like', 'POST', 'like_reply'],
|
||||
['/forum/replies/(?P<id>\d+)/hot', 'POST', 'hot_reply'],
|
||||
['/forum/replies/(?P<id>\d+)/audit', 'POST', 'audit_reply'],
|
||||
['/forum/replies/(?P<id>\d+)/delete', 'POST', 'delete_reply'],
|
||||
['/forum/replies/(?P<id>\d+)/restore', 'POST', 'restore_reply'],
|
||||
['/forum/terms', 'POST', 'create_term'],
|
||||
['/forum/terms/(?P<id>\d+)', 'PATCH', 'update_term'],
|
||||
['/forum/terms/(?P<id>\d+)', 'DELETE', 'delete_term'],
|
||||
['/forum/plates/(?P<id>\d+)/moderators', 'POST', 'moderator'],
|
||||
['/forum/plates/(?P<id>\d+)/apply', 'POST', 'apply_moderator'],
|
||||
['/forum/plates/(?P<id>\d+)/applications/(?P<user_id>\d+)', 'POST', 'process_application'],
|
||||
['/forum/posts/(?P<id>\d+)/checkout', 'POST', 'checkout_post'],
|
||||
['/forum/posts/(?P<id>\d+)/checkout', 'GET', 'preview_post'],
|
||||
['/forum/plates/(?P<id>\d+)/checkout', 'POST', 'checkout_plate'],
|
||||
['/forum/plates/(?P<id>\d+)/checkout', 'GET', 'preview_plate'],
|
||||
['/forum/upload/(?P<kind>image|video|resource)', 'POST', 'upload'],
|
||||
];
|
||||
foreach ($routes as [$path, $method, $handler]) {
|
||||
register_rest_route($base, $path, [
|
||||
'methods' => $method,
|
||||
'callback' => [self::class, $handler],
|
||||
'permission_callback' => $auth,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public static function authorize(\WP_REST_Request $request) {
|
||||
if (!Core::enabled()) {
|
||||
return new \WP_Error('jio_forum_disabled', '论坛未启用', ['status' => 404]);
|
||||
}
|
||||
if (!is_user_logged_in()) {
|
||||
return new \WP_Error('jio_forum_login', '请先登录', ['status' => 401]);
|
||||
}
|
||||
$nonce = (string) $request->get_header('X-WP-Nonce');
|
||||
if ($nonce === '' || !wp_verify_nonce($nonce, 'wp_rest')) {
|
||||
return new \WP_Error('jio_forum_nonce', '安全验证失败,请刷新页面', ['status' => 403]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function fail(string $message, int $status = 400): \WP_Error {
|
||||
return new \WP_Error('jio_forum_error', $message, ['status' => $status]);
|
||||
}
|
||||
|
||||
private static function success(array $data = [], string $message = '操作成功'): \WP_REST_Response {
|
||||
return new \WP_REST_Response(['success' => true, 'data' => $data, 'message' => $message], 200);
|
||||
}
|
||||
|
||||
private static function input(\WP_REST_Request $request): array {
|
||||
$json = $request->get_json_params();
|
||||
return is_array($json) ? $json : $request->get_params();
|
||||
}
|
||||
|
||||
private static function post(int $id): ?\WP_Post {
|
||||
$post = get_post($id);
|
||||
return $post instanceof \WP_Post && $post->post_type === Core::POST ? $post : null;
|
||||
}
|
||||
|
||||
private static function plate(int $id): ?\WP_Post {
|
||||
$post = get_post($id);
|
||||
return $post instanceof \WP_Post && $post->post_type === Core::PLATE ? $post : null;
|
||||
}
|
||||
|
||||
private static function actor(): int {
|
||||
return get_current_user_id();
|
||||
}
|
||||
|
||||
private static function with_lock(string $scope, int $id, callable $operation) {
|
||||
$user = self::actor();
|
||||
$token = Lock::acquire($scope, $user, $id);
|
||||
if ($token === '') return self::fail('操作正在处理中,请稍后重试', 409);
|
||||
try {
|
||||
$object_token = Lock::acquire($scope . '-object', 0, $id);
|
||||
if ($object_token === '') return self::fail('操作正在处理中,请稍后重试', 409);
|
||||
try {
|
||||
return $operation();
|
||||
} finally {
|
||||
Lock::release($scope . '-object', 0, $id, $object_token);
|
||||
}
|
||||
} finally {
|
||||
Lock::release($scope, $user, $id, $token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
namespace Jio\Forum;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait RestActions {
|
||||
public static function follow_plate(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
if (!self::plate($id) || get_post_status($id) !== 'publish') {
|
||||
return self::fail('版块不存在', 404);
|
||||
}
|
||||
$user = self::actor();
|
||||
return self::with_lock('follow', $id, static function () use ($user, $id) {
|
||||
$key = '_jio_forum_follow_' . $id;
|
||||
if (Access::follows($user, $id)) {
|
||||
delete_user_meta($user, $key);
|
||||
Commerce::revoke_plate_entitlement($user, $id);
|
||||
jio_update_post_counter($id, '_jio_forum_follow_count', -1);
|
||||
return self::success(['following' => false, 'label' => '关注版块'], '已取消关注');
|
||||
}
|
||||
$gate = (string) get_post_meta($id, '_jio_forum_follow_mode', true);
|
||||
if (in_array($gate, ['pay', 'points'], true) && !Commerce::has_plate_entitlement($user, $id)) {
|
||||
return self::fail('此版块需要先完成付费关注', 402);
|
||||
}
|
||||
add_user_meta($user, $key, 1, true);
|
||||
jio_update_post_counter($id, '_jio_forum_follow_count', 1);
|
||||
do_action('jio_forum_plate_followed', $id, $user);
|
||||
return self::success(['following' => true, 'label' => '已关注'], '已关注版块');
|
||||
});
|
||||
}
|
||||
|
||||
public static function score(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
if (!self::post($id) || get_post_status($id) !== 'publish') {
|
||||
return self::fail('帖子不存在', 404);
|
||||
}
|
||||
$user = self::actor();
|
||||
if (!jio_get_setting('jio_forum_score_enabled', true)) {
|
||||
return self::fail('评分未启用');
|
||||
}
|
||||
$direction = sanitize_key((string) (self::input($request)['direction'] ?? 'up'));
|
||||
if (!in_array($direction, ['up', 'down'], true)) {
|
||||
return self::fail('评分类型无效');
|
||||
}
|
||||
return self::with_lock('score', $id, static function () use ($user, $id, $direction) {
|
||||
$key = '_jio_forum_score_' . $id;
|
||||
$previous = (int) get_user_meta($user, $key, true);
|
||||
$delta = $direction === 'up' ? 1 : -1;
|
||||
$new_user_score = $previous + $delta;
|
||||
$limit = max(0, (int) jio_get_setting($direction === 'up' ? 'jio_forum_score_up_max' : 'jio_forum_score_down_max', 5));
|
||||
if (($delta > 0 && $new_user_score > $limit) || ($delta < 0 && $new_user_score < -$limit)) {
|
||||
return self::fail('已达到评分上限');
|
||||
}
|
||||
update_user_meta($user, $key, $new_user_score);
|
||||
jio_update_post_counter($id, '_jio_forum_score_up_total', max(0, $new_user_score) - max(0, $previous));
|
||||
jio_update_post_counter($id, '_jio_forum_score_down_total', max(0, -$new_user_score) - max(0, -$previous));
|
||||
$score = (int) get_post_meta($id, '_jio_forum_score', true) + $delta;
|
||||
update_post_meta($id, '_jio_forum_score', $score);
|
||||
do_action('jio_forum_post_scored', $id, $user, $direction);
|
||||
return self::success(['score' => $score, 'user_score' => $new_user_score, 'direction' => $direction, 'label' => '评分 ' . $score], '评分成功');
|
||||
});
|
||||
}
|
||||
|
||||
public static function favorite(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
if (!self::post($id) || get_post_status($id) !== 'publish') {
|
||||
return self::fail('帖子不存在', 404);
|
||||
}
|
||||
$key = '_jio_forum_favorite_' . $id;
|
||||
$user = self::actor();
|
||||
return self::with_lock('favorite', $id, static function () use ($user, $id, $key) {
|
||||
if (get_user_meta($user, $key, true)) {
|
||||
delete_user_meta($user, $key);
|
||||
$active = false;
|
||||
} else {
|
||||
add_user_meta($user, $key, 1, true);
|
||||
$active = true;
|
||||
}
|
||||
jio_update_post_counter($id, '_jio_forum_favorite_count', $active ? 1 : -1);
|
||||
do_action('jio_forum_post_favorited', $id, $user, $active);
|
||||
return self::success(['active' => $active, 'label' => $active ? '已收藏' : '收藏'], $active ? '已收藏' : '已取消收藏');
|
||||
});
|
||||
}
|
||||
|
||||
public static function vote(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
if (!self::post($id) || get_post_status($id) !== 'publish' || get_post_meta($id, '_jio_forum_type', true) !== 'vote') {
|
||||
return self::fail('投票不存在', 404);
|
||||
}
|
||||
if (!Access::can_view(self::actor(), $id)) return self::fail('无权查看此投票', 403);
|
||||
$user = self::actor();
|
||||
return self::with_lock('vote', $id, static function () use ($request, $user, $id) {
|
||||
$key = '_jio_forum_vote_' . $id;
|
||||
if (get_user_meta($user, $key, true) !== '') {
|
||||
return self::success(['selected' => get_user_meta($user, $key, true)], '已经投票');
|
||||
}
|
||||
$deadline = (int) get_post_meta($id, '_jio_forum_vote_deadline', true);
|
||||
if ($deadline > 0 && current_time('timestamp') > $deadline) return self::fail('投票已截止', 409);
|
||||
$input = self::input($request);
|
||||
$options = (array) get_post_meta($id, '_jio_forum_vote_options', true);
|
||||
$type = (string) get_post_meta($id, '_jio_forum_vote_type', true);
|
||||
$selected = $type === 'multiple' ? (array) ($input['options'] ?? []) : [$input['option'] ?? null];
|
||||
$indices = [];
|
||||
foreach ($selected as $value) {
|
||||
$index = filter_var($value, FILTER_VALIDATE_INT);
|
||||
if ($index === false || $index === null || !array_key_exists($index, $options) || in_array($index, $indices, true)) {
|
||||
return self::fail('请选择有效选项');
|
||||
}
|
||||
$indices[] = $index;
|
||||
}
|
||||
if (!$indices || $type !== 'multiple' && count($indices) !== 1) return self::fail('请选择有效选项');
|
||||
add_user_meta($user, $key, $type === 'multiple' ? $indices : (string) $indices[0], true);
|
||||
$counts = (array) get_post_meta($id, '_jio_forum_vote_counts', true);
|
||||
foreach ($indices as $index) $counts[$index] = (int) ($counts[$index] ?? 0) + 1;
|
||||
update_post_meta($id, '_jio_forum_vote_counts', $counts);
|
||||
update_post_meta($id, '_jio_forum_vote_total', (int) get_post_meta($id, '_jio_forum_vote_total', true) + 1);
|
||||
return self::success(['selected' => $indices, 'counts' => $counts], '投票成功');
|
||||
});
|
||||
}
|
||||
|
||||
public static function adopt(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
$post = self::post($id);
|
||||
$comment_id = absint(self::input($request)['comment_id'] ?? 0);
|
||||
$comment = get_comment($comment_id);
|
||||
if (!$post || get_post_meta($id, '_jio_forum_type', true) !== 'question' || !$comment instanceof \WP_Comment || (int) $comment->comment_post_ID !== $id || $comment->comment_approved !== '1') {
|
||||
return self::fail('回复无效');
|
||||
}
|
||||
if (!Access::can_manage_post(self::actor(), $id)) {
|
||||
return self::fail('无权采纳回复', 403);
|
||||
}
|
||||
$old = (int) get_post_meta($id, '_jio_forum_adopted_comment', true);
|
||||
if ($old && $old !== $comment_id) {
|
||||
return self::fail('此问题已有采纳回复');
|
||||
}
|
||||
update_post_meta($id, '_jio_forum_adopted_comment', $comment_id);
|
||||
update_comment_meta($comment_id, '_jio_forum_adopted', 1);
|
||||
do_action('jio_forum_answer_adopted', $id, $comment_id, self::actor());
|
||||
return self::success(['comment_id' => $comment_id], '已采纳回复');
|
||||
}
|
||||
|
||||
public static function delete_post(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
if (!self::post($id) || !Access::can_manage_post(self::actor(), $id)) {
|
||||
return self::fail('无权删除', 403);
|
||||
}
|
||||
wp_trash_post($id);
|
||||
return self::success(['url' => Core::home_url()], '已删除');
|
||||
}
|
||||
|
||||
public static function restore_post(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
if (!self::post($id) || get_post_status($id) !== 'trash' || !Access::can_manage_post(self::actor(), $id)) {
|
||||
return self::fail('无权撤销删除', 403);
|
||||
}
|
||||
wp_untrash_post($id);
|
||||
return self::success(['url' => get_permalink($id)], '已恢复');
|
||||
}
|
||||
|
||||
public static function audit_post(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
$post = self::post($id);
|
||||
if (!$post || !Access::is_moderator(self::actor(), (int) get_post_meta($id, '_jio_forum_plate_id', true))) {
|
||||
return self::fail('无权审核', 403);
|
||||
}
|
||||
$status = sanitize_key((string) (self::input($request)['status'] ?? 'publish'));
|
||||
if (!in_array($status, ['publish', 'pending'], true)) {
|
||||
return self::fail('审核状态无效');
|
||||
}
|
||||
wp_update_post(['ID' => $id, 'post_status' => $status]);
|
||||
do_action('jio_forum_post_reviewed', $id, $status, self::actor());
|
||||
return self::success(['status' => $status], $status === 'publish' ? '审核通过' : '已转为待审核');
|
||||
}
|
||||
|
||||
public static function pin_post(\WP_REST_Request $request) {
|
||||
return self::flag($request, '_jio_forum_pinned', '置顶');
|
||||
}
|
||||
|
||||
public static function essence_post(\WP_REST_Request $request) {
|
||||
return self::flag($request, '_jio_forum_essence', '精华');
|
||||
}
|
||||
|
||||
private static function flag(\WP_REST_Request $request, string $key, string $label) {
|
||||
$id = absint($request['id']);
|
||||
if (!self::post($id) || !Access::is_moderator(self::actor(), (int) get_post_meta($id, '_jio_forum_plate_id', true))) {
|
||||
return self::fail('无权操作', 403);
|
||||
}
|
||||
$active = !(bool) get_post_meta($id, $key, true);
|
||||
update_post_meta($id, $key, $active ? 1 : 0);
|
||||
return self::success(['active' => $active, 'label' => ($active ? '取消' : '') . $label], $active ? '已设置' . $label : '已取消' . $label);
|
||||
}
|
||||
|
||||
public static function move_post(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
$old_plate = (int) get_post_meta($id, '_jio_forum_plate_id', true);
|
||||
$new_plate = absint(self::input($request)['plate_id'] ?? 0);
|
||||
if (!self::post($id) || !Access::is_moderator(self::actor(), $old_plate) || !self::plate($new_plate) || get_post_status($new_plate) !== 'publish') {
|
||||
return self::fail('无权移动帖子', 403);
|
||||
}
|
||||
update_post_meta($id, '_jio_forum_plate_id', $new_plate);
|
||||
return self::success(['plate_id' => $new_plate], '已移动帖子');
|
||||
}
|
||||
|
||||
public static function comments_status(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
if (!self::post($id) || !Access::is_moderator(self::actor(), (int) get_post_meta($id, '_jio_forum_plate_id', true))) {
|
||||
return self::fail('无权管理回复', 403);
|
||||
}
|
||||
$open = (string) (self::input($request)['status'] ?? 'closed') === 'open';
|
||||
wp_update_post(['ID' => $id, 'comment_status' => $open ? 'open' : 'closed']);
|
||||
return self::success(['open' => $open], $open ? '已开放回复' : '已关闭回复');
|
||||
}
|
||||
|
||||
public static function report_post(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
if (!self::post($id)) {
|
||||
return self::fail('帖子不存在', 404);
|
||||
}
|
||||
$input = self::input($request);
|
||||
$reason = sanitize_textarea_field((string) ($input['reason'] ?? ''));
|
||||
$category = sanitize_text_field((string) ($input['category'] ?? '其他'));
|
||||
if ($reason === '') {
|
||||
return self::fail('请填写举报原因');
|
||||
}
|
||||
$result = \Jio\Messages\ReportCenter::get_instance()->submit(self::actor(), 'post', $id, $reason, $category);
|
||||
return $result ? self::success(['report_id' => $result], '举报已提交') : self::fail('举报提交失败');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
namespace Jio\Forum;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait RestCommerce {
|
||||
public static function preview_post(\WP_REST_Request $request) {
|
||||
return self::preview_checkout('post', absint($request['id']));
|
||||
}
|
||||
|
||||
public static function preview_plate(\WP_REST_Request $request) {
|
||||
return self::preview_checkout('plate', absint($request['id']));
|
||||
}
|
||||
|
||||
private static function preview_checkout(string $kind, int $id) {
|
||||
$subject = $kind === 'post' ? self::post($id) : self::plate($id);
|
||||
if (!$subject || $subject->post_status !== 'publish') {
|
||||
return self::fail('内容不存在', 404);
|
||||
}
|
||||
if ($kind === 'post') {
|
||||
$plate_access = self::parent_plate_access($id, self::actor());
|
||||
if (is_wp_error($plate_access)) return $plate_access;
|
||||
}
|
||||
$rule = (string) get_post_meta($id, $kind === 'post' ? '_jio_forum_view_limit' : '_jio_forum_follow_mode', true);
|
||||
if ($kind === 'plate' && !in_array($rule, ['points', 'pay'], true)) {
|
||||
$rule = (string) get_post_meta($id, '_jio_forum_view_limit', true);
|
||||
}
|
||||
if (!in_array($rule, ['points', 'pay'], true)) {
|
||||
return self::fail('此内容不需要付费');
|
||||
}
|
||||
$price = self::price($kind, $id, $rule, self::actor());
|
||||
if (is_wp_error($price)) return $price;
|
||||
$methods = ['points'];
|
||||
if ($rule === 'pay') {
|
||||
$methods = [];
|
||||
foreach (['balance', 'alipay', 'wechatpay', 'paypal', 'stripe'] as $method) {
|
||||
if (isset(\Jio\Payment\PaymentOrchestrator::resolve_gateway($method)['gateway'])) {
|
||||
$methods[] = $method;
|
||||
}
|
||||
}
|
||||
if ($price !== '0.00' && !$methods) return self::fail('暂无可用支付方式', 503);
|
||||
}
|
||||
return self::success(['title' => $subject->post_title, 'mode' => $rule, 'price' => $price, 'methods' => $methods], '确认解锁');
|
||||
}
|
||||
|
||||
public static function checkout_post(\WP_REST_Request $request) {
|
||||
return self::checkout('post', absint($request['id']), $request);
|
||||
}
|
||||
|
||||
public static function checkout_plate(\WP_REST_Request $request) {
|
||||
return self::checkout('plate', absint($request['id']), $request);
|
||||
}
|
||||
|
||||
private static function price(string $kind, int $id, string $mode, int $user_id) {
|
||||
$key = $kind === 'post' ? '_jio_forum_price' : '_jio_forum_follow_price';
|
||||
$raw = (string) get_post_meta($id, $key, true);
|
||||
$vip = function_exists('jio_get_user_vip_level') ? min(2, (int) jio_get_user_vip_level($user_id)) : 0;
|
||||
if ($vip > 0 && jio_get_setting('jio_forum_vip_price_enabled', true)) {
|
||||
$override = (string) get_post_meta($id, $key . '_vip' . $vip, true);
|
||||
if ($override !== '') {
|
||||
$raw = $override;
|
||||
}
|
||||
}
|
||||
if ($mode === 'points') {
|
||||
if (!preg_match('/^(0|[1-9]\d*)$/', $raw) || strlen($raw) > strlen((string) PHP_INT_MAX) || (strlen($raw) === strlen((string) PHP_INT_MAX) && strcmp($raw, (string) PHP_INT_MAX) > 0)) {
|
||||
return self::fail('积分价格无效');
|
||||
}
|
||||
return $raw;
|
||||
}
|
||||
try {
|
||||
return \Jio\Ledger\AssetAmount::balance($raw)->decimal();
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
return self::fail('付费价格无效');
|
||||
}
|
||||
}
|
||||
|
||||
private static function checkout(string $kind, int $id, \WP_REST_Request $request) {
|
||||
$subject = $kind === 'post' ? self::post($id) : self::plate($id);
|
||||
if (!$subject || $subject->post_status !== 'publish') {
|
||||
return self::fail('内容不存在', 404);
|
||||
}
|
||||
$user = self::actor();
|
||||
if ($kind === 'post') {
|
||||
$plate_access = self::parent_plate_access($id, $user);
|
||||
if (is_wp_error($plate_access)) return $plate_access;
|
||||
}
|
||||
$mode = (string) get_post_meta($id, $kind === 'post' ? '_jio_forum_view_limit' : '_jio_forum_follow_mode', true);
|
||||
if ($kind === 'plate' && !in_array($mode, ['points', 'pay'], true)) {
|
||||
$mode = (string) get_post_meta($id, '_jio_forum_view_limit', true);
|
||||
}
|
||||
if (!in_array($mode, ['points', 'pay'], true)) {
|
||||
return self::fail('此内容不需要付费');
|
||||
}
|
||||
$product = $kind === 'post' ? 'forum_post' : 'forum_plate_follow';
|
||||
$lock = Lock::acquire('checkout-' . $product, $user, $id);
|
||||
if ($lock === '') return self::fail('正在处理同一内容的支付,请稍后重试', 409);
|
||||
try {
|
||||
$orders = \Jio\Payment\Order::get_instance();
|
||||
if ($kind === 'plate' ? Commerce::has_plate_entitlement($user, $id) : $orders->has_paid_access($user, $product, $id)) {
|
||||
return self::success(['paid' => true, 'url' => $kind === 'post' ? get_permalink($id) : get_permalink($id)], '已解锁');
|
||||
}
|
||||
$price = self::price($kind, $id, $mode, $user);
|
||||
if (is_wp_error($price)) return $price;
|
||||
$input = self::input($request);
|
||||
$method = $mode === 'points' ? 'points' : sanitize_key((string) ($input['payment_method'] ?? 'balance'));
|
||||
$resolved_gateway = null;
|
||||
if ($mode === 'pay' && $price !== '0.00') {
|
||||
$resolved_gateway = \Jio\Payment\PaymentOrchestrator::resolve_gateway($method);
|
||||
if (!isset($resolved_gateway['gateway'])) {
|
||||
return self::fail((string) ($resolved_gateway['message'] ?? '支付方式不可用'));
|
||||
}
|
||||
}
|
||||
$uuid = (string) $request->get_header('Idempotency-Key');
|
||||
if (!wp_is_uuid($uuid)) {
|
||||
return self::fail('缺少有效的重复请求标识');
|
||||
}
|
||||
$request_key = \Jio\Ledger\OperationKey::deterministic('forum-checkout', [$user, strtolower($uuid)]);
|
||||
$repo = \Jio\Payment\OrderRepository::get_instance();
|
||||
$existing = $repo->get_by_request_key($request_key);
|
||||
if (!$existing) {
|
||||
foreach ($repo->get_user_orders($user, ['product_type' => $product, 'status' => 'pending', 'limit' => 100]) as $pending) {
|
||||
if ((int) ($pending['product_id'] ?? 0) === $id) {
|
||||
$existing = $pending;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$stored_amount = $mode === 'points' ? (int) $price : $price;
|
||||
if ($existing && ((int) $existing['user_id'] !== $user || (string) $existing['product_type'] !== $product || (int) $existing['product_id'] !== $id || (string) $existing['currency'] !== ($mode === 'points' ? 'points' : 'cny') || $existing['amount'] !== $stored_amount || (string) $existing['payment_method'] !== $method)) {
|
||||
return self::fail('重复请求标识已用于其他购买', 409);
|
||||
}
|
||||
if ($existing && $existing['status'] === 'paid' && $kind !== 'plate') {
|
||||
return self::success(['paid' => true, 'url' => get_permalink($id)], '已解锁');
|
||||
}
|
||||
if ($existing && $existing['status'] === 'paid') {
|
||||
return self::fail('此订单的关注资格已结束,请重新发起支付', 409);
|
||||
}
|
||||
$order_number = $existing ? (string) $existing['order_number'] : $repo->create([
|
||||
'user_id' => $user, 'product_type' => $product, 'product_id' => $id,
|
||||
'amount' => $stored_amount, 'currency' => $mode === 'points' ? 'points' : 'CNY',
|
||||
'status' => \Jio\Payment\Order::STATUS_PENDING, 'payment_method' => $method,
|
||||
'request_key' => $request_key, 'note' => wp_json_encode(['forum' => 1, 'mode' => $mode, 'title' => $subject->post_title]),
|
||||
]);
|
||||
if ($order_number === '') {
|
||||
$existing = $repo->get_by_request_key($request_key);
|
||||
$order_number = $existing ? (string) $existing['order_number'] : '';
|
||||
}
|
||||
if ($order_number === '') {
|
||||
return self::fail('创建订单失败', 500);
|
||||
}
|
||||
if ((($mode === 'points') && (int) $price === 0) || (($mode === 'pay') && $price === '0.00')) {
|
||||
if (!$orders->update_status($order_number, \Jio\Payment\Order::STATUS_PAID, ['payment_method' => 'free', 'require_transition' => true])) {
|
||||
return self::fail('订单确认失败', 409);
|
||||
}
|
||||
return self::success(['paid' => true, 'url' => get_permalink($id)], '已解锁');
|
||||
}
|
||||
if ($mode === 'points') {
|
||||
$result = \Jio\Ledger\LedgerService::get_instance()->debit(
|
||||
$user, \Jio\Ledger\AssetType::POINTS, (int) $price,
|
||||
\Jio\Ledger\OperationKey::deterministic('forum-points', [$order_number]),
|
||||
'forum_points_pay', ['type' => 'order', 'key' => $order_number],
|
||||
'论坛内容解锁', $user, ['order_number' => $order_number],
|
||||
static function () use ($orders, $order_number) {
|
||||
return $orders->update_status($order_number, \Jio\Payment\Order::STATUS_PAID, ['payment_method' => 'points', 'require_transition' => true]);
|
||||
},
|
||||
static function () use ($repo, $order_number, $user) {
|
||||
$order = $repo->get($order_number);
|
||||
return $order && (int) $order['user_id'] === $user && $order['status'] === 'pending';
|
||||
}
|
||||
);
|
||||
if (is_wp_error($result) || !$result) {
|
||||
return self::fail(is_wp_error($result) ? $result->get_error_message() : '积分支付失败', 402);
|
||||
}
|
||||
return self::success(['paid' => true, 'url' => get_permalink($id)], '积分支付成功');
|
||||
}
|
||||
$order = $repo->get($order_number);
|
||||
$order['subject'] = '论坛内容:' . $subject->post_title;
|
||||
$order['payment_method'] = $method;
|
||||
$paid = \Jio\Payment\PaymentOrchestrator::pay($resolved_gateway['gateway'], $order, ['paid_note' => '论坛内容解锁']);
|
||||
if (empty($paid['success'])) {
|
||||
return self::fail((string) ($paid['message'] ?? '支付失败'), (int) ($paid['status'] ?? 400));
|
||||
}
|
||||
return self::success(array_merge(['order_number' => $order_number, 'url' => get_permalink($id)], \Jio\Payment\PaymentOrchestrator::cashier_payload($paid)), (string) ($paid['message'] ?? '请完成支付'));
|
||||
} finally {
|
||||
Lock::release('checkout-' . $product, $user, $id, $lock);
|
||||
}
|
||||
}
|
||||
|
||||
private static function parent_plate_access(int $post_id, int $user_id) {
|
||||
$plate_id = (int) get_post_meta($post_id, '_jio_forum_plate_id', true);
|
||||
if (!self::plate($plate_id) || get_post_status($plate_id) !== 'publish') return self::fail('所属版块不可用', 404);
|
||||
if (Access::is_moderator($user_id, $plate_id)) return true;
|
||||
$rule = (string) get_post_meta($plate_id, '_jio_forum_view_limit', true);
|
||||
if ($rule === 'follow' && !Access::follows($user_id, $plate_id)) return self::fail('请先关注版块', 403);
|
||||
if (in_array($rule, ['points', 'pay'], true) && !(Commerce::has_plate_entitlement($user_id, $plate_id) && Access::follows($user_id, $plate_id))) {
|
||||
return self::fail('请先解锁版块', 402);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Jio\Forum;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait RestMedia {
|
||||
public static function upload(\WP_REST_Request $request) {
|
||||
$kind = sanitize_key((string) $request['kind']);
|
||||
$key = ['image' => 'jio_forum_upload_image', 'video' => 'jio_forum_upload_video', 'resource' => 'jio_forum_upload_file'][$kind] ?? '';
|
||||
if ($key === '' || !jio_get_setting($key, $kind === 'image')) {
|
||||
return self::fail('此类型上传未启用', 403);
|
||||
}
|
||||
$files = $request->get_file_params();
|
||||
$file = $files['file'] ?? null;
|
||||
if (!is_array($file)) {
|
||||
return self::fail('请选择文件');
|
||||
}
|
||||
$front = \Jio\Core\FrontSubmit::get_instance();
|
||||
$result = $front->persist_uploaded_file($kind, $file);
|
||||
if (is_wp_error($result)) return $result;
|
||||
if (!$result instanceof \WP_REST_Response) return self::fail('上传失败', 500);
|
||||
$data = $result->get_data();
|
||||
return self::success(['id' => absint($data['id'] ?? 0), 'url' => esc_url_raw((string) ($data['url'] ?? '')), 'name' => sanitize_text_field((string) ($data['title'] ?? $file['name'] ?? ''))], '上传成功');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace Jio\Forum;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait RestPlates {
|
||||
public static function audit_plate(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
if (!self::plate($id) || !Access::is_admin(self::actor())) {
|
||||
return self::fail('无权审核版块', 403);
|
||||
}
|
||||
$status = sanitize_key((string) (self::input($request)['status'] ?? 'publish'));
|
||||
if (!in_array($status, ['publish', 'pending'], true)) {
|
||||
return self::fail('审核状态无效');
|
||||
}
|
||||
wp_update_post(['ID' => $id, 'post_status' => $status]);
|
||||
do_action('jio_forum_plate_reviewed', $id, $status, self::actor());
|
||||
return self::success(['status' => $status, 'url' => get_permalink($id)], $status === 'publish' ? '版块审核通过' : '版块待审核');
|
||||
}
|
||||
|
||||
public static function delete_plate(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
if (!self::plate($id) || !Access::can_manage_moderators(self::actor(), $id)) {
|
||||
return self::fail('无权删除版块', 403);
|
||||
}
|
||||
$input = self::input($request);
|
||||
$action = sanitize_key((string) ($input['posts_action'] ?? 'trash'));
|
||||
if (!in_array($action, ['trash', 'move'], true)) {
|
||||
return self::fail('帖子处理方式无效');
|
||||
}
|
||||
if ($action === 'move') {
|
||||
$target = absint($input['target_plate_id'] ?? 0);
|
||||
if ($target === $id || !self::plate($target) || get_post_status($target) !== 'publish') {
|
||||
return self::fail('目标版块无效');
|
||||
}
|
||||
$posts = get_posts(['post_type' => Core::POST, 'post_status' => ['publish', 'pending', 'draft'], 'meta_key' => '_jio_forum_plate_id', 'meta_value' => $id, 'posts_per_page' => -1, 'fields' => 'ids']);
|
||||
foreach ($posts as $post_id) update_post_meta($post_id, '_jio_forum_plate_id', $target);
|
||||
}
|
||||
wp_trash_post($id);
|
||||
return self::success(['url' => Core::home_url()], '版块已移入回收站');
|
||||
}
|
||||
|
||||
public static function restore_plate(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
if (!self::plate($id) || get_post_status($id) !== 'trash' || !Access::can_manage_moderators(self::actor(), $id)) {
|
||||
return self::fail('无权恢复版块', 403);
|
||||
}
|
||||
wp_untrash_post($id);
|
||||
return self::success(['url' => get_permalink($id)], '版块已恢复');
|
||||
}
|
||||
|
||||
private static function taxonomy(string $name): string {
|
||||
return match ($name) {
|
||||
'category' => Core::CATEGORY,
|
||||
'topic' => Core::TOPIC,
|
||||
'tag' => Core::TAG,
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private static function term_permission(string $taxonomy, int $term_id = 0): bool {
|
||||
if (Access::is_admin(self::actor())) {
|
||||
return true;
|
||||
}
|
||||
return $term_id === 0 && $taxonomy === Core::TOPIC && (bool) jio_get_setting('jio_forum_user_topics', true);
|
||||
}
|
||||
|
||||
public static function create_term(\WP_REST_Request $request) {
|
||||
$input = self::input($request);
|
||||
$taxonomy = self::taxonomy(sanitize_key((string) ($input['type'] ?? '')));
|
||||
if ($taxonomy === '' || !self::term_permission($taxonomy)) {
|
||||
return self::fail('无权创建分类', 403);
|
||||
}
|
||||
$name = sanitize_text_field((string) ($input['name'] ?? ''));
|
||||
if ($name === '' || mb_strlen($name) > 60) {
|
||||
return self::fail('名称无效');
|
||||
}
|
||||
$result = wp_insert_term($name, $taxonomy, [
|
||||
'description' => sanitize_textarea_field((string) ($input['description'] ?? '')),
|
||||
'parent' => $taxonomy === Core::CATEGORY ? absint($input['parent'] ?? 0) : 0,
|
||||
]);
|
||||
if (is_wp_error($result)) {
|
||||
return self::fail($result->get_error_message());
|
||||
}
|
||||
return self::success(['id' => (int) $result['term_id']], '已创建');
|
||||
}
|
||||
|
||||
public static function update_term(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
$term = get_term($id);
|
||||
if (!$term instanceof \WP_Term || !self::term_permission($term->taxonomy, $id)) {
|
||||
return self::fail('无权编辑分类', 403);
|
||||
}
|
||||
$input = self::input($request);
|
||||
$result = wp_update_term($id, $term->taxonomy, [
|
||||
'name' => sanitize_text_field((string) ($input['name'] ?? $term->name)),
|
||||
'description' => sanitize_textarea_field((string) ($input['description'] ?? $term->description)),
|
||||
]);
|
||||
return is_wp_error($result) ? self::fail($result->get_error_message()) : self::success(['id' => $id], '已更新');
|
||||
}
|
||||
|
||||
public static function delete_term(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
$term = get_term($id);
|
||||
if (!$term instanceof \WP_Term || !self::term_permission($term->taxonomy, $id)) {
|
||||
return self::fail('无权删除分类', 403);
|
||||
}
|
||||
$result = wp_delete_term($id, $term->taxonomy);
|
||||
return is_wp_error($result) || !$result ? self::fail('删除失败') : self::success([], '已删除');
|
||||
}
|
||||
|
||||
public static function moderator(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
if (!self::plate($id) || !Access::can_manage_moderators(self::actor(), $id)) {
|
||||
return self::fail('无权设置版主', 403);
|
||||
}
|
||||
$input = self::input($request);
|
||||
$user_id = absint($input['user_id'] ?? 0);
|
||||
$active = !empty($input['active']);
|
||||
if (!$user_id || !get_userdata($user_id)) {
|
||||
return self::fail('用户不存在');
|
||||
}
|
||||
$list = array_map('absint', (array) get_post_meta($id, '_jio_forum_moderators', true));
|
||||
$list = array_values(array_unique(array_diff($list, [$user_id])));
|
||||
if ($active) {
|
||||
$list[] = $user_id;
|
||||
}
|
||||
update_post_meta($id, '_jio_forum_moderators', $list);
|
||||
do_action('jio_forum_moderator_changed', $id, $user_id, $active);
|
||||
return self::success(['moderators' => $list], $active ? '已设为版主' : '已移除版主');
|
||||
}
|
||||
|
||||
public static function apply_moderator(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
$user = self::actor();
|
||||
if (!self::plate($id) || get_post_status($id) !== 'publish' || Access::is_moderator($user, $id) || !jio_get_setting('jio_forum_moderator_apply', true)) {
|
||||
return self::fail('无法申请此版块版主', 403);
|
||||
}
|
||||
$key = '_jio_forum_application_' . $id;
|
||||
$state = (string) get_user_meta($user, $key, true);
|
||||
if ($state === 'pending') {
|
||||
return self::success(['status' => 'pending'], '申请正在审核');
|
||||
}
|
||||
update_user_meta($user, $key, 'pending');
|
||||
update_user_meta($user, $key . '_reason', sanitize_textarea_field((string) (self::input($request)['reason'] ?? '')));
|
||||
do_action('jio_forum_moderator_applied', $id, $user);
|
||||
return self::success(['status' => 'pending'], '申请已提交');
|
||||
}
|
||||
|
||||
public static function process_application(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
$user_id = absint($request['user_id']);
|
||||
if (!self::plate($id) || !Access::can_manage_moderators(self::actor(), $id) || !get_userdata($user_id)) {
|
||||
return self::fail('无权处理申请', 403);
|
||||
}
|
||||
$key = '_jio_forum_application_' . $id;
|
||||
if (get_user_meta($user_id, $key, true) !== 'pending') {
|
||||
return self::fail('申请不存在');
|
||||
}
|
||||
$status = sanitize_key((string) (self::input($request)['status'] ?? ''));
|
||||
if (!in_array($status, ['approved', 'rejected'], true)) {
|
||||
return self::fail('处理结果无效');
|
||||
}
|
||||
update_user_meta($user_id, $key, $status);
|
||||
if ($status === 'approved') {
|
||||
$list = array_map('absint', (array) get_post_meta($id, '_jio_forum_moderators', true));
|
||||
$list[] = $user_id;
|
||||
update_post_meta($id, '_jio_forum_moderators', array_values(array_unique($list)));
|
||||
}
|
||||
do_action('jio_forum_moderator_application_processed', $id, $user_id, $status);
|
||||
return self::success(['status' => $status], '申请已处理');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
namespace Jio\Forum;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait RestPosts {
|
||||
public static function create_post(\WP_REST_Request $request) {
|
||||
return self::save_post($request, 0);
|
||||
}
|
||||
|
||||
public static function update_post(\WP_REST_Request $request) {
|
||||
return self::save_post($request, absint($request['id']));
|
||||
}
|
||||
|
||||
private static function save_post(\WP_REST_Request $request, int $id) {
|
||||
$user_id = self::actor();
|
||||
$current = $id > 0 ? self::post($id) : null;
|
||||
if ($id > 0 && (!$current || !Access::can_manage_post($user_id, $id))) {
|
||||
return self::fail('无权编辑此帖子', 403);
|
||||
}
|
||||
$input = self::input($request);
|
||||
$plate_id = absint($input['plate_id'] ?? ($current ? get_post_meta($id, '_jio_forum_plate_id', true) : 0));
|
||||
if (!self::plate($plate_id) || !Access::can_publish($user_id, $plate_id)) {
|
||||
return self::fail('没有在此版块发布的权限', 403);
|
||||
}
|
||||
if ($id === 0 && !Access::is_moderator($user_id, $plate_id)) {
|
||||
$limit = max(0, (int) jio_get_setting('jio_forum_daily_post_limit', 20));
|
||||
if ($limit > 0) {
|
||||
$recent = get_posts(['post_type' => Core::POST, 'post_status' => ['publish', 'pending'], 'author' => $user_id, 'date_query' => [['after' => 'today']], 'posts_per_page' => $limit, 'fields' => 'ids']);
|
||||
if (count($recent) >= $limit) {
|
||||
return self::fail('已达到今日发帖上限', 429);
|
||||
}
|
||||
}
|
||||
}
|
||||
$title = sanitize_text_field((string) ($input['title'] ?? ($current->post_title ?? '')));
|
||||
$content = wp_kses_post((string) ($input['content'] ?? ($current->post_content ?? '')));
|
||||
if (mb_strlen($title) < 2 || mb_strlen($title) > 120 || trim(wp_strip_all_tags($content)) === '') {
|
||||
return self::fail('请填写标题和正文');
|
||||
}
|
||||
$type = sanitize_key((string) ($input['type'] ?? ($current ? get_post_meta($id, '_jio_forum_type', true) : 'normal')));
|
||||
if (!in_array($type, ['normal', 'question', 'vote'], true)) {
|
||||
return self::fail('帖子类型无效');
|
||||
}
|
||||
if (($type === 'vote' && !jio_get_setting('jio_forum_vote_enabled', true)) || ($type === 'question' && !jio_get_setting('jio_forum_question_enabled', true))) {
|
||||
return self::fail('当前未启用此帖子类型');
|
||||
}
|
||||
$votes = [];
|
||||
$vote_type = 'single';
|
||||
$vote_title = '';
|
||||
$vote_days = 0;
|
||||
if ($type === 'vote') {
|
||||
$vote_type = sanitize_key((string) ($input['vote_type'] ?? ($current ? get_post_meta($id, '_jio_forum_vote_type', true) : 'single')));
|
||||
if ($vote_type === '') $vote_type = 'single';
|
||||
if (!in_array($vote_type, ['single', 'multiple', 'pk'], true)) return self::fail('投票方式无效');
|
||||
$vote_title = sanitize_text_field((string) ($input['vote_title'] ?? ($current ? get_post_meta($id, '_jio_forum_vote_title', true) : '')));
|
||||
if (mb_strlen($vote_title) > 60) return self::fail('投票标题过长');
|
||||
$vote_days = absint($input['vote_days'] ?? 0);
|
||||
if (!in_array($vote_days, [0, 1, 7, 30], true)) return self::fail('投票有效期无效');
|
||||
$raw = (string) ($input['vote_options'] ?? '');
|
||||
if ($raw === '' && $current) {
|
||||
$votes = (array) get_post_meta($id, '_jio_forum_vote_options', true);
|
||||
} else {
|
||||
$votes = array_values(array_unique(array_filter(array_map('sanitize_text_field', preg_split('/\R/', $raw)))));
|
||||
}
|
||||
$max_votes = max(2, min(20, (int) jio_get_setting('jio_forum_vote_max', 10)));
|
||||
if (count($votes) < 2 || count($votes) > $max_votes) {
|
||||
return self::fail('投票选项数量无效');
|
||||
}
|
||||
if ($vote_type === 'pk' && count($votes) !== 2) return self::fail('双选 PK 只能有两个选项');
|
||||
if ($current && get_post_meta($id, '_jio_forum_vote_total', true) && $votes !== (array) get_post_meta($id, '_jio_forum_vote_options', true)) {
|
||||
return self::fail('已有投票后不能更改选项');
|
||||
}
|
||||
if ($current && get_post_meta($id, '_jio_forum_vote_total', true) && $vote_type !== (string) get_post_meta($id, '_jio_forum_vote_type', true)) {
|
||||
return self::fail('已有投票后不能更改投票方式');
|
||||
}
|
||||
}
|
||||
$view = sanitize_key((string) ($input['view_limit'] ?? ($current ? get_post_meta($id, '_jio_forum_view_limit', true) : 'all')));
|
||||
if (!in_array($view, ['all', 'login', 'follow', 'points', 'pay'], true)) {
|
||||
return self::fail('访问权限无效');
|
||||
}
|
||||
if (($view === 'points' && !jio_get_setting('jio_forum_points_enabled', true)) || ($view === 'pay' && !jio_get_setting('jio_forum_paid_content', true))) {
|
||||
return self::fail('当前未启用此解锁方式');
|
||||
}
|
||||
$price = (string) ($input['price'] ?? ($current ? get_post_meta($id, '_jio_forum_price', true) : '0'));
|
||||
if (($view === 'points' && (!ctype_digit($price) || (int) $price <= 0)) || ($view === 'pay' && !preg_match('/^(0|[1-9]\d*)(?:\.\d{1,2})?$/', $price))) {
|
||||
return self::fail('解锁价格无效');
|
||||
}
|
||||
if (in_array($view, ['points', 'pay'], true)) {
|
||||
$valid_price = self::validate_price($price, $view);
|
||||
if (is_wp_error($valid_price)) return $valid_price;
|
||||
foreach (['vip1', 'vip2'] as $vip) {
|
||||
if (!isset($input['price_' . $vip]) || $input['price_' . $vip] === '') continue;
|
||||
$valid_price = self::validate_price((string) $input['price_' . $vip], $view);
|
||||
if (is_wp_error($valid_price)) return $valid_price;
|
||||
}
|
||||
}
|
||||
if (isset($input['topics']) && !jio_get_setting('jio_forum_user_topics', true) && !Access::is_admin($user_id)) {
|
||||
foreach (array_filter(array_map('sanitize_text_field', explode(',', (string) $input['topics']))) as $name) {
|
||||
if (!term_exists($name, Core::TOPIC)) return self::fail('无权创建新话题', 403);
|
||||
}
|
||||
}
|
||||
if ($current && (int) get_post_meta($id, '_jio_forum_vote_total', true) > 0 && $type !== 'vote') {
|
||||
return self::fail('已有投票后不能更改帖子类型');
|
||||
}
|
||||
if ($current && get_post_meta($id, '_jio_forum_adopted_comment', true) && $type !== 'question') {
|
||||
return self::fail('已有采纳回复后不能更改帖子类型');
|
||||
}
|
||||
$status = Access::is_moderator($user_id, $plate_id) || !jio_get_setting('jio_forum_post_review', true) ? 'publish' : 'pending';
|
||||
$post_data = [
|
||||
'ID' => $id, 'post_type' => Core::POST, 'post_title' => $title, 'post_content' => $content,
|
||||
'post_status' => $status, 'post_author' => $current ? (int) $current->post_author : $user_id,
|
||||
'comment_status' => $current ? $current->comment_status : 'open', 'ping_status' => 'closed',
|
||||
];
|
||||
$result = $id ? wp_update_post($post_data, true) : wp_insert_post($post_data, true);
|
||||
if (is_wp_error($result) || !$result) {
|
||||
return self::fail('保存失败', 500);
|
||||
}
|
||||
$id = (int) $result;
|
||||
if (class_exists('\\Jio\\Attachment\\PendingUploads')) {
|
||||
\Jio\Attachment\PendingUploads::claim_text($content, $user_id);
|
||||
}
|
||||
update_post_meta($id, '_jio_forum_plate_id', $plate_id);
|
||||
update_post_meta($id, '_jio_forum_type', $type);
|
||||
update_post_meta($id, '_jio_forum_view_limit', $view);
|
||||
update_post_meta($id, '_jio_forum_price', $price);
|
||||
foreach (['vip1', 'vip2'] as $vip) {
|
||||
if (!isset($input['price_' . $vip])) continue;
|
||||
$vip_price = (string) $input['price_' . $vip];
|
||||
if ($vip_price === '' || preg_match($view === 'points' ? '/^\d+$/' : '/^(0|[1-9]\d*)(?:\.\d{1,2})?$/', $vip_price)) {
|
||||
update_post_meta($id, '_jio_forum_price_' . $vip, $vip_price);
|
||||
}
|
||||
}
|
||||
if ($type === 'vote') {
|
||||
update_post_meta($id, '_jio_forum_vote_options', $votes);
|
||||
update_post_meta($id, '_jio_forum_vote_type', $vote_type);
|
||||
update_post_meta($id, '_jio_forum_vote_title', $vote_title);
|
||||
if (!$current || !get_post_meta($id, '_jio_forum_vote_started', true)) {
|
||||
$started = current_time('timestamp');
|
||||
update_post_meta($id, '_jio_forum_vote_started', $started);
|
||||
update_post_meta($id, '_jio_forum_vote_deadline', $vote_days ? $started + $vote_days * DAY_IN_SECONDS : 0);
|
||||
}
|
||||
}
|
||||
foreach ([Core::TOPIC => 'topics', Core::TAG => 'tags'] as $taxonomy => $key) {
|
||||
if (isset($input[$key])) {
|
||||
$names = array_values(array_filter(array_map('sanitize_text_field', explode(',', (string) $input[$key]))));
|
||||
wp_set_object_terms($id, $names, $taxonomy);
|
||||
}
|
||||
}
|
||||
do_action($current ? 'jio_forum_post_updated' : 'jio_forum_post_created', $id, $user_id, $status);
|
||||
return self::success(['id' => $id, 'url' => $status === 'pending' ? Core::edit_url($id) : get_permalink($id), 'status' => $status], $status === 'pending' ? '已提交审核' : '帖子已发布');
|
||||
}
|
||||
|
||||
public static function create_plate(\WP_REST_Request $request) {
|
||||
return self::save_plate($request, 0);
|
||||
}
|
||||
|
||||
public static function update_plate(\WP_REST_Request $request) {
|
||||
return self::save_plate($request, absint($request['id']));
|
||||
}
|
||||
|
||||
private static function save_plate(\WP_REST_Request $request, int $id) {
|
||||
$user_id = self::actor();
|
||||
$current = $id > 0 ? self::plate($id) : null;
|
||||
if ($id > 0 && (!$current || !Access::is_moderator($user_id, $id))) {
|
||||
return self::fail('无权编辑版块', 403);
|
||||
}
|
||||
if ($id === 0 && !Access::is_admin($user_id) && !jio_get_setting('jio_forum_user_plates', true)) {
|
||||
return self::fail('当前不允许创建版块', 403);
|
||||
}
|
||||
$input = self::input($request);
|
||||
$title = sanitize_text_field((string) ($input['title'] ?? ($current->post_title ?? '')));
|
||||
if (mb_strlen($title) < 2 || mb_strlen($title) > 80) {
|
||||
return self::fail('版块名称需为 2 至 80 个字符');
|
||||
}
|
||||
$follow_mode = sanitize_key((string) ($input['follow_mode'] ?? ($current ? get_post_meta($id, '_jio_forum_follow_mode', true) : 'all')));
|
||||
$view_limit = sanitize_key((string) ($input['view_limit'] ?? ($current ? get_post_meta($id, '_jio_forum_view_limit', true) : 'all')));
|
||||
if (!in_array($follow_mode, ['all', 'points', 'pay'], true) || !in_array($view_limit, ['all', 'login', 'follow', 'points', 'pay'], true)) {
|
||||
return self::fail('版块权限无效');
|
||||
}
|
||||
if (in_array($follow_mode, ['points', 'pay'], true) && !jio_get_setting('jio_forum_paid_follow', true)) {
|
||||
return self::fail('付费关注未启用');
|
||||
}
|
||||
if (($follow_mode === 'points' || $view_limit === 'points') && !jio_get_setting('jio_forum_points_enabled', true)) {
|
||||
return self::fail('积分解锁未启用');
|
||||
}
|
||||
$follow_price = (string) ($input['follow_price'] ?? ($current ? get_post_meta($id, '_jio_forum_follow_price', true) : '0'));
|
||||
if ($follow_price === '') $follow_price = '0';
|
||||
if (!preg_match($follow_mode === 'points' ? '/^\d+$/' : '/^(0|[1-9]\d*)(?:\.\d{1,2})?$/', $follow_price)) {
|
||||
return self::fail('关注价格无效');
|
||||
}
|
||||
if (in_array($follow_mode, ['points', 'pay'], true)) {
|
||||
$valid_price = self::validate_price($follow_price, $follow_mode);
|
||||
if (is_wp_error($valid_price)) return $valid_price;
|
||||
foreach (['vip1', 'vip2'] as $vip) {
|
||||
if (!isset($input['follow_price_' . $vip]) || $input['follow_price_' . $vip] === '') continue;
|
||||
$valid_price = self::validate_price((string) $input['follow_price_' . $vip], $follow_mode);
|
||||
if (is_wp_error($valid_price)) return $valid_price;
|
||||
}
|
||||
}
|
||||
$status = Access::is_admin($user_id) || !jio_get_setting('jio_forum_plate_review', true) ? 'publish' : 'pending';
|
||||
$result = $id ? wp_update_post([
|
||||
'ID' => $id, 'post_title' => $title,
|
||||
'post_content' => wp_kses_post((string) ($input['content'] ?? $current->post_content)),
|
||||
], true) : wp_insert_post([
|
||||
'post_type' => Core::PLATE, 'post_title' => $title, 'post_status' => $status,
|
||||
'post_author' => $user_id, 'post_content' => wp_kses_post((string) ($input['content'] ?? '')),
|
||||
], true);
|
||||
if (is_wp_error($result) || !$result) {
|
||||
return self::fail('版块保存失败', 500);
|
||||
}
|
||||
$id = (int) $result;
|
||||
if (isset($input['category_id'])) {
|
||||
$term_id = absint($input['category_id']);
|
||||
if ($term_id && term_exists($term_id, Core::CATEGORY)) {
|
||||
wp_set_object_terms($id, [$term_id], Core::CATEGORY);
|
||||
}
|
||||
}
|
||||
if (Access::is_moderator($user_id, $id) && isset($input['publish_limit'])) {
|
||||
$limit = sanitize_key((string) $input['publish_limit']);
|
||||
if (in_array($limit, ['all', 'follow'], true)) {
|
||||
update_post_meta($id, '_jio_forum_publish_limit', $limit);
|
||||
}
|
||||
}
|
||||
update_post_meta($id, '_jio_forum_follow_mode', $follow_mode);
|
||||
update_post_meta($id, '_jio_forum_follow_price', $follow_price);
|
||||
update_post_meta($id, '_jio_forum_view_limit', $view_limit);
|
||||
foreach (['vip1', 'vip2'] as $vip) {
|
||||
if (!isset($input['follow_price_' . $vip])) continue;
|
||||
$vip_price = (string) $input['follow_price_' . $vip];
|
||||
if ($vip_price === '' || preg_match($follow_mode === 'points' ? '/^\d+$/' : '/^(0|[1-9]\d*)(?:\.\d{1,2})?$/', $vip_price)) {
|
||||
update_post_meta($id, '_jio_forum_follow_price_' . $vip, $vip_price);
|
||||
}
|
||||
}
|
||||
return self::success(['id' => $id, 'url' => get_post_status($id) === 'publish' ? get_permalink($id) : Core::home_url(), 'status' => get_post_status($id)], get_post_status($id) === 'pending' ? '版块已提交审核' : '版块已保存');
|
||||
}
|
||||
|
||||
private static function validate_price(string $value, string $mode) {
|
||||
if ($mode === 'points') {
|
||||
$max = (string) PHP_INT_MAX;
|
||||
return preg_match('/^(0|[1-9]\d*)$/', $value) && (strlen($value) < strlen($max) || strlen($value) === strlen($max) && strcmp($value, $max) <= 0)
|
||||
? true : self::fail('积分价格超出范围');
|
||||
}
|
||||
try {
|
||||
\Jio\Ledger\AssetAmount::balance($value);
|
||||
return true;
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
return self::fail('付费价格超出范围');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace Jio\Forum;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait RestReplies {
|
||||
private static bool $posting_reply = false;
|
||||
|
||||
public static function inside_reply(): bool {
|
||||
return self::$posting_reply;
|
||||
}
|
||||
|
||||
public static function reply(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
$post = self::post($id);
|
||||
if (!$post || $post->post_status !== 'publish' || $post->comment_status !== 'open' || !Access::can_view(self::actor(), $id)) {
|
||||
return self::fail('当前无法回复', 403);
|
||||
}
|
||||
$input = self::input($request);
|
||||
$content = trim(wp_kses_post((string) ($input['content'] ?? '')));
|
||||
if (mb_strlen(wp_strip_all_tags($content)) < 2 || mb_strlen($content) > 10000) {
|
||||
return self::fail('回复长度需为 2 至 10000 个字符');
|
||||
}
|
||||
$parent = absint($input['parent_id'] ?? 0);
|
||||
if ($parent) {
|
||||
$parent_comment = get_comment($parent);
|
||||
if (!$parent_comment instanceof \WP_Comment || (int) $parent_comment->comment_post_ID !== $id || $parent_comment->comment_approved !== '1') {
|
||||
return self::fail('被回复的内容不存在');
|
||||
}
|
||||
}
|
||||
$user = wp_get_current_user();
|
||||
self::$posting_reply = true;
|
||||
try {
|
||||
$result = wp_new_comment([
|
||||
'comment_post_ID' => $id,
|
||||
'comment_content' => $content,
|
||||
'comment_parent' => $parent,
|
||||
'user_id' => (int) $user->ID,
|
||||
'comment_author' => $user->display_name,
|
||||
'comment_author_email' => $user->user_email,
|
||||
'comment_author_url' => $user->user_url,
|
||||
], true);
|
||||
} finally {
|
||||
self::$posting_reply = false;
|
||||
}
|
||||
if (is_wp_error($result) || !$result) {
|
||||
return self::fail(is_wp_error($result) ? $result->get_error_message() : '回复失败');
|
||||
}
|
||||
$comment = get_comment((int) $result);
|
||||
if (class_exists('\\Jio\\Attachment\\PendingUploads')) {
|
||||
\Jio\Attachment\PendingUploads::claim_text($content, (int) $user->ID);
|
||||
}
|
||||
return self::success(['id' => (int) $result, 'status' => $comment->comment_approved === '1' ? 'publish' : 'pending'], $comment->comment_approved === '1' ? '回复成功' : '回复已提交审核');
|
||||
}
|
||||
|
||||
public static function like_reply(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
$comment = get_comment($id);
|
||||
if (!$comment instanceof \WP_Comment || get_post_type((int) $comment->comment_post_ID) !== Core::POST || $comment->comment_approved !== '1') {
|
||||
return self::fail('回复不存在', 404);
|
||||
}
|
||||
$key = '_jio_forum_reply_like_' . $id;
|
||||
$user = self::actor();
|
||||
return self::with_lock('reply-like', $id, static function () use ($id, $user, $key) {
|
||||
$active = !get_user_meta($user, $key, true);
|
||||
if ($active) add_user_meta($user, $key, 1, true);
|
||||
else delete_user_meta($user, $key);
|
||||
$count = max(0, (int) get_comment_meta($id, 'jio_comment_likes', true) + ($active ? 1 : -1));
|
||||
update_comment_meta($id, 'jio_comment_likes', $count);
|
||||
do_action('jio_comment_liked', $id, $user, $active);
|
||||
return self::success(['active' => $active, 'count' => $count, 'label' => '赞 ' . $count], $active ? '已点赞' : '已取消点赞');
|
||||
});
|
||||
}
|
||||
|
||||
public static function hot_reply(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
$comment = get_comment($id);
|
||||
$post_id = $comment instanceof \WP_Comment ? (int) $comment->comment_post_ID : 0;
|
||||
$plate = (int) get_post_meta($post_id, '_jio_forum_plate_id', true);
|
||||
if (!$comment instanceof \WP_Comment || get_post_type($post_id) !== Core::POST || !Access::is_moderator(self::actor(), $plate)) {
|
||||
return self::fail('无权设置热门回复', 403);
|
||||
}
|
||||
$active = !get_comment_meta($id, '_jio_forum_hot', true);
|
||||
update_comment_meta($id, '_jio_forum_hot', $active ? 1 : 0);
|
||||
return self::success(['active' => $active, 'label' => $active ? '取消热门' : '设为热门'], $active ? '已设为热门' : '已取消热门');
|
||||
}
|
||||
|
||||
private static function can_moderate_reply(int $id): bool {
|
||||
$comment = get_comment($id);
|
||||
if (!$comment instanceof \WP_Comment || get_post_type((int) $comment->comment_post_ID) !== Core::POST) return false;
|
||||
$plate = (int) get_post_meta((int) $comment->comment_post_ID, '_jio_forum_plate_id', true);
|
||||
return Access::is_moderator(self::actor(), $plate);
|
||||
}
|
||||
|
||||
public static function audit_reply(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
if (!self::can_moderate_reply($id)) return self::fail('无权审核回复', 403);
|
||||
$approved = sanitize_key((string) (self::input($request)['status'] ?? 'approve'));
|
||||
if (!in_array($approved, ['approve', 'hold'], true)) return self::fail('审核状态无效');
|
||||
if (!wp_set_comment_status($id, $approved)) return self::fail('回复审核失败', 500);
|
||||
if ($approved === 'hold') self::clear_adoption($id);
|
||||
return self::success(['status' => $approved], $approved === 'approve' ? '回复已通过' : '回复已隐藏');
|
||||
}
|
||||
|
||||
public static function delete_reply(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
$comment = get_comment($id);
|
||||
if (!$comment instanceof \WP_Comment || get_post_type((int) $comment->comment_post_ID) !== Core::POST || ((int) $comment->user_id !== self::actor() && !self::can_moderate_reply($id))) return self::fail('无权删除回复', 403);
|
||||
if (!wp_trash_comment($id)) return self::fail('删除失败', 500);
|
||||
self::clear_adoption($id);
|
||||
return self::success([], '回复已删除');
|
||||
}
|
||||
|
||||
public static function restore_reply(\WP_REST_Request $request) {
|
||||
$id = absint($request['id']);
|
||||
$comment = get_comment($id);
|
||||
if (!$comment instanceof \WP_Comment || get_post_type((int) $comment->comment_post_ID) !== Core::POST || $comment->comment_approved !== 'trash' || ((int) $comment->user_id !== self::actor() && !self::can_moderate_reply($id))) return self::fail('无权恢复回复', 403);
|
||||
if (!wp_untrash_comment($id)) return self::fail('恢复失败', 500);
|
||||
return self::success([], '回复已恢复');
|
||||
}
|
||||
|
||||
private static function clear_adoption(int $comment_id): void {
|
||||
$comment = get_comment($comment_id);
|
||||
if (!$comment instanceof \WP_Comment) return;
|
||||
$post_id = (int) $comment->comment_post_ID;
|
||||
if ((int) get_post_meta($post_id, '_jio_forum_adopted_comment', true) === $comment_id) {
|
||||
delete_post_meta($post_id, '_jio_forum_adopted_comment');
|
||||
delete_comment_meta($comment_id, '_jio_forum_adopted');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
<?php
|
||||
|
||||
namespace Jio\Forum;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class View {
|
||||
public static function open(string $title, string $context): void {
|
||||
get_header();
|
||||
echo '<main id="forum" class="jio-forum jio-forum--' . esc_attr($context) . '"><div class="jio-forum__wrap">';
|
||||
echo '<nav class="jio-forum__breadcrumbs" aria-label="路径"><a href="' . esc_url(home_url('/')) . '">首页</a><span>/</span><a href="' . esc_url(Core::home_url()) . '">论坛</a>';
|
||||
if ($context !== 'home') {
|
||||
echo '<span>/</span><span>' . esc_html($title) . '</span>';
|
||||
}
|
||||
echo '</nav>';
|
||||
}
|
||||
|
||||
public static function close(): void {
|
||||
echo '</div></main>';
|
||||
get_footer();
|
||||
}
|
||||
|
||||
public static function sidebar(string $context): void {
|
||||
echo '<aside class="jio-forum__aside">';
|
||||
if (is_user_logged_in()) {
|
||||
echo '<a class="jio-forum__new" href="' . esc_url(Core::edit_url()) . '">发布帖子</a>';
|
||||
if ($context === 'home' && jio_get_setting('jio_forum_user_plates', true)) {
|
||||
echo '<button type="button" class="jio-forum__action jio-forum__create-plate" data-forum-open="plate">创建版块</button>';
|
||||
echo '<dialog class="jio-forum__dialog" data-forum-dialog="plate"><form data-forum-form="plate"><h2>创建版块</h2><label>名称<input name="title" required maxlength="80"></label><label>介绍<textarea name="content" rows="5"></textarea></label><p class="jio-forum__message" role="status"></p><div class="jio-forum__dialog-actions"><button type="button" data-forum-close>取消</button><button type="submit" class="jio-forum__primary">提交</button></div></form></dialog>';
|
||||
}
|
||||
} else {
|
||||
echo '<a class="jio-forum__new" href="' . esc_url(jio_get_login_url(Core::edit_url())) . '">登录后发帖</a>';
|
||||
}
|
||||
\Jio\WidgetSystem\WidgetAreas::render('forum_' . $context . '_sidebar');
|
||||
$plates = get_posts(['post_type' => Core::PLATE, 'post_status' => 'publish', 'posts_per_page' => 8, 'orderby' => 'date']);
|
||||
if ($plates) {
|
||||
echo '<section class="jio-forum__aside-section"><h2>热门版块</h2>';
|
||||
foreach ($plates as $plate) {
|
||||
echo '<a href="' . esc_url(get_permalink($plate)) . '">' . esc_html($plate->post_title) . '</a>';
|
||||
}
|
||||
echo '</section>';
|
||||
}
|
||||
echo '</aside>';
|
||||
}
|
||||
|
||||
public static function home(): void {
|
||||
self::open('论坛', 'home');
|
||||
\Jio\WidgetSystem\WidgetAreas::render('forum_home_top_fluid');
|
||||
echo '<div class="jio-forum__layout"><div class="jio-forum__main">';
|
||||
$name = (string) jio_get_setting('jio_forum_forum_name', '论坛');
|
||||
echo '<header class="jio-forum__hero"><h1>' . esc_html($name) . '</h1></header>';
|
||||
if (Access::is_admin(get_current_user_id())) {
|
||||
$pending = get_posts(['post_type' => Core::PLATE, 'post_status' => 'pending', 'posts_per_page' => 20]);
|
||||
if ($pending) {
|
||||
echo '<section class="jio-forum__pending"><h2>待审核版块</h2>';
|
||||
foreach ($pending as $plate) {
|
||||
echo '<div><span>' . esc_html($plate->post_title) . '</span><button type="button" data-forum-action="plate-audit" data-id="' . (int) $plate->ID . '">通过审核</button></div>';
|
||||
}
|
||||
echo '</section>';
|
||||
}
|
||||
self::term_management();
|
||||
}
|
||||
self::tabs();
|
||||
$categories = get_terms(['taxonomy' => Core::CATEGORY, 'hide_empty' => false]);
|
||||
if (is_array($categories) && $categories) {
|
||||
echo '<div class="jio-forum__chips">';
|
||||
foreach ($categories as $category) {
|
||||
echo '<a href="' . esc_url(get_term_link($category)) . '">' . esc_html($category->name) . '</a>';
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
self::posts(['post_type' => Core::POST, 'post_status' => 'publish'], 'home');
|
||||
echo '</div>';
|
||||
self::sidebar('home');
|
||||
echo '</div>';
|
||||
\Jio\WidgetSystem\WidgetAreas::render('forum_home_bottom_fluid');
|
||||
self::close();
|
||||
}
|
||||
|
||||
public static function listing(): void {
|
||||
$obj = get_queried_object();
|
||||
$plate = $obj instanceof \WP_Post && $obj->post_type === Core::PLATE ? $obj : null;
|
||||
$term = $obj instanceof \WP_Term ? $obj : null;
|
||||
$title = $plate ? $plate->post_title : ($term ? $term->name : '论坛');
|
||||
$context = $plate ? 'plate' : 'term';
|
||||
self::open($title, $context);
|
||||
\Jio\WidgetSystem\WidgetAreas::render('forum_' . $context . '_top_fluid');
|
||||
echo '<div class="jio-forum__layout"><div class="jio-forum__main">';
|
||||
echo '<header class="jio-forum__page-head"><h1>' . esc_html($title) . '</h1>';
|
||||
if ($plate) {
|
||||
echo '<div class="jio-forum__description">' . wp_kses_post(wpautop($plate->post_excerpt ?: $plate->post_content)) . '</div>';
|
||||
echo '<p class="jio-forum__stats">' . (int) get_post_meta($plate->ID, '_jio_forum_follow_count', true) . ' 人关注</p>';
|
||||
self::action('follow', $plate->ID, Access::follows(get_current_user_id(), $plate->ID) ? '已关注' : '关注版块');
|
||||
if (Access::is_moderator(get_current_user_id(), $plate->ID)) {
|
||||
self::plate_editor($plate);
|
||||
}
|
||||
if (is_user_logged_in() && !Access::is_moderator(get_current_user_id(), $plate->ID) && jio_get_setting('jio_forum_moderator_apply', true)) {
|
||||
self::action('apply', $plate->ID, '申请版主');
|
||||
}
|
||||
if (Access::is_admin(get_current_user_id()) || (int) $plate->post_author === get_current_user_id()) {
|
||||
self::applications($plate->ID);
|
||||
}
|
||||
} elseif ($term) {
|
||||
echo '<p>' . esc_html($term->description) . '</p>';
|
||||
}
|
||||
echo '</header>';
|
||||
self::tabs();
|
||||
if ($term && $term->taxonomy === Core::CATEGORY) {
|
||||
self::plates(['tax_query' => [['taxonomy' => Core::CATEGORY, 'terms' => [$term->term_id]]]]);
|
||||
} else {
|
||||
$args = ['post_type' => Core::POST, 'post_status' => 'publish'];
|
||||
if ($plate) {
|
||||
$args['meta_query'] = [['key' => '_jio_forum_plate_id', 'value' => $plate->ID]];
|
||||
} elseif ($term) {
|
||||
$args['tax_query'] = [['taxonomy' => $term->taxonomy, 'terms' => [$term->term_id]]];
|
||||
}
|
||||
self::posts($args, $context);
|
||||
if ($plate && Access::is_moderator(get_current_user_id(), $plate->ID)) {
|
||||
echo '<h2 class="jio-forum__section-title">待审核帖子</h2>';
|
||||
$args['post_status'] = 'pending';
|
||||
self::posts($args, $context);
|
||||
}
|
||||
}
|
||||
echo '</div>';
|
||||
self::sidebar($context);
|
||||
echo '</div>';
|
||||
\Jio\WidgetSystem\WidgetAreas::render('forum_' . $context . '_bottom_fluid');
|
||||
self::close();
|
||||
}
|
||||
|
||||
public static function tabs(): void {
|
||||
$tabs = ['latest' => '最新', 'popular' => '热门', 'essence' => '精华', 'question' => '问答', 'vote' => '投票', 'plates' => '版块', 'follow' => '已关注'];
|
||||
$enabled = (array) jio_get_setting('jio_forum_home_tabs', array_keys($tabs));
|
||||
$base_url = is_singular(Core::PLATE) ? get_permalink(get_queried_object_id()) : (is_tax([Core::CATEGORY, Core::TOPIC, Core::TAG]) ? get_term_link(get_queried_object()) : Core::home_url());
|
||||
echo '<nav class="jio-forum__tabs" aria-label="论坛栏目">';
|
||||
foreach ($tabs as $key => $label) {
|
||||
if (!in_array($key, $enabled, true)) continue;
|
||||
if ($key === 'follow' && !is_user_logged_in()) continue;
|
||||
$url = add_query_arg('forum_view', $key, $base_url);
|
||||
$selected = (string) ($_GET['forum_view'] ?? 'latest') === $key;
|
||||
echo '<a class="' . ($selected ? 'is-active' : '') . '" href="' . esc_url($url) . '">' . esc_html($label) . '</a>';
|
||||
}
|
||||
echo '</nav>';
|
||||
}
|
||||
|
||||
public static function posts(array $args, string $context = 'home'): void {
|
||||
$view = sanitize_key((string) ($_GET['forum_view'] ?? 'latest'));
|
||||
if ($view === 'plates') {
|
||||
self::plates([]);
|
||||
return;
|
||||
}
|
||||
if ($view === 'follow') {
|
||||
$ids = self::user_marked_ids(get_current_user_id(), '_jio_forum_follow_');
|
||||
$args['meta_query'][] = ['key' => '_jio_forum_plate_id', 'value' => $ids ?: [0], 'compare' => 'IN'];
|
||||
}
|
||||
if (in_array($view, ['question', 'vote'], true)) {
|
||||
$args['meta_query'][] = ['key' => '_jio_forum_type', 'value' => $view];
|
||||
}
|
||||
if ($view === 'essence') {
|
||||
$args['meta_query'][] = ['key' => '_jio_forum_essence', 'value' => 1];
|
||||
}
|
||||
$args['posts_per_page'] = max(1, min(50, (int) jio_get_setting('jio_forum_page_size', 20)));
|
||||
$args['paged'] = max(1, (int) get_query_var('paged', 1));
|
||||
$args['orderby'] = 'date';
|
||||
$args['order'] = 'DESC';
|
||||
$args['jio_forum_list'] = in_array($view, ['latest', 'popular', 'question', 'vote', 'essence', 'follow'], true) ? $view : 'latest';
|
||||
$query = new \WP_Query($args);
|
||||
\Jio\WidgetSystem\WidgetAreas::render('forum_' . $context . '_top_content');
|
||||
echo '<div class="jio-forum__list">';
|
||||
if (!$query->have_posts()) {
|
||||
echo '<p class="jio-forum__empty">暂无帖子</p>';
|
||||
}
|
||||
while ($query->have_posts()) {
|
||||
$query->the_post();
|
||||
self::post_card(get_post());
|
||||
}
|
||||
wp_reset_postdata();
|
||||
echo '</div>';
|
||||
self::pagination($query);
|
||||
\Jio\WidgetSystem\WidgetAreas::render('forum_' . $context . '_bottom_content');
|
||||
}
|
||||
|
||||
public static function plates(array $args): void {
|
||||
$query = new \WP_Query(array_merge(['post_type' => Core::PLATE, 'post_status' => 'publish', 'posts_per_page' => 20], $args));
|
||||
echo '<div class="jio-forum__plates">';
|
||||
while ($query->have_posts()) {
|
||||
$query->the_post();
|
||||
$plate = get_post();
|
||||
self::plate_card($plate);
|
||||
}
|
||||
wp_reset_postdata();
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
public static function plate_card(\WP_Post $plate): void {
|
||||
$url = get_permalink($plate);
|
||||
echo '<article class="jio-forum__plate"><a class="jio-forum__plate-cover" href="' . esc_url($url) . '">';
|
||||
if (jio_get_setting('jio_forum_show_plate_cover', true) && has_post_thumbnail($plate)) {
|
||||
echo get_the_post_thumbnail($plate, 'jio-card');
|
||||
}
|
||||
echo '</a><div><h2><a href="' . esc_url($url) . '">' . esc_html($plate->post_title) . '</a></h2><p>' . esc_html(wp_trim_words(wp_strip_all_tags($plate->post_excerpt ?: $plate->post_content), 22)) . '</p><small>' . (int) get_post_meta($plate->ID, '_jio_forum_follow_count', true) . ' 人关注</small></div></article>';
|
||||
}
|
||||
|
||||
public static function post_card(\WP_Post $post): void {
|
||||
$plate_id = (int) get_post_meta($post->ID, '_jio_forum_plate_id', true);
|
||||
$type = (string) get_post_meta($post->ID, '_jio_forum_type', true);
|
||||
echo '<article class="jio-forum__post"><a class="jio-forum__avatar" href="' . esc_url(get_author_posts_url((int) $post->post_author)) . '">' . get_avatar((int) $post->post_author, 44) . '</a><div class="jio-forum__post-body">';
|
||||
echo '<div class="jio-forum__post-top"><span>' . esc_html(get_the_author_meta('display_name', (int) $post->post_author)) . '</span><time datetime="' . esc_attr(get_the_date(DATE_W3C, $post)) . '">' . esc_html(get_the_date('', $post)) . '</time></div>';
|
||||
$url = $post->post_status === 'publish' ? get_permalink($post) : Core::edit_url($post->ID);
|
||||
echo '<h2><a href="' . esc_url($url) . '">' . esc_html($post->post_title) . '</a></h2>';
|
||||
$excerpt = Access::can_view(get_current_user_id(), $post->ID) ? wp_trim_words(wp_strip_all_tags($post->post_excerpt ?: $post->post_content), 30) : '内容已隐藏';
|
||||
echo '<p>' . esc_html($excerpt) . '</p>';
|
||||
echo '<div class="jio-forum__post-meta">';
|
||||
if (get_post_meta($post->ID, '_jio_forum_pinned', true)) {
|
||||
echo '<span class="jio-forum__badge">置顶</span>';
|
||||
}
|
||||
if ($type !== '') {
|
||||
echo '<span class="jio-forum__badge">' . esc_html($type === 'question' ? '问答' : '投票') . '</span>';
|
||||
}
|
||||
if ($type === 'question' && get_post_meta($post->ID, '_jio_forum_adopted_comment', true)) {
|
||||
echo '<span class="jio-forum__badge">已解决</span>';
|
||||
}
|
||||
if (get_post_meta($post->ID, '_jio_forum_essence', true)) {
|
||||
echo '<span class="jio-forum__badge">精华</span>';
|
||||
}
|
||||
if ($plate_id > 0) {
|
||||
echo '<a href="' . esc_url(get_permalink($plate_id)) . '">' . esc_html(get_the_title($plate_id)) . '</a>';
|
||||
}
|
||||
echo '<span>' . (int) get_post_meta($post->ID, '_jio_forum_views', true) . ' 阅读</span>';
|
||||
echo '<span>' . (int) $post->comment_count . ' 回复</span>';
|
||||
echo '<span>' . (int) get_post_meta($post->ID, '_jio_forum_score', true) . ' 评分</span>';
|
||||
echo '<span>' . (int) get_post_meta($post->ID, '_jio_forum_favorite_count', true) . ' 收藏</span></div></div></article>';
|
||||
$hot = get_comments(['post_id' => $post->ID, 'status' => 'approve', 'number' => 1, 'meta_key' => '_jio_forum_hot', 'meta_value' => '1']);
|
||||
if ($hot) {
|
||||
echo '<div class="jio-forum__hot-reply">热门回复:' . esc_html(wp_trim_words(wp_strip_all_tags($hot[0]->comment_content), 25)) . '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
public static function replies(int $post_id): void {
|
||||
$page = max(1, (int) ($_GET['cpage'] ?? 1));
|
||||
$limit = max(1, min(100, (int) jio_get_setting('jio_forum_reply_page_size', 20)));
|
||||
$comments = get_comments(['post_id' => $post_id, 'status' => 'approve', 'number' => $limit, 'offset' => ($page - 1) * $limit, 'orderby' => 'comment_date_gmt', 'order' => 'ASC']);
|
||||
$total = (int) get_comments(['post_id' => $post_id, 'status' => 'approve', 'count' => true]);
|
||||
echo '<section class="jio-forum__replies" id="comments"><h2>回复 <small>' . $total . '</small></h2>';
|
||||
if (!$comments) echo '<p class="jio-forum__empty">暂无回复</p>';
|
||||
foreach ($comments as $comment) {
|
||||
$adopted = (bool) get_comment_meta($comment->comment_ID, '_jio_forum_adopted', true);
|
||||
$hot = (bool) get_comment_meta($comment->comment_ID, '_jio_forum_hot', true);
|
||||
echo '<article class="jio-forum__reply" id="comment-' . (int) $comment->comment_ID . '"><div class="jio-forum__reply-avatar">' . get_avatar((int) $comment->user_id, 40) . '</div><div class="jio-forum__reply-main"><div class="jio-forum__reply-head"><b>' . esc_html(get_comment_author($comment)) . '</b><time>' . esc_html(get_comment_date('', $comment)) . '</time>' . ($adopted ? '<strong>已采纳</strong>' : '') . '</div>';
|
||||
if ($hot) echo '<span class="jio-forum__badge">热门回复</span>';
|
||||
if ((int) $comment->comment_parent > 0) {
|
||||
$parent = get_comment((int) $comment->comment_parent);
|
||||
if ($parent instanceof \WP_Comment) echo '<p class="jio-forum__reply-parent">回复 @' . esc_html(get_comment_author($parent)) . '</p>';
|
||||
}
|
||||
echo '<div class="jio-forum__reply-text">' . wp_kses_post(wpautop($comment->comment_content)) . '</div><div class="jio-forum__reply-actions">';
|
||||
if (is_user_logged_in()) echo '<button type="button" data-forum-action="reply-like" data-id="' . (int) $comment->comment_ID . '">赞 ' . (int) get_comment_meta($comment->comment_ID, 'jio_comment_likes', true) . '</button>';
|
||||
if (is_user_logged_in() && ((int) $comment->user_id === get_current_user_id() || Access::is_moderator(get_current_user_id(), (int) get_post_meta($post_id, '_jio_forum_plate_id', true)))) echo '<button type="button" data-forum-action="reply-delete" data-id="' . (int) $comment->comment_ID . '">删除</button>';
|
||||
if (Access::is_moderator(get_current_user_id(), (int) get_post_meta($post_id, '_jio_forum_plate_id', true))) echo '<button type="button" data-forum-action="reply-hot" data-id="' . (int) $comment->comment_ID . '">' . ($hot ? '取消热门' : '设为热门') . '</button>';
|
||||
if (is_user_logged_in()) echo '<button type="button" data-forum-reply-to="' . (int) $comment->comment_ID . '">回复</button>';
|
||||
if (get_post_meta($post_id, '_jio_forum_type', true) === 'question' && !get_post_meta($post_id, '_jio_forum_adopted_comment', true) && Access::can_manage_post(get_current_user_id(), $post_id)) {
|
||||
echo '<button type="button" data-forum-action="adopt" data-id="' . $post_id . '" data-comment-id="' . (int) $comment->comment_ID . '">采纳</button>';
|
||||
}
|
||||
echo '</div></div></article>';
|
||||
}
|
||||
if ($total > $limit) {
|
||||
$links = paginate_links(['base' => add_query_arg('cpage', '%#%', get_permalink($post_id)), 'current' => $page, 'total' => (int) ceil($total / $limit), 'type' => 'list']);
|
||||
echo '<nav class="jio-forum__pagination" aria-label="回复分页">' . wp_kses_post((string) $links) . '</nav>';
|
||||
}
|
||||
if (get_post_field('comment_status', $post_id) === 'open') {
|
||||
if (is_user_logged_in()) {
|
||||
echo '<form class="jio-forum__reply-form" data-forum-form="reply" data-id="' . $post_id . '"><h3>发表回复</h3><input type="hidden" name="parent_id" value="0"><textarea name="content" required rows="5" placeholder="写下你的回复"></textarea><p class="jio-forum__message" role="status"></p><button type="submit" class="jio-forum__primary">提交回复</button></form>';
|
||||
} else {
|
||||
echo '<p><a href="' . esc_url(jio_get_login_url(get_permalink($post_id))) . '">登录后回复</a></p>';
|
||||
}
|
||||
} else {
|
||||
echo '<p class="jio-forum__empty">回复已关闭</p>';
|
||||
}
|
||||
echo '</section>';
|
||||
}
|
||||
|
||||
private static function term_management(): void {
|
||||
echo '<section class="jio-forum__term-management"><h2>分类与话题</h2><form data-forum-form="term"><label>类型<select name="type"><option value="category">版块分类</option><option value="topic">论坛话题</option><option value="tag">论坛标签</option></select></label><label>名称<input name="name" required maxlength="60"></label><p class="jio-forum__message" role="status"></p><button type="submit" class="jio-forum__action">添加</button></form>';
|
||||
foreach (['category' => Core::CATEGORY, 'topic' => Core::TOPIC, 'tag' => Core::TAG] as $label => $taxonomy) {
|
||||
$terms = get_terms(['taxonomy' => $taxonomy, 'hide_empty' => false, 'number' => 30]);
|
||||
if (!is_array($terms)) continue;
|
||||
foreach ($terms as $term) {
|
||||
echo '<div class="jio-forum__term-row"><span>' . esc_html($term->name) . '</span><button type="button" data-forum-action="term-edit" data-id="' . (int) $term->term_id . '">编辑</button><button type="button" data-forum-action="term-delete" data-id="' . (int) $term->term_id . '">删除</button></div>';
|
||||
}
|
||||
}
|
||||
echo '</section>';
|
||||
}
|
||||
|
||||
private static function plate_editor(\WP_Post $plate): void {
|
||||
$id = $plate->ID;
|
||||
echo '<button type="button" class="jio-forum__action" data-forum-open="plate-edit-' . $id . '">编辑版块</button>';
|
||||
echo '<button type="button" class="jio-forum__action" data-forum-action="plate-delete" data-id="' . $id . '">删除版块</button>';
|
||||
echo '<dialog class="jio-forum__dialog" data-forum-dialog="plate-edit-' . $id . '"><form data-forum-form="plate" data-id="' . $id . '"><h2>编辑版块</h2>';
|
||||
echo '<label>名称<input name="title" required value="' . esc_attr($plate->post_title) . '"></label>';
|
||||
echo '<label>介绍<textarea name="content" rows="5">' . esc_textarea($plate->post_content) . '</textarea></label>';
|
||||
echo '<label>发帖权限<select name="publish_limit"><option value="all">所有用户</option><option value="follow"' . selected(get_post_meta($id, '_jio_forum_publish_limit', true), 'follow', false) . '>已关注用户</option></select></label>';
|
||||
echo '<label>关注方式<select name="follow_mode"><option value="all">免费</option><option value="points"' . selected(get_post_meta($id, '_jio_forum_follow_mode', true), 'points', false) . '>积分</option><option value="pay"' . selected(get_post_meta($id, '_jio_forum_follow_mode', true), 'pay', false) . '>付费</option></select></label>';
|
||||
echo '<label>关注价格<input name="follow_price" type="number" min="0" step="0.01" value="' . esc_attr((string) get_post_meta($id, '_jio_forum_follow_price', true)) . '"></label>';
|
||||
echo '<label>会员一级价格<input name="follow_price_vip1" type="number" min="0" step="0.01" value="' . esc_attr((string) get_post_meta($id, '_jio_forum_follow_price_vip1', true)) . '"></label>';
|
||||
echo '<label>会员二级价格<input name="follow_price_vip2" type="number" min="0" step="0.01" value="' . esc_attr((string) get_post_meta($id, '_jio_forum_follow_price_vip2', true)) . '"></label>';
|
||||
echo '<label>阅读权限<select name="view_limit">';
|
||||
foreach (['all' => '所有人', 'login' => '登录后', 'follow' => '关注后', 'points' => '积分解锁', 'pay' => '付费解锁'] as $key => $label) {
|
||||
echo '<option value="' . esc_attr($key) . '"' . selected(get_post_meta($id, '_jio_forum_view_limit', true), $key, false) . '>' . esc_html($label) . '</option>';
|
||||
}
|
||||
echo '</select></label><p class="jio-forum__message" role="status"></p><div class="jio-forum__dialog-actions"><button type="button" data-forum-close>取消</button><button type="submit" class="jio-forum__primary">保存</button></div></form></dialog>';
|
||||
}
|
||||
|
||||
private static function applications(int $plate_id): void {
|
||||
$users = get_users(['meta_key' => '_jio_forum_application_' . $plate_id, 'meta_value' => 'pending', 'number' => 20]);
|
||||
echo '<section class="jio-forum__applications"><h2>版主管理</h2>';
|
||||
$moderators = array_map('absint', (array) get_post_meta($plate_id, '_jio_forum_moderators', true));
|
||||
foreach ($moderators as $user_id) {
|
||||
$user = get_userdata($user_id);
|
||||
if ($user) echo '<div class="jio-forum__application"><span>' . esc_html($user->display_name) . '</span><button type="button" data-forum-action="moderator-remove" data-id="' . $plate_id . '" data-user-id="' . $user_id . '">移除</button></div>';
|
||||
}
|
||||
echo '<form data-forum-form="moderator" data-id="' . $plate_id . '"><label>用户 ID<input name="user_id" type="number" min="1" required></label><p class="jio-forum__message" role="status"></p><button type="submit" class="jio-forum__action">添加版主</button></form>';
|
||||
if ($users) echo '<h3>待处理申请</h3>';
|
||||
foreach ($users as $user) {
|
||||
echo '<div class="jio-forum__application"><span>' . esc_html($user->display_name) . '</span><button type="button" data-forum-action="application" data-id="' . $plate_id . '" data-user-id="' . (int) $user->ID . '" data-status="approved">通过</button><button type="button" data-forum-action="application" data-id="' . $plate_id . '" data-user-id="' . (int) $user->ID . '" data-status="rejected">拒绝</button></div>';
|
||||
}
|
||||
echo '</section>';
|
||||
}
|
||||
|
||||
public static function pagination(\WP_Query $query): void {
|
||||
if ($query->max_num_pages <= 1) {
|
||||
return;
|
||||
}
|
||||
echo '<nav class="jio-forum__pagination" aria-label="分页">' . wp_kses_post(paginate_links(['total' => $query->max_num_pages, 'current' => max(1, (int) get_query_var('paged', 1)), 'type' => 'list'])) . '</nav>';
|
||||
}
|
||||
|
||||
public static function action(string $action, int $id, string $label): void {
|
||||
echo '<button type="button" class="jio-forum__action" data-forum-action="' . esc_attr($action) . '" data-id="' . esc_attr((string) $id) . '">' . esc_html($label) . '</button>';
|
||||
}
|
||||
|
||||
public static function user_marked_ids(int $user_id, string $prefix): array {
|
||||
if ($user_id <= 0) return [];
|
||||
$ids = [];
|
||||
foreach (get_user_meta($user_id) as $key => $values) {
|
||||
if (str_starts_with((string) $key, $prefix) && !empty($values[0])) {
|
||||
$id = absint(substr((string) $key, strlen($prefix)));
|
||||
if ($id) $ids[] = $id;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function user_panel(int $user_id, bool $owner): void {
|
||||
$tab = sanitize_key((string) ($_GET['forum_tab'] ?? 'posts'));
|
||||
$tabs = ['posts' => '帖子', 'plates' => '版块'];
|
||||
if ($owner) $tabs += ['favorites' => '收藏', 'follow' => '关注'];
|
||||
if (!isset($tabs[$tab])) $tab = 'posts';
|
||||
echo '<div id="forum" class="jio-forum jio-forum__user-panel"><nav class="jio-forum__tabs" aria-label="论坛内容">';
|
||||
foreach ($tabs as $key => $label) {
|
||||
echo '<a class="' . ($tab === $key ? 'is-active' : '') . '" href="' . esc_url(add_query_arg('forum_tab', $key)) . '">' . esc_html($label) . '</a>';
|
||||
}
|
||||
echo '</nav>';
|
||||
if ($owner) echo '<p><a class="jio-forum__primary" href="' . esc_url(Core::edit_url()) . '">发布帖子</a></p>';
|
||||
$args = ['post_status' => $owner ? ['publish', 'pending'] : 'publish', 'posts_per_page' => 20];
|
||||
if ($tab === 'posts' || $tab === 'plates') {
|
||||
$args['post_type'] = $tab === 'posts' ? Core::POST : Core::PLATE;
|
||||
$args['author'] = $user_id;
|
||||
} else {
|
||||
$args['post_type'] = $tab === 'favorites' ? Core::POST : Core::PLATE;
|
||||
$ids = self::user_marked_ids($user_id, $tab === 'favorites' ? '_jio_forum_favorite_' : '_jio_forum_follow_');
|
||||
$args['post__in'] = $ids ?: [0];
|
||||
}
|
||||
$query = new \WP_Query($args);
|
||||
echo '<div class="jio-forum__list">';
|
||||
if (!$query->have_posts()) echo '<p class="jio-forum__empty">暂无内容</p>';
|
||||
while ($query->have_posts()) {
|
||||
$query->the_post();
|
||||
$post = get_post();
|
||||
if ($post->post_type === Core::POST) self::post_card($post);
|
||||
else {
|
||||
$url = $post->post_status === 'publish' ? get_permalink($post) : '';
|
||||
echo '<div class="jio-forum__user-row">' . ($url ? '<a href="' . esc_url($url) . '">' : '<span>') . esc_html($post->post_title) . ($url ? '</a>' : '</span>') . ($url ? '' : '<small>待审核</small>') . '</div>';
|
||||
if ($owner && !$url) self::plate_editor($post);
|
||||
}
|
||||
}
|
||||
wp_reset_postdata();
|
||||
echo '</div></div>';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace Jio\Forum;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class Widget extends \WP_Widget {
|
||||
public function __construct() {
|
||||
parent::__construct('jio_forum_widget', '论坛内容', ['description' => '显示论坛帖子、版块或话题']);
|
||||
}
|
||||
|
||||
public function widget($args, $instance): void {
|
||||
if (!Core::enabled()) {
|
||||
return;
|
||||
}
|
||||
$title = sanitize_text_field((string) ($instance['title'] ?? '论坛动态'));
|
||||
$type = sanitize_key((string) ($instance['type'] ?? 'posts'));
|
||||
$count = max(1, min(20, (int) ($instance['count'] ?? 6)));
|
||||
echo $args['before_widget'] ?? '';
|
||||
echo ($args['before_title'] ?? '<h3>') . esc_html($title) . ($args['after_title'] ?? '</h3>');
|
||||
if ($type === 'topics') {
|
||||
$terms = get_terms(['taxonomy' => Core::TOPIC, 'hide_empty' => true, 'number' => $count, 'orderby' => 'count', 'order' => 'DESC']);
|
||||
if (is_array($terms)) {
|
||||
foreach ($terms as $term) {
|
||||
echo '<a class="jio-forum__widget-link" href="' . esc_url(get_term_link($term)) . '">' . esc_html($term->name) . '<span>' . (int) $term->count . '</span></a>';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$posts = get_posts(['post_type' => $type === 'plates' ? Core::PLATE : Core::POST, 'post_status' => 'publish', 'posts_per_page' => $count, 'orderby' => $type === 'hot' ? 'comment_count' : 'date']);
|
||||
foreach ($posts as $post) {
|
||||
echo '<a class="jio-forum__widget-link" href="' . esc_url(get_permalink($post)) . '">' . esc_html($post->post_title) . '</a>';
|
||||
}
|
||||
}
|
||||
echo $args['after_widget'] ?? '';
|
||||
}
|
||||
|
||||
public function form($instance): void {
|
||||
$title = (string) ($instance['title'] ?? '论坛动态');
|
||||
$type = (string) ($instance['type'] ?? 'posts');
|
||||
$count = (int) ($instance['count'] ?? 6);
|
||||
echo '<p><label for="' . esc_attr($this->get_field_id('title')) . '">标题</label><input class="widefat" id="' . esc_attr($this->get_field_id('title')) . '" name="' . esc_attr($this->get_field_name('title')) . '" value="' . esc_attr($title) . '"></p>';
|
||||
echo '<p><label for="' . esc_attr($this->get_field_id('type')) . '">内容</label><select class="widefat" id="' . esc_attr($this->get_field_id('type')) . '" name="' . esc_attr($this->get_field_name('type')) . '">';
|
||||
foreach (['posts' => '最新帖子', 'hot' => '热门帖子', 'plates' => '版块', 'topics' => '热门话题'] as $key => $label) {
|
||||
echo '<option value="' . esc_attr($key) . '"' . selected($type, $key, false) . '>' . esc_html($label) . '</option>';
|
||||
}
|
||||
echo '</select></p>';
|
||||
echo '<p><label for="' . esc_attr($this->get_field_id('count')) . '">数量</label><input type="number" min="1" max="20" class="tiny-text" id="' . esc_attr($this->get_field_id('count')) . '" name="' . esc_attr($this->get_field_name('count')) . '" value="' . esc_attr((string) $count) . '"></p>';
|
||||
}
|
||||
|
||||
public function update($new_instance, $old_instance): array {
|
||||
$type = sanitize_key((string) ($new_instance['type'] ?? 'posts'));
|
||||
return [
|
||||
'title' => sanitize_text_field((string) ($new_instance['title'] ?? '')),
|
||||
'type' => in_array($type, ['posts', 'hot', 'plates', 'topics'], true) ? $type : 'posts',
|
||||
'count' => max(1, min(20, (int) ($new_instance['count'] ?? 6))),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,9 @@ class Home {
|
||||
|
||||
$visible[$key] = $this->tabs[$key];
|
||||
}
|
||||
if (class_exists('\\Jio\\Forum\\Core') && \Jio\Forum\Core::enabled() && isset($this->tabs['forum'])) {
|
||||
$visible['forum'] = $this->tabs['forum'];
|
||||
}
|
||||
|
||||
if (empty($visible)) {
|
||||
foreach (['posts', 'comments', 'favorites'] as $fallback) {
|
||||
|
||||
@@ -50,6 +50,13 @@ final class WidgetAreas {
|
||||
$areas['newposts_sidebar_bottom'] = ['前台投稿 - 侧栏底部', '显示在前台投稿侧栏底部,并在移动端保留。', 'sidebar'];
|
||||
$areas['all_footer'] = ['页脚内部', '显示在站点页脚内部。', 'content'];
|
||||
$areas['mobile_nav_fluid'] = ['移动菜单 - 底部', '显示在移动端抽屉菜单底部。', 'sidebar'];
|
||||
foreach (['home' => '论坛首页', 'plate' => '论坛版块', 'term' => '论坛分类', 'post' => '论坛帖子'] as $key => $name) {
|
||||
foreach (['top_fluid' => '顶部全宽', 'top_content' => '主内容上方', 'bottom_content' => '主内容下方', 'bottom_fluid' => '底部全宽', 'sidebar' => '侧栏'] as $position => $label) {
|
||||
$kind = str_contains($position, 'fluid') ? 'fluid' : ($position === 'sidebar' ? 'sidebar' : 'content');
|
||||
$areas['forum_' . $key . '_' . $position] = [$name . ' - ' . $label, '显示在' . $name . '的' . $label, $kind];
|
||||
}
|
||||
}
|
||||
$areas['forum_edit_sidebar'] = ['论坛发帖 - 侧栏', '显示在论坛发帖页面侧栏', 'sidebar'];
|
||||
return $areas;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
use Jio\Forum\Access;
|
||||
use Jio\Forum\Core;
|
||||
use Jio\Forum\View;
|
||||
|
||||
$edit_id = absint(get_query_var('jio_forum_edit'));
|
||||
$post = $edit_id > 0 ? get_post($edit_id) : null;
|
||||
if (!is_user_logged_in()) {
|
||||
wp_safe_redirect(jio_get_login_url(Core::edit_url($edit_id)));
|
||||
exit;
|
||||
}
|
||||
if ($edit_id > 0 && (!$post instanceof WP_Post || !Access::can_manage_post(get_current_user_id(), $edit_id))) {
|
||||
status_header(403);
|
||||
wp_die(esc_html__('无权编辑此帖子', 'jio'));
|
||||
}
|
||||
|
||||
wp_enqueue_editor();
|
||||
|
||||
$plate_id = $post ? (int) get_post_meta($post->ID, '_jio_forum_plate_id', true) : absint($_GET['plate'] ?? 0);
|
||||
$vote_started = $post ? (int) get_post_meta($post->ID, '_jio_forum_vote_started', true) : 0;
|
||||
$vote_deadline = $post ? (int) get_post_meta($post->ID, '_jio_forum_vote_deadline', true) : 0;
|
||||
$vote_days = $vote_started && $vote_deadline ? (int) round(($vote_deadline - $vote_started) / DAY_IN_SECONDS) : 0;
|
||||
$plates = get_posts(['post_type' => Core::PLATE, 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'title', 'order' => 'ASC']);
|
||||
View::open($post ? '编辑帖子' : '发布帖子', 'edit');
|
||||
?>
|
||||
<div class="jio-forum__layout"><div class="jio-forum__main">
|
||||
<h1><?php echo $post ? '编辑帖子' : '发布帖子'; ?></h1>
|
||||
<form class="jio-forum__form" data-forum-form="post" data-id="<?php echo esc_attr((string) $edit_id); ?>">
|
||||
<label>标题<input name="title" required maxlength="120" value="<?php echo esc_attr($post ? $post->post_title : ''); ?>"></label>
|
||||
<label>版块<select name="plate_id" required><option value="">选择版块</option><?php foreach ($plates as $plate) : ?><option value="<?php echo esc_attr((string) $plate->ID); ?>" <?php selected($plate_id, $plate->ID); ?>><?php echo esc_html($plate->post_title); ?></option><?php endforeach; ?></select></label>
|
||||
<label>类型<select name="type"><option value="normal">普通帖子</option><option value="question" <?php selected($post ? get_post_meta($post->ID, '_jio_forum_type', true) : '', 'question'); ?>>问答</option><option value="vote" <?php selected($post ? get_post_meta($post->ID, '_jio_forum_type', true) : '', 'vote'); ?>>投票</option></select></label>
|
||||
<label>正文</label>
|
||||
<?php wp_editor($post ? $post->post_content : '', 'jio_forum_content', ['textarea_name' => 'content', 'textarea_rows' => 16, 'media_buttons' => false, 'quicktags' => true, 'tinymce' => ['toolbar1' => 'formatselect,bold,italic,bullist,numlist,blockquote,link,unlink,alignleft,aligncenter,alignright,undo,redo', 'toolbar2' => 'forecolor,removeformat']]); ?>
|
||||
<div class="jio-forum__editor-tools">
|
||||
<?php foreach (['image' => '插入图片', 'video' => '插入视频', 'resource' => '插入附件'] as $kind => $label) : ?>
|
||||
<?php if (jio_get_setting('jio_forum_upload_' . ($kind === 'resource' ? 'file' : $kind), $kind === 'image')) : ?><button type="button" class="jio-forum__action" data-forum-upload="<?php echo esc_attr($kind); ?>"><?php echo esc_html($label); ?></button><?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<input type="file" data-forum-file hidden>
|
||||
</div>
|
||||
<label>话题<input name="topics" value="<?php echo esc_attr($post ? implode(', ', wp_get_post_terms($post->ID, Core::TOPIC, ['fields' => 'names'])) : ''); ?>" placeholder="多个话题以逗号分隔"></label>
|
||||
<label>标签<input name="tags" value="<?php echo esc_attr($post ? implode(', ', wp_get_post_terms($post->ID, Core::TAG, ['fields' => 'names'])) : ''); ?>" placeholder="多个标签以逗号分隔"></label>
|
||||
<div class="jio-forum__form-extra" data-forum-type="vote">
|
||||
<label>投票标题<input name="vote_title" maxlength="60" value="<?php echo esc_attr($post ? (string) get_post_meta($post->ID, '_jio_forum_vote_title', true) : ''); ?>"></label>
|
||||
<label>投票方式<select name="vote_type"><option value="single">单选</option><option value="multiple" <?php selected($post ? get_post_meta($post->ID, '_jio_forum_vote_type', true) : '', 'multiple'); ?>>多选</option><option value="pk" <?php selected($post ? get_post_meta($post->ID, '_jio_forum_vote_type', true) : '', 'pk'); ?>>双选 PK</option></select></label>
|
||||
<label>投票选项<textarea name="vote_options" rows="4" placeholder="每行一个选项"><?php echo esc_textarea($post ? implode("\n", (array) get_post_meta($post->ID, '_jio_forum_vote_options', true)) : ''); ?></textarea></label>
|
||||
<label>有效期<select name="vote_days"><option value="0" <?php selected($vote_days, 0); ?>>永久</option><option value="1" <?php selected($vote_days, 1); ?>>1 天</option><option value="7" <?php selected($vote_days, 7); ?>>7 天</option><option value="30" <?php selected($vote_days, 30); ?>>30 天</option></select></label>
|
||||
</div>
|
||||
<label>访问权限<select name="view_limit"><option value="all">所有人</option><option value="login" <?php selected($post ? get_post_meta($post->ID, '_jio_forum_view_limit', true) : '', 'login'); ?>>登录后</option><option value="follow" <?php selected($post ? get_post_meta($post->ID, '_jio_forum_view_limit', true) : '', 'follow'); ?>>关注版块后</option><option value="points" <?php selected($post ? get_post_meta($post->ID, '_jio_forum_view_limit', true) : '', 'points'); ?>>积分解锁</option><option value="pay" <?php selected($post ? get_post_meta($post->ID, '_jio_forum_view_limit', true) : '', 'pay'); ?>>付费解锁</option></select></label>
|
||||
<label>解锁价格<input name="price" type="number" min="0" step="0.01" value="<?php echo esc_attr($post ? (string) get_post_meta($post->ID, '_jio_forum_price', true) : '0'); ?>"></label>
|
||||
<label>会员一级价格<input name="price_vip1" type="number" min="0" step="0.01" value="<?php echo esc_attr($post ? (string) get_post_meta($post->ID, '_jio_forum_price_vip1', true) : ''); ?>"></label>
|
||||
<label>会员二级价格<input name="price_vip2" type="number" min="0" step="0.01" value="<?php echo esc_attr($post ? (string) get_post_meta($post->ID, '_jio_forum_price_vip2', true) : ''); ?>"></label>
|
||||
<p class="jio-forum__message" role="status" aria-live="polite"></p><button type="submit" class="jio-forum__primary">保存帖子</button>
|
||||
</form>
|
||||
</div><?php View::sidebar('edit'); ?></div>
|
||||
<?php View::close(); ?>
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
defined('ABSPATH') || exit;
|
||||
\Jio\Forum\View::listing();
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
use Jio\Forum\Access;
|
||||
use Jio\Forum\Core;
|
||||
use Jio\Forum\View;
|
||||
|
||||
the_post();
|
||||
$post = get_post();
|
||||
$id = (int) $post->ID;
|
||||
$plate_id = (int) get_post_meta($id, '_jio_forum_plate_id', true);
|
||||
$type = (string) get_post_meta($id, '_jio_forum_type', true);
|
||||
$user_id = get_current_user_id();
|
||||
$can_view = Access::can_view($user_id, $id);
|
||||
$can_manage = Access::can_manage_post($user_id, $id);
|
||||
$can_moderate = Access::is_moderator($user_id, $plate_id);
|
||||
View::open($post->post_title, 'post');
|
||||
\Jio\WidgetSystem\WidgetAreas::render('forum_post_top_fluid');
|
||||
?>
|
||||
<div class="jio-forum__layout"><div class="jio-forum__main">
|
||||
<article class="jio-forum__single">
|
||||
<header class="jio-forum__single-head">
|
||||
<div class="jio-forum__single-meta"><?php echo get_avatar((int) $post->post_author, 40); ?><span><?php echo esc_html(get_the_author_meta('display_name', (int) $post->post_author)); ?></span><time datetime="<?php echo esc_attr(get_the_date(DATE_W3C)); ?>"><?php echo esc_html(get_the_date()); ?></time></div>
|
||||
<h1><?php echo esc_html($post->post_title); ?></h1>
|
||||
<div class="jio-forum__badges">
|
||||
<?php if (get_post_meta($id, '_jio_forum_pinned', true)) : ?><span>置顶</span><?php endif; ?>
|
||||
<?php if ($type === 'question') : ?><span>问答</span><?php elseif ($type === 'vote') : ?><span>投票</span><?php endif; ?>
|
||||
<?php if ($type === 'question' && get_post_meta($id, '_jio_forum_adopted_comment', true)) : ?><span>已解决</span><?php endif; ?>
|
||||
<?php if (get_post_meta($id, '_jio_forum_essence', true)) : ?><span>精华</span><?php endif; ?>
|
||||
<?php if ($plate_id > 0) : ?><a href="<?php echo esc_url(get_permalink($plate_id)); ?>"><?php echo esc_html(get_the_title($plate_id)); ?></a><?php endif; ?>
|
||||
<?php foreach (wp_get_post_terms($id, Core::TOPIC) as $term) : ?><a href="<?php echo esc_url(get_term_link($term)); ?>">#<?php echo esc_html($term->name); ?></a><?php endforeach; ?>
|
||||
</div>
|
||||
</header>
|
||||
<?php if ($can_view) : ?>
|
||||
<div class="jio-forum__single-content"><?php echo apply_filters('the_content', $post->post_content); ?></div>
|
||||
<?php if ($type === 'vote') : ?>
|
||||
<?php
|
||||
$options = (array) get_post_meta($id, '_jio_forum_vote_options', true);
|
||||
$votes = (array) get_post_meta($id, '_jio_forum_vote_counts', true);
|
||||
$vote_type = (string) get_post_meta($id, '_jio_forum_vote_type', true);
|
||||
$deadline = (int) get_post_meta($id, '_jio_forum_vote_deadline', true);
|
||||
$expired = $deadline > 0 && current_time('timestamp') > $deadline;
|
||||
$vote_display = (string) jio_get_setting('jio_forum_vote_number_type', 'percentage');
|
||||
$already_voted = $user_id > 0 && get_user_meta($user_id, '_jio_forum_vote_' . $id, true) !== '';
|
||||
?>
|
||||
<form class="jio-forum__vote" data-forum-form="vote" data-id="<?php echo esc_attr((string) $id); ?>"><h2><?php echo esc_html((string) get_post_meta($id, '_jio_forum_vote_title', true) ?: '参与投票'); ?></h2>
|
||||
<?php foreach ($options as $index => $option) : ?>
|
||||
<label><input type="<?php echo $vote_type === 'multiple' ? 'checkbox' : 'radio'; ?>" name="<?php echo $vote_type === 'multiple' ? 'option[]' : 'option'; ?>" value="<?php echo esc_attr((string) $index); ?>" <?php disabled($already_voted || $expired); ?> <?php if ($vote_type !== 'multiple') echo 'required'; ?>><span><?php echo esc_html($option); ?></span>
|
||||
<?php if ($vote_display !== 'hide') : ?><span class="jio-forum__vote-count"><?php echo esc_html($vote_display === 'number' ? (string) (int) ($votes[$index] ?? 0) : (string) ((int) get_post_meta($id, '_jio_forum_vote_total', true) > 0 ? round(100 * (int) ($votes[$index] ?? 0) / (int) get_post_meta($id, '_jio_forum_vote_total', true)) : 0) . '%'); ?></span><?php endif; ?></label>
|
||||
<?php endforeach; ?>
|
||||
<p class="jio-forum__message" role="status"></p><?php if ($already_voted) : ?><p>已投票</p><?php elseif ($expired) : ?><p>投票已截止</p><?php else : ?><button type="submit" class="jio-forum__primary">提交投票</button><?php endif; ?>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php else : ?>
|
||||
<section class="jio-forum__locked"><h2>内容已隐藏</h2><p>请完成访问条件后查看。</p>
|
||||
<?php if ($user_id > 0) : ?>
|
||||
<?php
|
||||
$plate_rule = (string) get_post_meta($plate_id, '_jio_forum_view_limit', true);
|
||||
$post_rule = (string) get_post_meta($id, '_jio_forum_view_limit', true);
|
||||
if (in_array($plate_rule, ['points', 'pay'], true) && !\Jio\Forum\Commerce::has_plate_entitlement($user_id, $plate_id)) {
|
||||
View::action('plate-unlock', $plate_id, '解锁版块');
|
||||
} elseif (($plate_rule === 'follow' || $post_rule === 'follow') && !Access::follows($user_id, $plate_id)) {
|
||||
echo '<a href="' . esc_url(get_permalink($plate_id)) . '">关注版块</a>';
|
||||
} elseif (in_array($post_rule, ['points', 'pay'], true)) {
|
||||
View::action('unlock', $id, '解锁帖子');
|
||||
}
|
||||
?>
|
||||
<?php else : ?><a href="<?php echo esc_url(jio_get_login_url(get_permalink($id))); ?>">登录查看</a><?php endif; ?>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
<div class="jio-forum__actions">
|
||||
<?php View::action('score', $id, '赞 ' . (int) get_post_meta($id, '_jio_forum_score', true)); ?>
|
||||
<button type="button" class="jio-forum__action" data-forum-action="score" data-direction="down" data-id="<?php echo esc_attr((string) $id); ?>">踩</button>
|
||||
<?php View::action('favorite', $id, '收藏'); ?>
|
||||
<?php View::action('report', $id, '举报'); ?>
|
||||
<?php if ($can_manage) : ?><a href="<?php echo esc_url(Core::edit_url($id)); ?>">编辑</a><?php View::action('delete', $id, '删除'); endif; ?>
|
||||
<?php if ($can_moderate) : View::action('essence', $id, '精华'); View::action('pin', $id, '置顶'); View::action('audit', $id, '审核'); View::action('comments', $id, $post->comment_status === 'open' ? '关闭回复' : '开放回复'); View::action('move', $id, '移动版块'); endif; ?>
|
||||
</div>
|
||||
</article>
|
||||
<?php if ($can_view) : ?>
|
||||
<?php if ($type === 'question' && get_post_meta($id, '_jio_forum_adopted_comment', true)) : ?><p class="jio-forum__adopted">已采纳回复 #<?php echo (int) get_post_meta($id, '_jio_forum_adopted_comment', true); ?></p><?php endif; ?>
|
||||
<?php View::replies($id); ?>
|
||||
<?php endif; ?>
|
||||
</div><?php View::sidebar('post'); ?></div>
|
||||
<?php \Jio\WidgetSystem\WidgetAreas::render('forum_post_bottom_fluid'); View::close(); ?>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
/**
|
||||
* Template Name: Jio - 论坛首页
|
||||
* Template Post Type: page
|
||||
*/
|
||||
defined('ABSPATH') || exit;
|
||||
if (!\Jio\Forum\Core::enabled()) {
|
||||
status_header(404);
|
||||
include get_404_template();
|
||||
return;
|
||||
}
|
||||
\Jio\Forum\View::home();
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
defined('ABSPATH') || exit;
|
||||
\Jio\Forum\View::user_panel(get_current_user_id(), true);
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
defined('ABSPATH') || exit;
|
||||
$user_id = (int) ($args['user_id'] ?? 0);
|
||||
if ($user_id > 0) {
|
||||
\Jio\Forum\View::user_panel($user_id, $user_id === get_current_user_id());
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { spawn } = require('node:child_process')
|
||||
|
||||
const edge = process.env.JIO_FORUM_BROWSER || 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe'
|
||||
const base = process.env.JIO_FORUM_BASE_URL || 'http://localhost:9081'
|
||||
const lockedUrl = process.env.JIO_FORUM_LOCKED_URL || ''
|
||||
const multiVoteUrl = process.env.JIO_FORUM_MULTI_VOTE_URL || ''
|
||||
const browserPassword = process.env.JIO_FORUM_TEST_PASSWORD || ''
|
||||
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'jio-forum-browser-'))
|
||||
const browser = spawn(edge, [
|
||||
'--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check',
|
||||
'--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank',
|
||||
], { stdio: 'ignore', windowsHide: true })
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
const until = async (fn, timeout = 15000) => {
|
||||
const end = Date.now() + timeout
|
||||
while (Date.now() < end) {
|
||||
const result = await fn().catch(() => null)
|
||||
if (result) return result
|
||||
await sleep(100)
|
||||
}
|
||||
throw new Error('browser timeout')
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const portFile = path.join(profile, 'DevToolsActivePort')
|
||||
const port = await until(async () => fs.existsSync(portFile) && Number(fs.readFileSync(portFile, 'utf8').split('\n')[0]))
|
||||
const target = await until(async () => {
|
||||
const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
|
||||
return list.find((item) => item.type === 'page')?.webSocketDebuggerUrl
|
||||
})
|
||||
const socket = new WebSocket(target)
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.addEventListener('open', resolve, { once: true })
|
||||
socket.addEventListener('error', reject, { once: true })
|
||||
})
|
||||
let nextId = 0
|
||||
const pending = new Map()
|
||||
const problems = []
|
||||
socket.addEventListener('message', ({ data }) => {
|
||||
const item = JSON.parse(data)
|
||||
if (item.id && pending.has(item.id)) {
|
||||
const { resolve, reject } = pending.get(item.id)
|
||||
pending.delete(item.id)
|
||||
item.error ? reject(new Error(item.error.message)) : resolve(item.result)
|
||||
}
|
||||
if (item.method === 'Runtime.exceptionThrown') problems.push(item.params.exceptionDetails.text)
|
||||
if (item.method === 'Runtime.consoleAPICalled' && item.params.type === 'error') {
|
||||
problems.push(item.params.args.map((arg) => arg.value || arg.description || '').join(' '))
|
||||
}
|
||||
if (item.method === 'Network.loadingFailed' && !item.params.canceled) problems.push(item.params.errorText)
|
||||
})
|
||||
const call = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const id = ++nextId
|
||||
pending.set(id, { resolve, reject })
|
||||
socket.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
await Promise.all([call('Page.enable'), call('Runtime.enable'), call('Network.enable')])
|
||||
|
||||
const check = async (width, height, url, label) => {
|
||||
await call('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 600 })
|
||||
await call('Page.navigate', { url: base + url })
|
||||
await until(async () => {
|
||||
const result = await call('Runtime.evaluate', { expression: 'document.readyState', returnByValue: true })
|
||||
return result.result.value === 'complete'
|
||||
})
|
||||
await sleep(800)
|
||||
const result = await call('Runtime.evaluate', {
|
||||
expression: `({forum:!!document.getElementById('forum'),posts:document.querySelectorAll('.jio-forum__post').length,locked:document.body.textContent.includes('内容已隐藏'),leaked:document.documentElement.innerHTML.includes('需要积分查看的正文'),overflow:document.documentElement.scrollWidth>document.documentElement.clientWidth,width:innerWidth})`,
|
||||
returnByValue: true,
|
||||
})
|
||||
const state = result.result.value
|
||||
if (!state.forum || state.overflow || (url === '/forums/' && state.posts < 1) || (url === lockedUrl && (!state.locked || state.leaked))) {
|
||||
throw new Error(`${label}: ${JSON.stringify(state)}`)
|
||||
}
|
||||
console.log(`${label}: ${JSON.stringify(state)}`)
|
||||
}
|
||||
|
||||
try {
|
||||
await check(1440, 900, '/forums/', 'desktop home')
|
||||
await check(390, 844, '/forums/', 'mobile home')
|
||||
if (lockedUrl) {
|
||||
await check(390, 844, lockedUrl, 'mobile locked post')
|
||||
await call('Runtime.evaluate', { expression: `document.body.classList.remove('dark-theme');document.documentElement.classList.remove('dark')` })
|
||||
await sleep(650)
|
||||
const before = await call('Runtime.evaluate', { expression: `getComputedStyle(document.querySelector('.jio-forum__locked')).backgroundColor`, returnByValue: true })
|
||||
await call('Runtime.evaluate', { expression: `document.body.classList.add('dark-theme');document.documentElement.classList.add('dark')` })
|
||||
await sleep(650)
|
||||
const after = await call('Runtime.evaluate', { expression: `getComputedStyle(document.querySelector('.jio-forum__locked')).backgroundColor`, returnByValue: true })
|
||||
if (before.result.value === after.result.value) throw new Error(`forum dark mode does not change locked surface: ${before.result.value}`)
|
||||
console.log(`dark mode: ${JSON.stringify({ light: before.result.value, dark: after.result.value })}`)
|
||||
}
|
||||
if (browserPassword) {
|
||||
await call('Page.navigate', { url: base + '/sign/?tab=signin' })
|
||||
await until(async () => {
|
||||
const state = await call('Runtime.evaluate', { expression: 'document.readyState === "complete" && !!window.jioData?.restNonce', returnByValue: true })
|
||||
return state.result.value
|
||||
})
|
||||
const login = await call('Runtime.evaluate', {
|
||||
expression: `(async () => { const response=await fetch('/wp-json/jio/v1/auth/login',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-WP-Nonce':window.jioData.restNonce},body:JSON.stringify({target:'forum_admin',password:${JSON.stringify(browserPassword)},redirect_to:${JSON.stringify(base + '/wp-admin/')}})});return {status:response.status,body:await response.json()}})()`,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
})
|
||||
if (login.result.value?.status !== 200) throw new Error(`login failed: ${JSON.stringify(login.result.value)}`)
|
||||
for (const [width, height, label] of [[1440, 900, 'desktop admin'], [390, 844, 'mobile admin']]) {
|
||||
await call('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 600 })
|
||||
await call('Page.navigate', { url: base + '/wp-admin/admin.php?page=jio-settings#/forum/general' })
|
||||
const state = await until(async () => {
|
||||
const result = await call('Runtime.evaluate', { expression: `({app:!!document.querySelector('#jio-admin-app'),ready:document.querySelector('#jio-admin-app')?.textContent.includes('启用论坛'),width:innerWidth,overflow:document.documentElement.scrollWidth>document.documentElement.clientWidth})`, returnByValue: true })
|
||||
return result.result.value?.ready && result.result.value
|
||||
}, 20000)
|
||||
if (!state.app || state.overflow) throw new Error(`${label}: ${JSON.stringify(state)}`)
|
||||
console.log(`${label}: ${JSON.stringify(state)}`)
|
||||
}
|
||||
if (multiVoteUrl) {
|
||||
await call('Page.navigate', { url: base + multiVoteUrl })
|
||||
await until(async () => {
|
||||
const result = await call('Runtime.evaluate', { expression: `document.querySelectorAll('.jio-forum__vote input[type=checkbox]').length >= 2`, returnByValue: true })
|
||||
return result.result.value
|
||||
})
|
||||
await call('Runtime.evaluate', { expression: `(() => {const form=document.querySelector('[data-forum-form=vote]');const boxes=form.querySelectorAll('input[type=checkbox]');boxes[0].checked=true;boxes[1].checked=true;form.requestSubmit();return true})()`, returnByValue: true })
|
||||
const voted = await until(async () => {
|
||||
const result = await call('Runtime.evaluate', { expression: `({done:document.querySelector('.jio-forum__vote')?.textContent.includes('已投票'),error:document.querySelector('.jio-forum__vote .jio-forum__message')?.textContent||''})`, returnByValue: true })
|
||||
return result.result.value?.done && result.result.value
|
||||
}, 12000)
|
||||
if (voted.error) throw new Error(`multiple vote browser error: ${voted.error}`)
|
||||
console.log(`multiple vote browser: ${JSON.stringify(voted)}`)
|
||||
}
|
||||
await call('Page.navigate', { url: base + '/posts-edit/' })
|
||||
const editorState = await until(async () => {
|
||||
const result = await call('Runtime.evaluate', { expression: `({form:!!document.querySelector('[data-forum-form=post]'),editor:!!document.querySelector('#jio_forum_content'),upload:!!document.querySelector('[data-forum-upload=image]')})`, returnByValue: true })
|
||||
return result.result.value?.form && result.result.value
|
||||
})
|
||||
if (!editorState.editor || !editorState.upload) throw new Error(`forum editor unavailable: ${JSON.stringify(editorState)}`)
|
||||
console.log(`forum editor: ${JSON.stringify(editorState)}`)
|
||||
const editorLayout = await call('Runtime.evaluate', { expression: `({overflow:document.documentElement.scrollWidth>document.documentElement.clientWidth,css:[...document.styleSheets].some((sheet)=>sheet.href?.includes('/forum.css'))})`, returnByValue: true })
|
||||
if (editorLayout.result.value?.overflow || !editorLayout.result.value?.css) throw new Error(`mobile editor layout: ${JSON.stringify(editorLayout.result.value)}`)
|
||||
console.log(`mobile editor layout: ${JSON.stringify(editorLayout.result.value)}`)
|
||||
const upload = await call('Runtime.evaluate', {
|
||||
expression: `(async()=>{const source=await fetch('/wp-content/themes/jio/assets/img/topic.jpg');const blob=await source.blob();const body=new FormData();body.append('file',new File([blob],'topic.jpg',{type:'image/jpeg'}));const response=await fetch('/wp-json/jio/v1/forum/upload/image',{method:'POST',credentials:'same-origin',headers:{'X-WP-Nonce':window.jioForum.nonce},body});const payload=await response.json();return {status:response.status,id:payload.data?.id,url:payload.data?.url,message:payload.message}})()`,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
})
|
||||
if (upload.result.value?.status !== 200 || !upload.result.value?.url) throw new Error(`forum upload failed: ${JSON.stringify(upload.result.value)}`)
|
||||
console.log(`forum upload: ${JSON.stringify({ status: upload.result.value.status, id: upload.result.value.id })}`)
|
||||
const submitted = await call('Runtime.evaluate', {
|
||||
expression: `(() => { const form=document.querySelector('[data-forum-form=post]');form.querySelector('[name=title]').value='浏览器表单测试';const plate=form.querySelector('[name=plate_id]');plate.value=plate.querySelector('option[value]:not([value=""])')?.value||'';const editor=window.tinymce?.get('jio_forum_content');if(editor&&!editor.isHidden())editor.setContent('<p>浏览器表单正文</p>');else form.querySelector('[name=content]').value='浏览器表单正文';form.requestSubmit();return {plate:plate.value} })()`,
|
||||
returnByValue: true,
|
||||
})
|
||||
if (!submitted.result.value?.plate) throw new Error('no publishable plate in editor')
|
||||
const saved = await until(async () => {
|
||||
const result = await call('Runtime.evaluate', { expression: `({url:location.pathname,error:document.querySelector('.jio-forum__form .jio-forum__message')?.textContent||''})`, returnByValue: true })
|
||||
return result.result.value?.url.startsWith('/forum-post/') && result.result.value
|
||||
}, 12000)
|
||||
console.log(`browser form submit: ${JSON.stringify(saved)}`)
|
||||
await call('Page.navigate', { url: base + '/user/forum/' })
|
||||
const center = await until(async () => {
|
||||
const result = await call('Runtime.evaluate', { expression: `({panel:!!document.querySelector('#panel-forum'),posts:document.querySelector('#panel-forum')?.textContent.includes('帖子'),plates:document.querySelector('#panel-forum')?.textContent.includes('版块'),css:[...document.styleSheets].some((sheet)=>sheet.href?.includes('/forum.css'))})`, returnByValue: true })
|
||||
return result.result.value?.panel && result.result.value
|
||||
})
|
||||
if (!center.posts || !center.plates || !center.css) throw new Error(`forum user panel unavailable: ${JSON.stringify(center)}`)
|
||||
console.log(`user center: ${JSON.stringify(center)}`)
|
||||
await call('Page.navigate', { url: base + '/author/forum_admin/' })
|
||||
const publicHome = await until(async () => {
|
||||
const result = await call('Runtime.evaluate', { expression: `({forum:!!document.querySelector('#panel-forum'),css:[...document.styleSheets].some((sheet)=>sheet.href?.includes('/forum.css'))})`, returnByValue: true })
|
||||
return result.result.value?.forum && result.result.value
|
||||
})
|
||||
if (!publicHome.css) throw new Error(`public user home CSS missing: ${JSON.stringify(publicHome)}`)
|
||||
console.log(`public user home: ${JSON.stringify(publicHome)}`)
|
||||
}
|
||||
if (problems.length) throw new Error(`browser errors: ${problems.join('; ')}`)
|
||||
} finally {
|
||||
call('Browser.close').catch(() => {})
|
||||
await sleep(150)
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = async () => {
|
||||
const target = path.resolve(profile)
|
||||
const tempRoot = path.resolve(os.tmpdir()) + path.sep
|
||||
if (target.startsWith(tempRoot) && path.basename(target).startsWith('jio-forum-browser-')) {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
try {
|
||||
fs.rmSync(target, { recursive: true, force: true })
|
||||
return
|
||||
} catch (error) {
|
||||
if (error.code !== 'EBUSY' && error.code !== 'EPERM') throw error
|
||||
await sleep(250)
|
||||
}
|
||||
}
|
||||
throw new Error('browser profile is still in use')
|
||||
}
|
||||
}
|
||||
|
||||
run().then(async () => {
|
||||
browser.kill()
|
||||
await cleanup()
|
||||
}).catch(async (error) => {
|
||||
console.error(error)
|
||||
browser.kill()
|
||||
await cleanup().catch((cleanupError) => console.error(cleanupError))
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
name: jio-forum-check
|
||||
|
||||
services:
|
||||
db:
|
||||
image: mysql:8.0
|
||||
environment:
|
||||
MYSQL_DATABASE: jioforum
|
||||
MYSQL_USER: jioforum
|
||||
MYSQL_PASSWORD: jioforum
|
||||
MYSQL_RANDOM_ROOT_PASSWORD: "1"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u jioforum -pjioforum"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
|
||||
wordpress:
|
||||
image: wordpress:latest
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
WORDPRESS_DB_HOST: db
|
||||
WORDPRESS_DB_USER: jioforum
|
||||
WORDPRESS_DB_PASSWORD: jioforum
|
||||
WORDPRESS_DB_NAME: jioforum
|
||||
ports:
|
||||
- "9081:80"
|
||||
volumes:
|
||||
- wp_data:/var/www/html
|
||||
- type: bind
|
||||
source: ../..
|
||||
target: /var/www/html/wp-content/themes/jio
|
||||
read_only: true
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
wp_data:
|
||||
@@ -0,0 +1,47 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const root = path.resolve(__dirname, '../..')
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
|
||||
|
||||
test('every forum page mounts the dedicated runtime and the public write routes require a nonce', () => {
|
||||
const core = read('src/Forum/Core.php')
|
||||
const rest = read('src/Forum/Rest.php')
|
||||
const runtime = read('assets/js/forum-runtime.js')
|
||||
for (const template of ['page-forums.php', 'page-forum-list.php', 'page-forum-post.php', 'page-forum-edit.php']) {
|
||||
assert.ok(fs.existsSync(path.join(root, 'templates/pages', template)))
|
||||
}
|
||||
assert.match(core, /wp_enqueue_script\('jio-forum'/)
|
||||
assert.match(core, /wp_create_nonce\('wp_rest'\)/)
|
||||
assert.match(rest, /'permission_callback' => \$auth/)
|
||||
assert.match(rest, /wp_verify_nonce\(\$nonce, 'wp_rest'\)/)
|
||||
assert.match(runtime, /'X-WP-Nonce'/)
|
||||
})
|
||||
|
||||
test('paid content has server-side presentation guards beyond its page body', () => {
|
||||
const access = read('src/Forum/Access.php')
|
||||
const integration = read('src/Forum/Integration.php')
|
||||
const view = read('src/Forum/View.php')
|
||||
assert.match(access, /function can_view\(/)
|
||||
assert.match(integration, /add_filter\('the_content'/)
|
||||
assert.match(integration, /add_filter\('get_the_excerpt'/)
|
||||
assert.match(integration, /add_filter\('jio_seo_head_payload'/)
|
||||
assert.match(view, /Access::can_view\(get_current_user_id\(\), \$post->ID\)/)
|
||||
})
|
||||
|
||||
test('forum management, account, search and responsive styles remain connected', () => {
|
||||
const admin = read('assets/admin/src/constants/sections/forum.js')
|
||||
const schema = read('src/Admin/Schema/SchemaForum.php')
|
||||
const search = read('search.php')
|
||||
const css = read('assets/css/routes/forum.css')
|
||||
for (const section of ['general', 'home', 'publishing', 'access', 'replies', 'payment', 'urls']) {
|
||||
assert.match(admin, new RegExp(`'${section}'`))
|
||||
assert.match(schema, new RegExp(`'${section}'`))
|
||||
}
|
||||
assert.match(search, /search_type === 'forum'/)
|
||||
assert.match(css, /@media\(max-width:800px\)/)
|
||||
assert.match(css, /@media\(max-width:480px\)/)
|
||||
assert.match(read('src/Forum/View.php'), /function user_panel\(/)
|
||||
})
|
||||
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
$_SERVER['HTTP_HOST'] = 'localhost:9081';
|
||||
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
|
||||
if (($argv[1] ?? '') === 'install') {
|
||||
define('WP_INSTALLING', true);
|
||||
}
|
||||
require '/var/www/html/wp-load.php';
|
||||
|
||||
if (($argv[1] ?? '') === 'set-browser-password') {
|
||||
$password = (string) getenv('JIO_FORUM_TEST_PASSWORD');
|
||||
$admin = get_user_by('login', 'forum_admin');
|
||||
if (strlen($password) < 16 || !$admin instanceof WP_User) {
|
||||
throw new RuntimeException('browser login configuration missing');
|
||||
}
|
||||
wp_set_password($password, (int) $admin->ID);
|
||||
echo "browser login ready\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
if (($argv[1] ?? '') === 'install') {
|
||||
if (!is_blog_installed()) {
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
wp_install('Forum check', 'forum_admin', 'forum@example.test', false, '', wp_generate_password(24));
|
||||
}
|
||||
update_option('home', 'http://localhost:9081');
|
||||
update_option('siteurl', 'http://localhost:9081');
|
||||
switch_theme('jio');
|
||||
echo "installed\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
function forum_check(bool $condition, string $label): void {
|
||||
if (!$condition) {
|
||||
throw new RuntimeException($label);
|
||||
}
|
||||
echo "OK: {$label}\n";
|
||||
}
|
||||
|
||||
function forum_request(string $method, string $route, int $user_id, array $params = [], string $key = ''): WP_REST_Response {
|
||||
wp_set_current_user($user_id);
|
||||
$request = new WP_REST_Request($method, '/jio/v1/forum/' . ltrim($route, '/'));
|
||||
$request->set_header('X-WP-Nonce', wp_create_nonce('wp_rest'));
|
||||
if ($key !== '') $request->set_header('Idempotency-Key', $key);
|
||||
$request->set_body_params($params);
|
||||
$response = rest_do_request($request);
|
||||
forum_check($response instanceof WP_REST_Response, "REST response {$route}");
|
||||
return $response;
|
||||
}
|
||||
|
||||
forum_check(post_type_exists(\Jio\Forum\Core::PLATE), 'plate registered');
|
||||
forum_check(post_type_exists(\Jio\Forum\Core::POST), 'post registered');
|
||||
forum_check(taxonomy_exists(\Jio\Forum\Core::TOPIC), 'topic registered');
|
||||
forum_check(get_page_by_path('forums') instanceof WP_Post, 'forum home created');
|
||||
|
||||
$schema = \Jio\Admin\SchemaProvider::get_instance()->get_schema('forum');
|
||||
forum_check(is_array($schema) && count($schema['sections'] ?? []) === 7, 'forum settings schema');
|
||||
$invalid_urls = \Jio\Admin\SchemaProvider::get_instance()->validate('forum', ['jio_forum_slug_plate' => 'forum-post'], 'urls');
|
||||
forum_check(!$invalid_urls['valid'], 'duplicate URL slugs rejected');
|
||||
|
||||
$admin = get_user_by('login', 'forum_admin');
|
||||
forum_check($admin instanceof WP_User, 'admin exists');
|
||||
wp_set_current_user((int) $admin->ID);
|
||||
$admin_schema_request = new WP_REST_Request('GET', '/jio/v1/settings/schema/forum/general');
|
||||
$admin_schema_request->set_header('X-WP-Nonce', wp_create_nonce('wp_rest'));
|
||||
$admin_schema_response = rest_do_request($admin_schema_request);
|
||||
forum_check($admin_schema_response->get_status() === 200, 'admin forum settings route');
|
||||
$member_login = 'forum_member_' . substr(str_replace('-', '', wp_generate_uuid4()), 0, 8);
|
||||
$member_id = wp_create_user($member_login, wp_generate_password(24), $member_login . '@example.test');
|
||||
forum_check(!is_wp_error($member_id), 'member created');
|
||||
$member = get_userdata((int) $member_id);
|
||||
|
||||
wp_set_current_user((int) $admin->ID);
|
||||
$plate = wp_insert_post(['post_type' => \Jio\Forum\Core::PLATE, 'post_status' => 'publish', 'post_title' => '测试版块', 'post_author' => (int) $admin->ID], true);
|
||||
forum_check(!is_wp_error($plate) && $plate > 0, 'plate created');
|
||||
|
||||
$guest = forum_request('POST', 'posts', 0, ['title' => '测试帖子', 'content' => '正文内容', 'plate_id' => $plate]);
|
||||
forum_check($guest->get_status() === 401, 'guest cannot publish');
|
||||
|
||||
$created = forum_request('POST', 'posts', (int) $member->ID, ['title' => '测试帖子', 'content' => '正文内容', 'plate_id' => $plate, 'type' => 'question']);
|
||||
forum_check($created->get_status() === 200, 'member can submit');
|
||||
$post_id = (int) ($created->get_data()['data']['id'] ?? 0);
|
||||
forum_check($post_id > 0 && get_post_status($post_id) === 'pending', 'post awaits review');
|
||||
|
||||
$blocked = forum_request('POST', "posts/{$post_id}/audit", (int) $member->ID);
|
||||
forum_check($blocked->get_status() === 403, 'member cannot audit');
|
||||
$approved = forum_request('POST', "posts/{$post_id}/audit", (int) $admin->ID);
|
||||
forum_check($approved->get_status() === 200 && get_post_status($post_id) === 'publish', 'moderator approves post');
|
||||
|
||||
$follow = forum_request('POST', "plates/{$plate}/follow", (int) $member->ID);
|
||||
forum_check($follow->get_status() === 200 && \Jio\Forum\Access::follows((int) $member->ID, $plate), 'plate follow');
|
||||
|
||||
$reply = forum_request('POST', "posts/{$post_id}/replies", (int) $admin->ID, ['content' => '这是测试回复']);
|
||||
forum_check($reply->get_status() === 200, 'reply submitted');
|
||||
$comment_id = (int) ($reply->get_data()['data']['id'] ?? 0);
|
||||
$adopt = forum_request('POST', "posts/{$post_id}/adopt", (int) $member->ID, ['comment_id' => $comment_id]);
|
||||
forum_check($adopt->get_status() === 200, 'answer adopted');
|
||||
$hot_reply = forum_request('POST', "replies/{$comment_id}/hot", (int) $admin->ID);
|
||||
forum_check($hot_reply->get_status() === 200 && get_comment_meta($comment_id, '_jio_forum_hot', true), 'reply marked hot');
|
||||
$reply_deleted = forum_request('POST', "replies/{$comment_id}/delete", (int) $member->ID);
|
||||
forum_check($reply_deleted->get_status() === 403, 'other member cannot delete reply');
|
||||
$reply_deleted = forum_request('POST', "replies/{$comment_id}/delete", (int) $admin->ID);
|
||||
forum_check($reply_deleted->get_status() === 200 && get_comment($comment_id)->comment_approved === 'trash', 'moderator deletes reply');
|
||||
forum_check(!get_post_meta($post_id, '_jio_forum_adopted_comment', true), 'deleted answer clears adoption');
|
||||
$reply_restored = forum_request('POST', "replies/{$comment_id}/restore", (int) $admin->ID);
|
||||
forum_check($reply_restored->get_status() === 200 && get_comment($comment_id)->comment_approved === '1', 'moderator restores reply');
|
||||
$readopt = forum_request('POST', "posts/{$post_id}/adopt", (int) $member->ID, ['comment_id' => $comment_id]);
|
||||
forum_check($readopt->get_status() === 200, 'restored answer may be adopted again');
|
||||
$article_id = wp_insert_post(['post_type' => 'post', 'post_status' => 'publish', 'post_title' => '普通文章', 'post_author' => (int) $admin->ID]);
|
||||
$article_comment = wp_insert_comment(['comment_post_ID' => $article_id, 'comment_content' => '普通文章评论', 'user_id' => (int) $member->ID, 'comment_approved' => 1]);
|
||||
$wrong_comment_delete = forum_request('POST', "replies/{$article_comment}/delete", (int) $member->ID);
|
||||
forum_check($wrong_comment_delete->get_status() === 403 && get_comment($article_comment)->comment_approved === '1', 'forum route cannot delete article comment');
|
||||
|
||||
$term_created = forum_request('POST', 'terms', (int) $admin->ID, ['type' => 'topic', 'name' => '测试话题']);
|
||||
forum_check($term_created->get_status() === 200, 'forum topic created');
|
||||
$term_id = (int) ($term_created->get_data()['data']['id'] ?? 0);
|
||||
$term_updated = forum_request('PATCH', "terms/{$term_id}", (int) $admin->ID, ['name' => '新测试话题']);
|
||||
forum_check($term_updated->get_status() === 200 && get_term($term_id)->name === '新测试话题', 'forum topic edited');
|
||||
$term_deleted = forum_request('DELETE', "terms/{$term_id}", (int) $admin->ID);
|
||||
forum_check($term_deleted->get_status() === 200, 'forum topic deleted');
|
||||
|
||||
$vote = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '测试投票', 'content' => '请选择选项', 'plate_id' => $plate, 'type' => 'vote', 'vote_options' => "选项一\n选项二"]);
|
||||
forum_check($vote->get_status() === 200, 'vote post created');
|
||||
$vote_id = (int) ($vote->get_data()['data']['id'] ?? 0);
|
||||
$cast = forum_request('POST', "posts/{$vote_id}/vote", (int) $member->ID, ['option' => 0]);
|
||||
forum_check($cast->get_status() === 200, 'vote cast');
|
||||
$repeat = forum_request('POST', "posts/{$vote_id}/vote", (int) $member->ID, ['option' => 1]);
|
||||
forum_check($repeat->get_status() === 200 && (int) get_post_meta($vote_id, '_jio_forum_vote_total', true) === 1, 'vote idempotent');
|
||||
$multi = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '多选投票', 'content' => '选择多个选项', 'plate_id' => $plate, 'type' => 'vote', 'vote_type' => 'multiple', 'vote_title' => '你喜欢什么', 'vote_options' => "一\n二\n三"]);
|
||||
forum_check($multi->get_status() === 200, 'multiple vote created');
|
||||
$multi_id = (int) ($multi->get_data()['data']['id'] ?? 0);
|
||||
$multi_cast = forum_request('POST', "posts/{$multi_id}/vote", (int) $member->ID, ['options' => [0, 2]]);
|
||||
$multi_counts = (array) get_post_meta($multi_id, '_jio_forum_vote_counts', true);
|
||||
forum_check($multi_cast->get_status() === 200 && (int) get_post_meta($multi_id, '_jio_forum_vote_total', true) === 1 && (int) ($multi_counts[0] ?? 0) === 1 && (int) ($multi_counts[2] ?? 0) === 1, 'multiple vote counts one voter');
|
||||
$expired_vote = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '过期投票', 'content' => '已过期', 'plate_id' => $plate, 'type' => 'vote', 'vote_days' => 1, 'vote_options' => "甲\n乙"]);
|
||||
forum_check($expired_vote->get_status() === 200, 'limited vote created');
|
||||
$expired_id = (int) ($expired_vote->get_data()['data']['id'] ?? 0);
|
||||
update_post_meta($expired_id, '_jio_forum_vote_deadline', current_time('timestamp') - 10);
|
||||
$late_cast = forum_request('POST', "posts/{$expired_id}/vote", (int) $member->ID, ['option' => 0]);
|
||||
forum_check($late_cast->get_status() === 409, 'expired vote rejects ballot');
|
||||
$locked_vote = \Jio\Forum\Lock::acquire('vote', (int) $admin->ID, $vote_id);
|
||||
forum_check($locked_vote !== '', 'vote lock acquired');
|
||||
$overlap_vote = forum_request('POST', "posts/{$vote_id}/vote", (int) $admin->ID, ['option' => 0]);
|
||||
forum_check($overlap_vote->get_status() === 409 && (int) get_post_meta($vote_id, '_jio_forum_vote_total', true) === 1, 'overlapping vote rejected');
|
||||
\Jio\Forum\Lock::release('vote', (int) $admin->ID, $vote_id, $locked_vote);
|
||||
$object_lock = \Jio\Forum\Lock::acquire('vote-object', 0, $vote_id);
|
||||
forum_check($object_lock !== '', 'vote object lock acquired');
|
||||
$cross_user_vote = forum_request('POST', "posts/{$vote_id}/vote", (int) $admin->ID, ['option' => 0]);
|
||||
forum_check($cross_user_vote->get_status() === 409 && (int) get_post_meta($vote_id, '_jio_forum_vote_total', true) === 1, 'cross-user overlapping vote rejected');
|
||||
\Jio\Forum\Lock::release('vote-object', 0, $vote_id, $object_lock);
|
||||
$pinned = forum_request('POST', "posts/{$vote_id}/pin", (int) $admin->ID);
|
||||
forum_check($pinned->get_status() === 200, 'post pinned');
|
||||
$ordered = new WP_Query(['post_type' => \Jio\Forum\Core::POST, 'post_status' => 'publish', 'posts_per_page' => 1, 'jio_forum_list' => 'latest']);
|
||||
forum_check((int) ($ordered->posts[0]->ID ?? 0) === $vote_id, 'pinned post leads list');
|
||||
$favorite = forum_request('POST', "posts/{$vote_id}/favorite", (int) $member->ID);
|
||||
forum_check($favorite->get_status() === 200 && (int) get_post_meta($vote_id, '_jio_forum_favorite_count', true) === 1, 'favorite count updated');
|
||||
$scored = forum_request('POST', "posts/{$vote_id}/score", (int) $member->ID, ['direction' => 'up']);
|
||||
forum_check($scored->get_status() === 200 && (int) get_post_meta($vote_id, '_jio_forum_score', true) === 1, 'score count updated');
|
||||
|
||||
$apply = forum_request('POST', "plates/{$plate}/apply", (int) $member->ID, ['reason' => '愿意维护版块']);
|
||||
forum_check($apply->get_status() === 200, 'moderator application submitted');
|
||||
$process = forum_request('POST', "plates/{$plate}/applications/{$member->ID}", (int) $admin->ID, ['status' => 'approved']);
|
||||
forum_check($process->get_status() === 200 && \Jio\Forum\Access::is_moderator((int) $member->ID, $plate), 'moderator application approved');
|
||||
|
||||
$member_plate = forum_request('POST', 'plates', (int) $member->ID, ['title' => '用户创建版块']);
|
||||
forum_check($member_plate->get_status() === 200, 'member plate submitted');
|
||||
$member_plate_id = (int) ($member_plate->get_data()['data']['id'] ?? 0);
|
||||
forum_check(get_post_status($member_plate_id) === 'pending', 'member plate awaits review');
|
||||
$member_plate_approved = forum_request('POST', "plates/{$member_plate_id}/audit", (int) $admin->ID);
|
||||
forum_check($member_plate_approved->get_status() === 200, 'member plate approved');
|
||||
$member_plate_post = forum_request('POST', 'posts', (int) $member->ID, ['title' => '用户版块帖子', 'content' => '删除版块后应该一起入回收站', 'plate_id' => $member_plate_id]);
|
||||
forum_check($member_plate_post->get_status() === 200, 'member plate post created');
|
||||
$member_plate_post_id = (int) ($member_plate_post->get_data()['data']['id'] ?? 0);
|
||||
$deleted_plate = forum_request('POST', "plates/{$member_plate_id}/delete", (int) $member->ID, ['posts_action' => 'trash']);
|
||||
forum_check($deleted_plate->get_status() === 200 && get_post_status($member_plate_post_id) === 'trash', 'plate deletion cascades to posts');
|
||||
$restored_plate = forum_request('POST', "plates/{$member_plate_id}/restore", (int) $member->ID);
|
||||
forum_check($restored_plate->get_status() === 200 && get_post_status($member_plate_post_id) === 'publish', 'plate restore recovers posts (' . get_post_status($member_plate_post_id) . ')');
|
||||
|
||||
wp_set_current_user((int) $admin->ID);
|
||||
$pay_test_plate = wp_insert_post(['post_type' => \Jio\Forum\Core::PLATE, 'post_status' => 'publish', 'post_title' => '付费测试版块', 'post_author' => (int) $admin->ID], true);
|
||||
forum_check(!is_wp_error($pay_test_plate) && $pay_test_plate > 0, 'payment test plate created');
|
||||
$pending_post = forum_request('POST', 'posts', (int) $member->ID, ['title' => '待审核恢复测试', 'content' => '恢复后不能绕过审核', 'plate_id' => $pay_test_plate]);
|
||||
$pending_id = (int) ($pending_post->get_data()['data']['id'] ?? 0);
|
||||
forum_check($pending_post->get_status() === 200 && get_post_status($pending_id) === 'pending', 'pending post created');
|
||||
$pending_deleted = forum_request('POST', "posts/{$pending_id}/delete", (int) $member->ID);
|
||||
forum_check($pending_deleted->get_status() === 200 && get_post_status($pending_id) === 'trash', 'pending post trashed');
|
||||
$pending_restored = forum_request('POST', "posts/{$pending_id}/restore", (int) $member->ID);
|
||||
forum_check($pending_restored->get_status() === 200 && get_post_status($pending_id) === 'pending', 'pending status preserved on restore');
|
||||
|
||||
\Jio\Core\DBMigrator::maybe_migrate();
|
||||
$ledger = \Jio\Ledger\LedgerService::get_instance();
|
||||
$credit = $ledger->credit((int) $member->ID, \Jio\Ledger\AssetType::POINTS, 30, \Jio\Ledger\OperationKey::unique('forum-smoke-credit'), 'forum_test_credit');
|
||||
forum_check(!is_wp_error($credit), 'points credited');
|
||||
$before = $ledger->balance((int) $member->ID, \Jio\Ledger\AssetType::POINTS);
|
||||
$locked = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '积分帖子', 'content' => '需要积分查看的正文', 'plate_id' => $pay_test_plate, 'view_limit' => 'points', 'price' => '5']);
|
||||
forum_check($locked->get_status() === 200, 'paid post created');
|
||||
$locked_id = (int) ($locked->get_data()['data']['id'] ?? 0);
|
||||
forum_check(!\Jio\Forum\Access::can_view((int) $member->ID, $locked_id), 'unpaid content locked');
|
||||
$preview = forum_request('GET', "posts/{$locked_id}/checkout", (int) $member->ID);
|
||||
forum_check($preview->get_status() === 200 && (string) ($preview->get_data()['data']['price'] ?? '') === '5', 'points preview');
|
||||
$checkout_key = wp_generate_uuid4();
|
||||
$checkout = forum_request('POST', "posts/{$locked_id}/checkout", (int) $member->ID, ['payment_method' => 'points'], $checkout_key);
|
||||
forum_check($checkout->get_status() === 200, 'points checkout');
|
||||
forum_check(\Jio\Forum\Access::can_view((int) $member->ID, $locked_id), 'paid content unlocked');
|
||||
$after = $ledger->balance((int) $member->ID, \Jio\Ledger\AssetType::POINTS);
|
||||
forum_check($after === $before - 5, 'points debited once');
|
||||
$checkout_repeat = forum_request('POST', "posts/{$locked_id}/checkout", (int) $member->ID, ['payment_method' => 'points'], $checkout_key);
|
||||
forum_check($checkout_repeat->get_status() === 200 && $ledger->balance((int) $member->ID, \Jio\Ledger\AssetType::POINTS) === $after, 'checkout idempotent');
|
||||
|
||||
$paid_plate = forum_request('POST', 'plates', (int) $admin->ID, ['title' => '积分版块', 'follow_mode' => 'points', 'follow_price' => '3', 'view_limit' => 'points']);
|
||||
forum_check($paid_plate->get_status() === 200, 'paid plate created');
|
||||
$paid_plate_id = (int) ($paid_plate->get_data()['data']['id'] ?? 0);
|
||||
$plate_post = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '版块内帖子', 'content' => '付费版块内的内容', 'plate_id' => $paid_plate_id]);
|
||||
forum_check($plate_post->get_status() === 200, 'paid plate post created');
|
||||
$plate_post_id = (int) ($plate_post->get_data()['data']['id'] ?? 0);
|
||||
forum_check(!\Jio\Forum\Access::can_view((int) $member->ID, $plate_post_id), 'paid plate content locked');
|
||||
$nested_post = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '双层解锁帖子', 'content' => '双层付费正文', 'plate_id' => $paid_plate_id, 'view_limit' => 'points', 'price' => '1']);
|
||||
forum_check($nested_post->get_status() === 200, 'nested paid post created');
|
||||
$nested_post_id = (int) ($nested_post->get_data()['data']['id'] ?? 0);
|
||||
$premature_checkout = forum_request('POST', "posts/{$nested_post_id}/checkout", (int) $member->ID, ['payment_method' => 'points'], wp_generate_uuid4());
|
||||
forum_check($premature_checkout->get_status() === 402, 'post checkout requires parent plate access');
|
||||
$denied_follow = forum_request('POST', "plates/{$paid_plate_id}/follow", (int) $member->ID);
|
||||
forum_check($denied_follow->get_status() === 402, 'paid plate follow requires payment');
|
||||
$follow_paid = forum_request('POST', "plates/{$paid_plate_id}/checkout", (int) $member->ID, ['payment_method' => 'points'], wp_generate_uuid4());
|
||||
forum_check($follow_paid->get_status() === 200 && \Jio\Forum\Commerce::has_plate_entitlement((int) $member->ID, $paid_plate_id), 'paid plate entitlement');
|
||||
forum_check(\Jio\Forum\Access::can_view((int) $member->ID, $plate_post_id), 'paid plate content unlocked');
|
||||
$unfollow = forum_request('POST', "plates/{$paid_plate_id}/follow", (int) $member->ID);
|
||||
forum_check($unfollow->get_status() === 200 && !\Jio\Forum\Commerce::has_plate_entitlement((int) $member->ID, $paid_plate_id), 'unfollow revokes entitlement');
|
||||
forum_check(!\Jio\Forum\Access::can_view((int) $member->ID, $plate_post_id), 'unfollow relocks content');
|
||||
$points_before_refollow = $ledger->balance((int) $member->ID, \Jio\Ledger\AssetType::POINTS);
|
||||
$repurchase = forum_request('POST', "plates/{$paid_plate_id}/checkout", (int) $member->ID, ['payment_method' => 'points'], wp_generate_uuid4());
|
||||
forum_check($repurchase->get_status() === 200 && \Jio\Forum\Commerce::has_plate_entitlement((int) $member->ID, $paid_plate_id), 'paid follow can be purchased again');
|
||||
forum_check($ledger->balance((int) $member->ID, \Jio\Ledger\AssetType::POINTS) === $points_before_refollow - 3, 'second follow charged once');
|
||||
|
||||
$cash_credit = $ledger->credit((int) $member->ID, \Jio\Ledger\AssetType::BALANCE, 500, \Jio\Ledger\OperationKey::unique('forum-smoke-balance'), 'forum_test_credit');
|
||||
forum_check(!is_wp_error($cash_credit), 'balance credited');
|
||||
$cash_before = $ledger->balance((int) $member->ID, \Jio\Ledger\AssetType::BALANCE);
|
||||
$cash_post = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '余额帖子', 'content' => '需要余额查看的正文', 'plate_id' => $pay_test_plate, 'view_limit' => 'pay', 'price' => '2.00']);
|
||||
forum_check($cash_post->get_status() === 200, 'cash post created');
|
||||
$cash_post_id = (int) ($cash_post->get_data()['data']['id'] ?? 0);
|
||||
$cash_paid = forum_request('POST', "posts/{$cash_post_id}/checkout", (int) $member->ID, ['payment_method' => 'balance'], wp_generate_uuid4());
|
||||
forum_check($cash_paid->get_status() === 200, 'balance checkout');
|
||||
forum_check(\Jio\Forum\Access::can_view((int) $member->ID, $cash_post_id), 'balance content unlocked');
|
||||
forum_check($ledger->balance((int) $member->ID, \Jio\Ledger\AssetType::BALANCE) === $cash_before - 200, 'balance debited once');
|
||||
|
||||
$lock = \Jio\Forum\Lock::acquire('checkout-forum_post', (int) $member->ID, $vote_id);
|
||||
forum_check($lock !== '' && \Jio\Forum\Lock::acquire('checkout-forum_post', (int) $member->ID, $vote_id) === '', 'checkout lock excludes overlap');
|
||||
\Jio\Forum\Lock::release('checkout-forum_post', (int) $member->ID, $vote_id, $lock);
|
||||
$expensive = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '余额不足测试', 'content' => '订单复用正文', 'plate_id' => $pay_test_plate, 'view_limit' => 'pay', 'price' => '1000.00']);
|
||||
$expensive_id = (int) ($expensive->get_data()['data']['id'] ?? 0);
|
||||
forum_check($expensive->get_status() === 200, 'expensive post created');
|
||||
$first_attempt = forum_request('POST', "posts/{$expensive_id}/checkout", (int) $member->ID, ['payment_method' => 'balance'], wp_generate_uuid4());
|
||||
forum_check($first_attempt->get_status() === 402, 'insufficient balance rejected');
|
||||
$second_attempt = forum_request('POST', "posts/{$expensive_id}/checkout", (int) $member->ID, ['payment_method' => 'balance'], wp_generate_uuid4());
|
||||
forum_check($second_attempt->get_status() === 402, 'retry still insufficient (' . $second_attempt->get_status() . ': ' . wp_json_encode($second_attempt->get_data()) . ')');
|
||||
$pending_orders = array_filter(\Jio\Payment\OrderRepository::get_instance()->get_user_orders((int) $member->ID, ['product_type' => 'forum_post', 'status' => 'pending', 'limit' => 100]), static fn(array $order): bool => (int) $order['product_id'] === $expensive_id);
|
||||
forum_check(count($pending_orders) === 1, 'checkout retry reuses pending order');
|
||||
$too_large = forum_request('POST', 'posts', (int) $admin->ID, ['title' => '无效价格测试', 'content' => '异常金额不应入库', 'plate_id' => $pay_test_plate, 'view_limit' => 'pay', 'price' => '9999999999999999999999999999999.00']);
|
||||
forum_check($too_large->get_status() === 400, 'oversized price rejected before save');
|
||||
update_post_meta($expensive_id, '_jio_forum_price', '9999999999999999999999999999999.00');
|
||||
$invalid_preview = forum_request('GET', "posts/{$expensive_id}/checkout", (int) $member->ID);
|
||||
forum_check($invalid_preview->get_status() === 400, 'invalid stored price cannot become free');
|
||||
update_post_meta($expensive_id, '_jio_forum_price', '1000.00');
|
||||
|
||||
$settings = get_option('jio_settings', []);
|
||||
$settings['jio_forum_enabled'] = false;
|
||||
update_option('jio_settings', $settings);
|
||||
\Jio\Settings\Settings::flush_settings_cache();
|
||||
$disabled = forum_request('POST', 'posts', (int) $member->ID, ['title' => '已关闭论坛', 'content' => '不能发帖', 'plate_id' => $plate]);
|
||||
forum_check($disabled->get_status() === 404, 'disabled forum rejects writes');
|
||||
$settings['jio_forum_enabled'] = true;
|
||||
update_option('jio_settings', $settings);
|
||||
\Jio\Settings\Settings::flush_settings_cache();
|
||||
|
||||
echo "forum smoke passed\n";
|
||||
Reference in new issue
Block a user