diff --git a/assets/admin/src/constants/sections/forum.js b/assets/admin/src/constants/sections/forum.js
new file mode 100644
index 00000000..7baeb5b3
--- /dev/null
+++ b/assets/admin/src/constants/sections/forum.js
@@ -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,
+ })),
+}
diff --git a/assets/admin/src/constants/sections/index.js b/assets/admin/src/constants/sections/index.js
index dbb6ef40..57fc28a3 100644
--- a/assets/admin/src/constants/sections/index.js
+++ b/assets/admin/src/constants/sections/index.js
@@ -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),
diff --git a/assets/admin/src/pages/SchemaPage.vue b/assets/admin/src/pages/SchemaPage.vue
index 0ccd679b..05424695 100644
--- a/assets/admin/src/pages/SchemaPage.vue
+++ b/assets/admin/src/pages/SchemaPage.vue
@@ -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 {}
diff --git a/assets/css/routes/forum.css b/assets/css/routes/forum.css
new file mode 100644
index 00000000..f9396270
--- /dev/null
+++ b/assets/css/routes/forum.css
@@ -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}
diff --git a/assets/js/forum-runtime.js b/assets/js/forum-runtime.js
new file mode 100644
index 00000000..d88c5e4b
--- /dev/null
+++ b/assets/js/forum-runtime.js
@@ -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 = '
'
+ form.querySelector('h2').textContent = title
+ form.querySelector('[type="submit"]').textContent = submitText
+ const body = form.querySelector('.jio-forum__dialog-fields')
+ fields.forEach(({ name, label, type = 'text', options = [], value = '' }) => {
+ const wrapper = document.createElement('label')
+ wrapper.textContent = label
+ const control = type === 'select' ? document.createElement('select') : document.createElement(type === 'textarea' ? 'textarea' : 'input')
+ control.name = name
+ control.required = true
+ if (type === 'select') options.forEach(({ value: optionValue, label: optionLabel }) => {
+ const option = document.createElement('option')
+ option.value = optionValue
+ option.textContent = optionLabel
+ control.append(option)
+ })
+ else if (type === 'text') control.type = 'text'
+ control.value = value
+ wrapper.append(control)
+ body.append(wrapper)
+ })
+ dialog.append(form)
+ root.append(dialog)
+ const close = (value) => { dialog.close(); dialog.remove(); resolve(value) }
+ form.querySelector('[data-cancel]').addEventListener('click', () => close(null))
+ dialog.addEventListener('cancel', (event) => { event.preventDefault(); close(null) })
+ form.addEventListener('submit', (event) => { event.preventDefault(); close(Object.fromEntries(new FormData(form))) })
+ dialog.showModal()
+ })
+
+ const checkout = async (kind, id) => {
+ const preview = (await send(`${kind}s/${id}/checkout`)).data
+ const methods = (preview.methods || []).map((value) => ({ value, label: {
+ points: '积分', balance: '站内余额', alipay: '支付宝', wechatpay: '微信支付', paypal: 'PayPal', stripe: '银行卡',
+ }[value] || value }))
+ const data = await modal(`解锁 ${preview.title} · ${preview.price}${preview.mode === 'points' ? '积分' : '元'}`, [
+ { name: 'payment_method', label: '支付方式', type: 'select', options: methods },
+ ], '确认支付')
+ if (!data) return
+ const key = crypto.randomUUID()
+ const result = (await send(`${kind}s/${id}/checkout`, 'POST', data, key)).data
+ if (result.mode === 'redirect' && result.payment_url) window.location.href = result.payment_url
+ else if (result.mode === 'form' && result.form_html) {
+ const wrapper = document.createElement('div')
+ wrapper.innerHTML = result.form_html
+ document.body.append(wrapper)
+ wrapper.querySelector('form')?.submit()
+ } else if (result.mode === 'qrcode' && result.code_url && window.QRCode) {
+ const dialog = document.createElement('dialog')
+ dialog.className = 'jio-forum__dialog'
+ dialog.innerHTML = '扫码支付
支付完成后刷新页面
'
+ root.append(dialog)
+ new window.QRCode(dialog.querySelector('.jio-forum__qr'), { text: result.code_url, width: 180, height: 180 })
+ dialog.querySelector('button').addEventListener('click', () => window.location.reload())
+ dialog.showModal()
+ } else if (result.mode) window.location.href = config.orders
+ else window.location.reload()
+ }
+
+ const typeSelect = root.querySelector('select[name="type"]')
+ const syncType = () => {
+ const vote = root.querySelector('[data-forum-type="vote"]')
+ if (vote) vote.hidden = typeSelect?.value !== 'vote'
+ }
+ typeSelect?.addEventListener('change', syncType)
+ syncType()
+
+ const 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
+ }
+ })
+})()
diff --git a/functions.php b/functions.php
index 5246ba66..d9148c39 100644
--- a/functions.php
+++ b/functions.php
@@ -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';
diff --git a/inc/helpers/template-tags/search-meta.php b/inc/helpers/template-tags/search-meta.php
index f2fc487f..45b76a3d 100644
--- a/inc/helpers/template-tags/search-meta.php
+++ b/inc/helpers/template-tags/search-meta.php
@@ -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'];
diff --git a/inc/modules/user-notifications.php b/inc/modules/user-notifications.php
index 0673da1a..5410239f 100644
--- a/inc/modules/user-notifications.php
+++ b/inc/modules/user-notifications.php
@@ -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);
diff --git a/inc/modules/user-parts/template-registry.php b/inc/modules/user-parts/template-registry.php
index 6fc7669a..184eabd8 100644
--- a/inc/modules/user-parts/template-registry.php
+++ b/inc/modules/user-parts/template-registry.php
@@ -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 */
diff --git a/search.php b/search.php
index b4917dd5..90cc2f30 100644
--- a/search.php
+++ b/search.php
@@ -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
}
?>
+ have_posts()) : ?>
+
+ have_posts()) : $wp_query->the_post(); \Jio\Forum\View::post_card(get_post()); endwhile; wp_reset_postdata(); ?>
+
+
+ have_posts()) : ?>
+
+ have_posts()) : $wp_query->the_post(); \Jio\Forum\View::plate_card(get_post()); endwhile; wp_reset_postdata(); ?>
+
+
have_posts()) : ?>
'search', 'highlight_query' => $search_query, 'no_margin' => false]) : ['context' => 'search', 'mode' => 'list'];
diff --git a/src/Admin/Schema/SchemaForum.php b/src/Admin/Schema/SchemaForum.php
new file mode 100644
index 00000000..f687ffb4
--- /dev/null
+++ b/src/Admin/Schema/SchemaForum.php
@@ -0,0 +1,79 @@
+ '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;
+ }
+}
diff --git a/src/Admin/SchemaProvider.php b/src/Admin/SchemaProvider.php
index 263ed449..c3f74099 100644
--- a/src/Admin/SchemaProvider.php
+++ b/src/Admin/SchemaProvider.php
@@ -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,
];
}
diff --git a/src/Core/DomainModel.php b/src/Core/DomainModel.php
index 1fa017bc..31401aee 100644
--- a/src/Core/DomainModel.php
+++ b/src/Core/DomainModel.php
@@ -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'),
];
}
diff --git a/src/Forum/Access.php b/src/Forum/Access.php
new file mode 100644
index 00000000..0639a838
--- /dev/null
+++ b/src/Forum/Access.php
@@ -0,0 +1,104 @@
+ '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;
+ }
+}
diff --git a/src/Forum/Commerce.php b/src/Forum/Commerce.php
new file mode 100644
index 00000000..569f909f
--- /dev/null
+++ b/src/Forum/Commerce.php
@@ -0,0 +1,47 @@
+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);
+ }
+ }
+}
diff --git a/src/Forum/Core.php b/src/Forum/Core.php
new file mode 100644
index 00000000..4bf9fd89
--- /dev/null
+++ b/src/Forum/Core.php
@@ -0,0 +1,208 @@
+ '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');
+ }
+}
diff --git a/src/Forum/Integration.php b/src/Forum/Integration.php
new file mode 100644
index 00000000..8a1fffd1
--- /dev/null
+++ b/src/Forum/Integration.php
@@ -0,0 +1,175 @@
+ '论坛', '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) ? '内容已隐藏
' : $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]);
+ }
+ }
+
+}
diff --git a/src/Forum/Lock.php b/src/Forum/Lock.php
new file mode 100644
index 00000000..e547e8ae
--- /dev/null
+++ b/src/Forum/Lock.php
@@ -0,0 +1,29 @@
+ $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);
+ }
+}
diff --git a/src/Forum/Rest.php b/src/Forum/Rest.php
new file mode 100644
index 00000000..ab950fc7
--- /dev/null
+++ b/src/Forum/Rest.php
@@ -0,0 +1,127 @@
+\d+)', 'PATCH', 'update_post'],
+ ['/forum/plates', 'POST', 'create_plate'],
+ ['/forum/plates/(?P\d+)', 'PATCH', 'update_plate'],
+ ['/forum/plates/(?P\d+)/follow', 'POST', 'follow_plate'],
+ ['/forum/plates/(?P\d+)/audit', 'POST', 'audit_plate'],
+ ['/forum/plates/(?P\d+)/delete', 'POST', 'delete_plate'],
+ ['/forum/plates/(?P\d+)/restore', 'POST', 'restore_plate'],
+ ['/forum/posts/(?P\d+)/score', 'POST', 'score'],
+ ['/forum/posts/(?P\d+)/favorite', 'POST', 'favorite'],
+ ['/forum/posts/(?P\d+)/vote', 'POST', 'vote'],
+ ['/forum/posts/(?P\d+)/adopt', 'POST', 'adopt'],
+ ['/forum/posts/(?P\d+)/delete', 'POST', 'delete_post'],
+ ['/forum/posts/(?P\d+)/restore', 'POST', 'restore_post'],
+ ['/forum/posts/(?P\d+)/audit', 'POST', 'audit_post'],
+ ['/forum/posts/(?P\d+)/pin', 'POST', 'pin_post'],
+ ['/forum/posts/(?P\d+)/essence', 'POST', 'essence_post'],
+ ['/forum/posts/(?P\d+)/move', 'POST', 'move_post'],
+ ['/forum/posts/(?P\d+)/comments', 'POST', 'comments_status'],
+ ['/forum/posts/(?P\d+)/report', 'POST', 'report_post'],
+ ['/forum/posts/(?P\d+)/replies', 'POST', 'reply'],
+ ['/forum/replies/(?P\d+)/like', 'POST', 'like_reply'],
+ ['/forum/replies/(?P\d+)/hot', 'POST', 'hot_reply'],
+ ['/forum/replies/(?P\d+)/audit', 'POST', 'audit_reply'],
+ ['/forum/replies/(?P\d+)/delete', 'POST', 'delete_reply'],
+ ['/forum/replies/(?P\d+)/restore', 'POST', 'restore_reply'],
+ ['/forum/terms', 'POST', 'create_term'],
+ ['/forum/terms/(?P\d+)', 'PATCH', 'update_term'],
+ ['/forum/terms/(?P\d+)', 'DELETE', 'delete_term'],
+ ['/forum/plates/(?P\d+)/moderators', 'POST', 'moderator'],
+ ['/forum/plates/(?P\d+)/apply', 'POST', 'apply_moderator'],
+ ['/forum/plates/(?P\d+)/applications/(?P\d+)', 'POST', 'process_application'],
+ ['/forum/posts/(?P\d+)/checkout', 'POST', 'checkout_post'],
+ ['/forum/posts/(?P\d+)/checkout', 'GET', 'preview_post'],
+ ['/forum/plates/(?P\d+)/checkout', 'POST', 'checkout_plate'],
+ ['/forum/plates/(?P\d+)/checkout', 'GET', 'preview_plate'],
+ ['/forum/upload/(?Pimage|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);
+ }
+ }
+}
diff --git a/src/Forum/RestActions.php b/src/Forum/RestActions.php
new file mode 100644
index 00000000..4f30a073
--- /dev/null
+++ b/src/Forum/RestActions.php
@@ -0,0 +1,229 @@
+ 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('举报提交失败');
+ }
+}
diff --git a/src/Forum/RestCommerce.php b/src/Forum/RestCommerce.php
new file mode 100644
index 00000000..c99813e1
--- /dev/null
+++ b/src/Forum/RestCommerce.php
@@ -0,0 +1,201 @@
+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;
+ }
+}
diff --git a/src/Forum/RestMedia.php b/src/Forum/RestMedia.php
new file mode 100644
index 00000000..61d09d23
--- /dev/null
+++ b/src/Forum/RestMedia.php
@@ -0,0 +1,26 @@
+ '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'] ?? ''))], '上传成功');
+ }
+}
diff --git a/src/Forum/RestPlates.php b/src/Forum/RestPlates.php
new file mode 100644
index 00000000..eb820c9f
--- /dev/null
+++ b/src/Forum/RestPlates.php
@@ -0,0 +1,174 @@
+ $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], '申请已处理');
+ }
+}
diff --git a/src/Forum/RestPosts.php b/src/Forum/RestPosts.php
new file mode 100644
index 00000000..9ee1e6e9
--- /dev/null
+++ b/src/Forum/RestPosts.php
@@ -0,0 +1,251 @@
+ 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('付费价格超出范围');
+ }
+ }
+}
diff --git a/src/Forum/RestReplies.php b/src/Forum/RestReplies.php
new file mode 100644
index 00000000..a52c446d
--- /dev/null
+++ b/src/Forum/RestReplies.php
@@ -0,0 +1,132 @@
+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');
+ }
+ }
+}
diff --git a/src/Forum/View.php b/src/Forum/View.php
new file mode 100644
index 00000000..ca6a71c7
--- /dev/null
+++ b/src/Forum/View.php
@@ -0,0 +1,387 @@
+';
+ echo '
';
+ }
+
+ public static function close(): void {
+ echo '
';
+ get_footer();
+ }
+
+ public static function sidebar(string $context): void {
+ echo '';
+ }
+
+ public static function home(): void {
+ self::open('论坛', 'home');
+ \Jio\WidgetSystem\WidgetAreas::render('forum_home_top_fluid');
+ echo '';
+ $name = (string) jio_get_setting('jio_forum_forum_name', '论坛');
+ echo '
';
+ 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 '
待审核版块
';
+ foreach ($pending as $plate) {
+ echo '' . esc_html($plate->post_title) . '
';
+ }
+ echo '';
+ }
+ self::term_management();
+ }
+ self::tabs();
+ $categories = get_terms(['taxonomy' => Core::CATEGORY, 'hide_empty' => false]);
+ if (is_array($categories) && $categories) {
+ echo '
';
+ }
+ self::posts(['post_type' => Core::POST, 'post_status' => 'publish'], 'home');
+ echo '
';
+ self::sidebar('home');
+ echo '
';
+ \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 '';
+ echo '
' . esc_html($title) . '
';
+ if ($plate) {
+ echo '' . wp_kses_post(wpautop($plate->post_excerpt ?: $plate->post_content)) . '
';
+ echo '' . (int) get_post_meta($plate->ID, '_jio_forum_follow_count', true) . ' 人关注
';
+ 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 '' . esc_html($term->description) . '
';
+ }
+ echo '';
+ 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 '
待审核帖子
';
+ $args['post_status'] = 'pending';
+ self::posts($args, $context);
+ }
+ }
+ echo '
';
+ self::sidebar($context);
+ echo '
';
+ \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 '';
+ }
+
+ 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 '';
+ if (!$query->have_posts()) {
+ echo '
暂无帖子
';
+ }
+ while ($query->have_posts()) {
+ $query->the_post();
+ self::post_card(get_post());
+ }
+ wp_reset_postdata();
+ echo '
';
+ 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 '';
+ while ($query->have_posts()) {
+ $query->the_post();
+ $plate = get_post();
+ self::plate_card($plate);
+ }
+ wp_reset_postdata();
+ echo '
';
+ }
+
+ public static function plate_card(\WP_Post $plate): void {
+ $url = get_permalink($plate);
+ echo '';
+ if (jio_get_setting('jio_forum_show_plate_cover', true) && has_post_thumbnail($plate)) {
+ echo get_the_post_thumbnail($plate, 'jio-card');
+ }
+ echo '' . esc_html(wp_trim_words(wp_strip_all_tags($plate->post_excerpt ?: $plate->post_content), 22)) . '
' . (int) get_post_meta($plate->ID, '_jio_forum_follow_count', true) . ' 人关注 ';
+ }
+
+ 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 '' . get_avatar((int) $post->post_author, 44) . '';
+ echo '
' . esc_html(get_the_author_meta('display_name', (int) $post->post_author)) . '
';
+ $url = $post->post_status === 'publish' ? get_permalink($post) : Core::edit_url($post->ID);
+ echo '
';
+ $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 '
' . esc_html($excerpt) . '
';
+ echo '
';
+ if (get_post_meta($post->ID, '_jio_forum_pinned', true)) {
+ echo '
置顶';
+ }
+ if ($type !== '') {
+ echo '
' . esc_html($type === 'question' ? '问答' : '投票') . '';
+ }
+ if ($type === 'question' && get_post_meta($post->ID, '_jio_forum_adopted_comment', true)) {
+ echo '
已解决';
+ }
+ if (get_post_meta($post->ID, '_jio_forum_essence', true)) {
+ echo '
精华';
+ }
+ if ($plate_id > 0) {
+ echo '
' . esc_html(get_the_title($plate_id)) . '';
+ }
+ echo '
' . (int) get_post_meta($post->ID, '_jio_forum_views', true) . ' 阅读';
+ echo '
' . (int) $post->comment_count . ' 回复';
+ echo '
' . (int) get_post_meta($post->ID, '_jio_forum_score', true) . ' 评分';
+ echo '
' . (int) get_post_meta($post->ID, '_jio_forum_favorite_count', true) . ' 收藏 ';
+ $hot = get_comments(['post_id' => $post->ID, 'status' => 'approve', 'number' => 1, 'meta_key' => '_jio_forum_hot', 'meta_value' => '1']);
+ if ($hot) {
+ echo '热门回复:' . esc_html(wp_trim_words(wp_strip_all_tags($hot[0]->comment_content), 25)) . '
';
+ }
+ }
+
+ 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 '';
+ }
+
+ private static function term_management(): void {
+ echo '';
+ }
+
+ private static function plate_editor(\WP_Post $plate): void {
+ $id = $plate->ID;
+ echo '';
+ echo '';
+ echo '';
+ }
+
+ private static function applications(int $plate_id): void {
+ $users = get_users(['meta_key' => '_jio_forum_application_' . $plate_id, 'meta_value' => 'pending', 'number' => 20]);
+ echo '版主管理
';
+ $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 '' . esc_html($user->display_name) . '
';
+ }
+ echo '';
+ if ($users) echo '待处理申请
';
+ foreach ($users as $user) {
+ echo '' . esc_html($user->display_name) . '
';
+ }
+ echo '';
+ }
+
+ public static function pagination(\WP_Query $query): void {
+ if ($query->max_num_pages <= 1) {
+ return;
+ }
+ echo '';
+ }
+
+ public static function action(string $action, int $id, string $label): void {
+ echo '';
+ }
+
+ 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 '';
+ if ($owner) echo '
发布帖子
';
+ $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 '
';
+ if (!$query->have_posts()) echo '
暂无内容
';
+ 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 '
';
+ if ($owner && !$url) self::plate_editor($post);
+ }
+ }
+ wp_reset_postdata();
+ echo '
';
+ }
+}
diff --git a/src/Forum/Widget.php b/src/Forum/Widget.php
new file mode 100644
index 00000000..17b9b925
--- /dev/null
+++ b/src/Forum/Widget.php
@@ -0,0 +1,58 @@
+ '显示论坛帖子、版块或话题']);
+ }
+
+ 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'] ?? '') . esc_html($title) . ($args['after_title'] ?? '
');
+ 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 '' . esc_html($term->name) . '' . (int) $term->count . '';
+ }
+ }
+ } 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 '' . esc_html($post->post_title) . '';
+ }
+ }
+ echo $args['after_widget'] ?? '';
+ }
+
+ public function form($instance): void {
+ $title = (string) ($instance['title'] ?? '论坛动态');
+ $type = (string) ($instance['type'] ?? 'posts');
+ $count = (int) ($instance['count'] ?? 6);
+ echo '';
+ echo '';
+ echo '';
+ }
+
+ 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))),
+ ];
+ }
+}
diff --git a/src/User/Home.php b/src/User/Home.php
index 4d658b05..c4e61c71 100644
--- a/src/User/Home.php
+++ b/src/User/Home.php
@@ -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) {
diff --git a/src/WidgetSystem/WidgetAreas.php b/src/WidgetSystem/WidgetAreas.php
index 8496db00..28e69a81 100644
--- a/src/WidgetSystem/WidgetAreas.php
+++ b/src/WidgetSystem/WidgetAreas.php
@@ -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;
}
diff --git a/templates/pages/page-forum-edit.php b/templates/pages/page-forum-edit.php
new file mode 100644
index 00000000..67e30329
--- /dev/null
+++ b/templates/pages/page-forum-edit.php
@@ -0,0 +1,57 @@
+ 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');
+?>
+
+
+
+
+
diff --git a/templates/pages/page-forum-list.php b/templates/pages/page-forum-list.php
new file mode 100644
index 00000000..01ca8ad5
--- /dev/null
+++ b/templates/pages/page-forum-list.php
@@ -0,0 +1,3 @@
+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');
+?>
+
+
+
+
+ post_content); ?>
+
+ 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) !== '';
+ ?>
+
+
+
+ 内容已隐藏
请完成访问条件后查看。
+ 0) : ?>
+ 关注版块';
+ } elseif (in_array($post_rule, ['points', 'pay'], true)) {
+ View::action('unlock', $id, '解锁帖子');
+ }
+ ?>
+ 登录查看
+
+
+
+
+
+
+
+
编辑
+ comment_status === 'open' ? '关闭回复' : '开放回复'); View::action('move', $id, '移动版块'); endif; ?>
+
+
+
+
已采纳回复 #
+
+
+
+
diff --git a/templates/pages/page-forums.php b/templates/pages/page-forums.php
new file mode 100644
index 00000000..e941872f
--- /dev/null
+++ b/templates/pages/page-forums.php
@@ -0,0 +1,12 @@
+ 0) {
+ \Jio\Forum\View::user_panel($user_id, $user_id === get_current_user_id());
+}
diff --git a/tests/forum/browser-smoke.cjs b/tests/forum/browser-smoke.cjs
new file mode 100644
index 00000000..f7baa48c
--- /dev/null
+++ b/tests/forum/browser-smoke.cjs
@@ -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('浏览器表单正文
');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
+})
diff --git a/tests/forum/docker-compose.yml b/tests/forum/docker-compose.yml
new file mode 100644
index 00000000..017d7947
--- /dev/null
+++ b/tests/forum/docker-compose.yml
@@ -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:
diff --git a/tests/forum/forum-contract.test.cjs b/tests/forum/forum-contract.test.cjs
new file mode 100644
index 00000000..2270cc64
--- /dev/null
+++ b/tests/forum/forum-contract.test.cjs
@@ -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\(/)
+})
diff --git a/tests/forum/smoke.php b/tests/forum/smoke.php
new file mode 100644
index 00000000..07078251
--- /dev/null
+++ b/tests/forum/smoke.php
@@ -0,0 +1,275 @@
+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";