feat(creator-studio): 前台 Gutenberg 创作工作台与受保护付费附件
前台投稿页改为沉浸式创作工作台:Gutenberg 负责区块解析与序列化, React + TypeScript 负责工作台外壳与发布抽屉,Jio 服务端继续作为权限、 审核与付费的唯一权威。 - 新增 assets/creator-studio 独立构建(wp-scripts,锁定 WP 6.4 时代包), 仅在投稿模板加载,构建缺失或游客访问时回退原有 TinyMCE 表单 - 自动保存(4s 防抖 + rev 冲突 409)、本地快照恢复、发布配置抽屉、 移动端全屏单列与安全区适配 - 图片插入走现有 front-submit 上传管线,保留能力校验与待认领生命周期 - 已发布文章的改稿写入私有版本,审核通过前线上内容/链接/付费条款不变; 原生后台「改稿审核」一次性应用或退回,并校验源文章并发指纹 - 私有附件新表 jio_private_asset + jio_dl_token.asset_id,schema 升到 3.4.0; 文件存放 ABSPATH 之外,未配置私有存储时 fail closed - 下载令牌绑定文章/附件/用户/IP/UA/次数,每次交付复核 Visibility 的 订单、VIP 与配额判定;中断传输归还次数 - 付费字段改为写入前校验,非法价格不再留下半保存文章 - 新增 creator-studio 契约测试;更新受影响的 schema/下载/投稿契约 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7a8279f8e7
commit
fac4a714e0
45 files changed
+28495
-56
No files matched your search
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
build/*.map
|
||||
.DS_Store
|
||||
@@ -0,0 +1,56 @@
|
||||
# Jio Creator Studio(前台沉浸式创作工作台)
|
||||
|
||||
面向作者的前台写作体验:Gutenberg 作为内容内核,React + TypeScript 负责工作台与发布抽屉,Jio 服务端继续作为权限、审核、付费与附件交付的唯一权威。
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
cd assets/creator-studio
|
||||
npm install # 首次安装(packages 已锁定 WordPress 6.4 时代的版本)
|
||||
npm run build # 产物写入 assets/creator-studio/build/
|
||||
```
|
||||
|
||||
PHP 读取 `build/index.asset.php`(依赖清单 + 版本)并只在投稿模板加载,构建产物缺失时自动回退到原有 TinyMCE 投稿表单。
|
||||
|
||||
## 使用前提
|
||||
|
||||
- 已登录且具备 `new_post_add`(编辑已有文章需作者本人或审核权限)。
|
||||
- 私有附件(付费资料)需要在 `wp-config.php` 里配置私有目录:
|
||||
|
||||
```php
|
||||
define('JIO_PRIVATE_STORAGE_DIR', '/var/private/jio-assets'); // ABSPATH 之外
|
||||
```
|
||||
|
||||
若目录位于站点根目录内,还必须在 Web 服务器层禁止访问,并显式声明:
|
||||
|
||||
```php
|
||||
define('JIO_PRIVATE_STORAGE_VERIFIED', true);
|
||||
```
|
||||
|
||||
未配置时付费资料上传/下载接口返回 501(fail closed),普通图片与视频上传不受影响。
|
||||
|
||||
## 关键行为
|
||||
|
||||
| 场景 | 行为 |
|
||||
| --- | --- |
|
||||
| 新文章草稿/投稿 | 复用 `FrontSubmit::persist()`;草稿为 `draft`,投稿按 `new_post_audit_no` 决定 `publish` 或 `pending` |
|
||||
| 编辑已发布文章 | 写入私有改稿版本(`jio_submission`),线上文章与永久链接保持不变 |
|
||||
| 审核通过 | 后台「文章 → 改稿审核」一次性应用内容、摘要、分类、标签、封面与付费设置 |
|
||||
| 自动保存 | 云端 4 秒防抖 + `rev` 版本号;冲突返回 409,不覆盖较新内容 |
|
||||
| 本地恢复 | 仅保存标题与序列化正文,恢复前询问用户 |
|
||||
| 付费附件 | 上传到私有存储,只保存随机 `asset_key`;下载先取短期令牌,服务端每次复核订单/VIP/配额 |
|
||||
|
||||
## 相关代码
|
||||
|
||||
- 服务端:`src/Core/CreatorRest.php`、`CreatorStudio.php`、`SubmissionVersion.php`、`PreviewToken.php`
|
||||
- 安全交付:`src/Content/PrivateAssets.php`、`src/Content/ProtectedDownload.php`
|
||||
- 原生审核:`src/Admin/Native/SubmissionReview.php`
|
||||
- 前端:`assets/creator-studio/src/`(`index.tsx` / `panels/` / `hooks/` / `services/` / `styles/`)
|
||||
- 契约测试:`tests/widget-system/creator-studio-contract.test.cjs`
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
node --test tests/widget-system/*.test.cjs
|
||||
php tools/audit-psr4-paths.php
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
<?php return array('dependencies' => array('react', 'wp-block-editor', 'wp-block-library', 'wp-blocks', 'wp-data', 'wp-element', 'wp-i18n', 'wp-wordcount'), 'version' => '9f8085a073c5e67a84ed');
|
||||
@@ -0,0 +1 @@
|
||||
.jio-page-submit{background:#fafbfa}.jio-page-submit .float-btn,.jio-page-submit .footer,.jio-page-submit .footer-tabbar,.jio-page-submit .header,.jio-page-submit .jio-float-buttons{display:none!important}.jio-page-submit #main-content.container{max-width:none;padding:0}#jio-creator-studio.is-ready{display:block}.jio-creator{--jc-ink:#243330;--jc-ink-soft:#5c6b66;--jc-paper:#fafbfa;--jc-line:#e2e9e5;--jc-accent:#267665;--jc-accent-strong:#1d5f52;--jc-warn:#b4573f;--jc-bar:60px;-webkit-font-smoothing:antialiased;background:var(--jc-paper);color:var(--jc-ink);font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Noto Sans SC,PingFang SC,Microsoft YaHei,sans-serif;min-height:100vh;min-height:100dvh}.jio-creator *,.jio-creator :after,.jio-creator :before{box-sizing:border-box}.jio-creator button{color:inherit;font:inherit}.jio-creator__bar{align-items:center;backdrop-filter:blur(10px);background:rgba(250,251,250,.92);border-bottom:1px solid var(--jc-line);display:flex;gap:16px;height:var(--jc-bar);padding:0 clamp(16px,4vw,40px);position:sticky;top:0;z-index:30}.jio-creator__back{align-items:center;color:var(--jc-ink-soft);display:inline-flex;gap:6px;text-decoration:none}.jio-creator__back:focus-visible,.jio-creator__back:hover{color:var(--jc-accent)}.jio-creator__brand{color:var(--jc-ink-soft);font-size:13px;letter-spacing:.06em;margin-right:auto}.jio-creator__actions{align-items:center;display:flex;gap:10px}.jio-creator__save{align-items:center;color:var(--jc-ink-soft);display:inline-flex;font-size:13px;gap:6px}.jio-creator__save>i{color:var(--jc-line);font-size:7px}.jio-creator__save time{font-variant-numeric:tabular-nums;opacity:.7}.jio-creator__save.is-saved>i{color:var(--jc-accent)}.jio-creator__save.is-conflict,.jio-creator__save.is-conflict>i,.jio-creator__save.is-error,.jio-creator__save.is-error>i{color:var(--jc-warn)}.jio-creator__btn,.jio-creator__ghost,.jio-creator__primary{align-items:center;border:1px solid transparent;border-radius:8px;cursor:pointer;display:inline-flex;font-size:14px;justify-content:center;min-height:36px;padding:0 16px;transition:background .18s ease,border-color .18s ease,color .18s ease}.jio-creator__ghost{background:transparent;border-color:var(--jc-line)}.jio-creator__ghost:focus-visible,.jio-creator__ghost:hover{border-color:var(--jc-accent);color:var(--jc-accent)}.jio-creator__primary{background:var(--jc-accent);color:#fff}.jio-creator__primary:focus-visible,.jio-creator__primary:hover{background:var(--jc-accent-strong)}.jio-creator__primary:disabled{cursor:not-allowed;opacity:.5}.jio-creator__notice{align-items:center;background:#f1f5f2;border-bottom:1px solid var(--jc-line);color:var(--jc-ink-soft);display:flex;font-size:13px;gap:12px;margin:0 auto;max-width:820px;padding:10px 16px}.jio-creator__notice>span{flex:1}.jio-creator__notice button{background:transparent;border:0;color:var(--jc-accent);cursor:pointer}.jio-creator__notice.is-error{background:#fdf2ee;color:var(--jc-warn)}.jio-creator__hint{color:var(--jc-ink-soft);font-size:13px;margin:18px auto 0;max-width:820px;padding:0 clamp(16px,4vw,40px)}.jio-creator__canvas{margin:0 auto;padding:clamp(24px,5vw,56px) clamp(16px,4vw,40px) 120px;width:min(820px,100%)}.jio-creator__title{display:block;margin-bottom:8px}.jio-creator__title textarea{background:transparent;border:0;color:var(--jc-ink);font-family:Songti SC,"Noto Serif SC","Source Han Serif SC",serif;font-size:clamp(26px,3.4vw,34px);font-weight:600;line-height:1.35;min-height:56px;padding:0;resize:none;width:100%}.jio-creator__title textarea:focus{outline:0}.jio-creator__title textarea::-moz-placeholder{color:#b7c2bd}.jio-creator__title textarea::placeholder{color:#b7c2bd}.jio-creator__excerpt{display:block;margin-bottom:28px}.jio-creator__excerpt>span{color:var(--jc-ink-soft);display:block;font-size:12px;margin-bottom:6px}.jio-creator__excerpt textarea{background:transparent;border:0;border-bottom:1px solid var(--jc-line);color:var(--jc-ink-soft);font-family:inherit;font-size:15px;line-height:1.7;padding:8px 0;resize:none;width:100%}.jio-creator__excerpt textarea:focus{border-color:var(--jc-accent);outline:0}.jio-creator__editor{position:relative}.jio-creator__editor .block-editor-writing-flow{font-size:17px;line-height:1.85}.jio-creator__editor .block-editor-default-block-appender textarea.block-editor-default-block-appender__content{color:#b7c2bd}.jio-creator__editor .block-editor-block-list__layout{padding-bottom:80px}.jio-creator__editor .wp-block{max-width:none}.jio-creator__inspector{margin-top:24px}.jio-creator__inspector .block-editor-block-inspector{border-top:1px solid var(--jc-line);padding-top:16px}.jio-creator__inspector .block-editor-block-inspector:empty{display:none}.jio-creator__inspector .components-panel__body{border:0}.jio-creator__footer{border-top:1px solid var(--jc-line);color:var(--jc-ink-soft);display:flex;font-size:12px;justify-content:space-between;padding-top:20px}.jio-creator-drawer{display:flex;inset:0;justify-content:flex-end;posiLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
(()=>{"use strict";const e=window.React,t=window.wp.element,a=window.wp.blocks,r=window.wp.blockLibrary,n=window.wp.data,o=window.wp.blockEditor,i=window.wp.wordcount,l=window.wp.i18n;function c(e,t){const a=e&&(e.message||e.error)||(401===t?"登录状态已失效,请刷新页面重新登录。":"请求失败,请稍后重试。"),r=new Error(String(a));return r.status=t,r.code=e&&e.code?String(e.code):"",r.data=e,r}async function s(e){try{return await e.json()}catch(e){return null}}async function m(e,t,a){const r=await fetch(e,{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json","X-WP-Nonce":a},body:JSON.stringify(t)}),n=await s(r);if(!r.ok)throw c(n,r.status);return n}async function u(e,t,a,r={}){const n=new FormData;n.append("file",t),Object.entries(r).forEach(([e,t])=>n.append(e,t));const o=await fetch(e,{method:"POST",credentials:"same-origin",headers:{"X-WP-Nonce":a},body:n}),i=await s(o);if(!o.ok)throw c(i,o.status);return i}const d=window.jioCreatorStudio||{},p=d.nonce||"";function _(a){const{draft:r,setDraft:n,capabilities:o,limits:i,taxonomy:c,isPublishedEdit:s,onClose:m,onSaveDraft:_,onSubmit:y,privateStorage:f,privateUploads:b,saving:E,captcha:g}=a,[j,h]=(0,t.useState)(""),[w,v]=(0,t.useState)(""),[N,k]=(0,t.useState)(!1),S=(0,t.useRef)(null),C=(0,t.useRef)(null),x=(0,t.useRef)(null);(0,t.useEffect)(()=>{const e=x.current,t=document.getElementById("jio-creator-captcha");if(!e||!t)return;const a=t.parentElement;return e.appendChild(t),t.removeAttribute("hidden"),()=>{a&&(a.appendChild(t),t.setAttribute("hidden",""))}},[]);const I=(0,t.useMemo)(()=>(r.title||"").trim().length,[r.title])>=Number(i.titleMin||1),L=()=>{const e=j.trim();if(!e)return;const t=e.split(/[,,\n]+/u).map(e=>e.trim()).filter(Boolean);n({tags:Array.from(new Set([...r.tags,...t]))}),h("")};return(0,e.createElement)("div",{className:"jio-creator-drawer",role:"dialog","aria-modal":"true","aria-labelledby":"jio-creator-publish-title"},(0,e.createElement)("button",{type:"button",className:"jio-creator-drawer__backdrop","aria-label":(0,l.__)("关闭","jio"),onClick:m}),(0,e.createElement)("div",{className:"jio-creator-drawer__panel"},(0,e.createElement)("header",{className:"jio-creator-drawer__head"},(0,e.createElement)("h2",{id:"jio-creator-publish-title"},s?(0,l.__)("提交改稿","jio"):(0,l.__)("发布文章","jio")),(0,e.createElement)("button",{type:"button",className:"jio-creator-drawer__close",onClick:m,"aria-label":(0,l.__)("关闭","jio")},(0,e.createElement)("i",{className:"fa fas fa-times","aria-hidden":"true"}))),(0,e.createElement)("div",{className:"jio-creator-drawer__body"},s&&(0,e.createElement)("p",{className:"jio-creator-drawer__note"},(0,l.__)("审核通过前,读者看到的仍然是当前线上版本。","jio")),(0,e.createElement)("section",{className:"jio-creator-field"},(0,e.createElement)("h3",null,(0,l.__)("封面","jio")),r.featured?.data?.url?(0,e.createElement)("div",{className:"jio-creator-cover"},(0,e.createElement)("img",{src:String(r.featured.data.url),alt:""}),(0,e.createElement)("button",{type:"button",onClick:()=>n({featured:{type:"",data:{}}}),"aria-label":(0,l.__)("移除封面","jio")},(0,e.createElement)("i",{className:"fa fas fa-times","aria-hidden":"true"}))):o.canUploadImage?(0,e.createElement)("label",{className:"jio-creator-cover-empty"},(0,e.createElement)("input",{ref:S,type:"file",accept:"image/*",onChange:()=>{(async()=>{const e=S.current?.files?.[0];if(e&&d.media?.uploadUrl?.image){v(""),k(!0);try{const t=await u(d.media.uploadUrl.image,e,p),a=Number(t.attachment_id||t.id||0),r=String(t.url||"");if(!a||!r)throw new Error((0,l.__)("上传后无法读取图片。","jio"));n({featured:{type:"image",mediaId:a,data:{id:a,url:r}}})}catch(e){v(String(e?.message||(0,l.__)("上传失败","jio")))}finally{k(!1)}}})()},disabled:N}),(0,e.createElement)("i",{className:"fa fas fa-plus","aria-hidden":"true"}),(0,e.createElement)("span",null,N?(0,l.__)("上传中…","jio"):(0,l.__)("上传封面","jio"))):(0,e.createElement)("p",{className:"jio-creator-muted"},(0,l.__)("当前账号不能设置封面。","jio"))),(0,e.createElement)("section",{className:"jio-creator-field"},(0,e.createElement)("h3",null,(0,l.__)("分类","jio")),(0,e.createElement)("div",{className:"jio-creator-chips"},0===c.categories.length&&(0,e.createElement)("span",{className:"jio-creator-muted"},(0,l.__)("暂无可选分类","jio")),c.categories.map(t=>(0,e.createElement)("label",{key:t.id,className:"jio-creator-chip"+(r.categories.includes(t.id)?" is-active":"")},(0,e.createElement)("input",{type:"checkbox",checked:r.categories.includes(t.id),onChange:()=>(e=>{const t=r.categories.includes(e);n({categories:t?r.categories.filter(t=>t!==e):[...r.categories,e]})})(t.id)}),(0,e.createElement)("span",null,t.name))))),c.tags&&(0,e.createElement)("section",{className:"jio-creator-field"},(0,e.createElement)("h3",null,(0,l.__)("标签","jio")),(0,e.createElement)("div",{className:"jio-creator-Line truncated
|
||||
Generated
+23386
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "jio-creator-studio",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "wp-scripts build",
|
||||
"start": "wp-scripts start"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "18.3.31",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@wordpress/block-editor": "11.8.0",
|
||||
"@wordpress/block-library": "^8.8.0",
|
||||
"@wordpress/blocks": "12.8.0",
|
||||
"@wordpress/components": "23.8.0",
|
||||
"@wordpress/data": "9.5.0",
|
||||
"@wordpress/element": "5.8.0",
|
||||
"@wordpress/i18n": "4.31.0",
|
||||
"@wordpress/scripts": "26.4.0",
|
||||
"@wordpress/wordcount": "3.31.0",
|
||||
"sass": "1.77.8",
|
||||
"typescript": "5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 顶栏保存状态。状态文案即用户对“内容有没有保存”的唯一反馈。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
export type SaveStatus = 'ready' | 'saving' | 'saved' | 'error' | 'conflict' | 'submitting' | 'submitted';
|
||||
|
||||
export type SaveStateValue = { state: SaveStatus; at: string };
|
||||
|
||||
const LABELS: Record<SaveStatus, string> = {
|
||||
ready: __('尚未保存', 'jio'),
|
||||
saving: __('保存中…', 'jio'),
|
||||
saved: __('已保存', 'jio'),
|
||||
error: __('保存失败', 'jio'),
|
||||
conflict: __('内容已在别处更新', 'jio'),
|
||||
submitting: __('提交中…', 'jio'),
|
||||
submitted: __('已提交', 'jio'),
|
||||
};
|
||||
|
||||
export function SaveState({ value }: { value: SaveStateValue }) {
|
||||
return (
|
||||
<span className={`jio-creator__save is-${value.state}`} role="status" aria-live="polite">
|
||||
<i className="fa fas fa-circle" aria-hidden="true" />
|
||||
<span>{LABELS[value.state] || LABELS.ready}</span>
|
||||
{value.state === 'saved' && value.at ? <time>{value.at}</time> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 自动保存节奏控制。
|
||||
*
|
||||
* - 云端自动保存 4 秒防抖,保存进行中不叠加请求;
|
||||
* - 本地快照 1.5 秒防抖,由调用方写入 localStorage;
|
||||
* - 页面隐藏/失焦时立即冲刷一次,避免切走丢稿。
|
||||
*
|
||||
* 回调放进 ref,保证 schedule 引用稳定:否则每次输入产生的重渲染都会重置
|
||||
* 定时器,自动保存永远不会触发。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef } from '@wordpress/element';
|
||||
|
||||
type Options = {
|
||||
enabled: boolean;
|
||||
onSave: () => Promise<boolean>;
|
||||
onLocal: () => void;
|
||||
remoteDelay?: number;
|
||||
localDelay?: number;
|
||||
};
|
||||
|
||||
export function useAutosave({ enabled, onSave, onLocal, remoteDelay = 4000, localDelay = 1500 }: Options) {
|
||||
const remoteTimer = useRef<number | null>(null);
|
||||
const localTimer = useRef<number | null>(null);
|
||||
const inFlight = useRef(false);
|
||||
|
||||
const saveRef = useRef(onSave);
|
||||
const localRef = useRef(onLocal);
|
||||
saveRef.current = onSave;
|
||||
localRef.current = onLocal;
|
||||
|
||||
const clear = useCallback(() => {
|
||||
if (remoteTimer.current) {
|
||||
window.clearTimeout(remoteTimer.current);
|
||||
remoteTimer.current = null;
|
||||
}
|
||||
if (localTimer.current) {
|
||||
window.clearTimeout(localTimer.current);
|
||||
localTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
if (inFlight.current) {
|
||||
return;
|
||||
}
|
||||
inFlight.current = true;
|
||||
try {
|
||||
await saveRef.current();
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const schedule = useCallback(() => {
|
||||
clear();
|
||||
localRef.current();
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
localTimer.current = window.setTimeout(() => localRef.current(), localDelay);
|
||||
remoteTimer.current = window.setTimeout(() => {
|
||||
void flush();
|
||||
}, remoteDelay);
|
||||
}, [clear, enabled, flush, localDelay, remoteDelay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return undefined;
|
||||
}
|
||||
const onHide = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
void flush();
|
||||
}
|
||||
};
|
||||
const onBlur = () => {
|
||||
void flush();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onHide);
|
||||
window.addEventListener('blur', onBlur);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onHide);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
clear();
|
||||
};
|
||||
}, [clear, enabled, flush]);
|
||||
|
||||
return useMemo(() => ({ schedule, flush, cancel: clear }), [clear, flush, schedule]);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 本地草稿备份。
|
||||
*
|
||||
* 只保存标题与序列化后的正文,绝不保存 nonce、文件或付费凭据;恢复前由界面
|
||||
* 询问用户,成功保存服务端后才清除。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef } from '@wordpress/element';
|
||||
|
||||
type Snapshot = { title: string; content: string; at: string };
|
||||
|
||||
type Options = {
|
||||
key: string;
|
||||
snapshot: Snapshot;
|
||||
onFound: (savedAt: string) => void;
|
||||
};
|
||||
|
||||
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function useLocalRecovery({ key, snapshot, onFound }: Options) {
|
||||
const latest = useRef(snapshot);
|
||||
latest.current = snapshot;
|
||||
const checked = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (checked.current) {
|
||||
return;
|
||||
}
|
||||
checked.current = true;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
const at = typeof parsed.at === 'string' ? Date.parse(parsed.at) : NaN;
|
||||
if (!Number.isFinite(at) || Date.now() - at > MAX_AGE_MS) {
|
||||
window.localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
if (typeof parsed.content === 'string' && parsed.content !== snapshot.content) {
|
||||
onFound(String(parsed.at));
|
||||
}
|
||||
} catch (error) {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
}, [key, onFound, snapshot.content]);
|
||||
|
||||
const markLocalSaved = useCallback(() => {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(latest.current));
|
||||
} catch (error) {
|
||||
// 存储配额不足时静默降级:云端自动保存仍然是主路径。
|
||||
}
|
||||
}, [key]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
}, [key]);
|
||||
|
||||
return { markLocalSaved, clear };
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* Jio Creator Studio — 前台沉浸式创作界面。
|
||||
*
|
||||
* Gutenberg 负责区块解析、编辑与序列化;本入口只搭建工作台外壳、发布配置抽屉
|
||||
* 与保存/预览/提交编排。所有写入都走 Jio REST(CreatorRest),服务端负责权限、
|
||||
* 字段清洗、审核状态与私有附件归属。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
import { createRoot, useCallback, useEffect, useMemo, useRef, useState } from '@wordpress/element';
|
||||
import { parse, serialize } from '@wordpress/blocks';
|
||||
import { registerCoreBlocks } from '@wordpress/block-library';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { BlockEditorProvider, BlockList, BlockTools, BlockInspector } from '@wordpress/block-editor';
|
||||
import { count as countWords } from '@wordpress/wordcount';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
import { apiPost } from './services/api';
|
||||
import { installMediaUploadBridge } from './services/media';
|
||||
import { PublishDrawer } from './panels/publish-drawer';
|
||||
import { SaveState, type SaveStateValue } from './components/save-state';
|
||||
import { useAutosave } from './hooks/use-autosave';
|
||||
import { useLocalRecovery } from './hooks/use-local-recovery';
|
||||
import { normalizePayForRequest } from './utils/pay';
|
||||
import './styles/studio.scss';
|
||||
|
||||
// 核心区块注册只做一次;重复调用会触发 "Block type already registered"。
|
||||
const coreRegistered = (window as any).__jioCoreBlocksRegistered;
|
||||
if (!coreRegistered && typeof registerCoreBlocks === 'function') {
|
||||
registerCoreBlocks();
|
||||
(window as any).__jioCoreBlocksRegistered = true;
|
||||
}
|
||||
|
||||
const bootstrap: Record<string, any> = window['jioCreatorStudio'] || {};
|
||||
const nonce: string = bootstrap.nonce || '';
|
||||
const STORAGE_KEY = `jio-studio-${bootstrap.sourceId || 0}`;
|
||||
|
||||
type Featured = { type: string; data: Record<string, any>; mediaId?: number };
|
||||
type PayState = {
|
||||
enabled: boolean;
|
||||
modo: 'cash' | 'points';
|
||||
price: string;
|
||||
points: number;
|
||||
vip1Price: string;
|
||||
vip2Price: string;
|
||||
payLimit: number;
|
||||
downloads: Array<Record<string, any>>;
|
||||
};
|
||||
|
||||
type Limits = {
|
||||
titleMin?: number;
|
||||
titleMax?: number;
|
||||
titlePlaceholder?: string;
|
||||
priceMin?: number;
|
||||
priceMax?: number;
|
||||
pointsMin?: number;
|
||||
pointsMax?: number;
|
||||
};
|
||||
|
||||
type Draft = {
|
||||
postId: number;
|
||||
versionId: number;
|
||||
rev: number;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
categories: number[];
|
||||
tags: string[];
|
||||
custom: Record<string, string[]>;
|
||||
featured: Featured;
|
||||
pay: PayState;
|
||||
};
|
||||
|
||||
function initialDraft(): Draft {
|
||||
const pay = bootstrap.pay || {};
|
||||
const selected = (bootstrap.categories && bootstrap.categories.selected) || [];
|
||||
const tags = (bootstrap.tags && bootstrap.tags.items) || [];
|
||||
return {
|
||||
postId: Number(bootstrap.sourceId || 0),
|
||||
versionId: Number(bootstrap.versionId || 0),
|
||||
rev: Number(bootstrap.versionRev || 0),
|
||||
title: String(bootstrap.title || ''),
|
||||
excerpt: String(bootstrap.excerpt || ''),
|
||||
content: String(bootstrap.content || ''),
|
||||
categories: selected.map(Number),
|
||||
tags: tags.slice(),
|
||||
custom: bootstrap.custom || {},
|
||||
featured: bootstrap.featured || { type: '', data: {} },
|
||||
pay: {
|
||||
enabled: !!pay.enabled,
|
||||
modo: pay.payModo === 'points' ? 'points' : 'cash',
|
||||
price: String(pay.price || '0.00'),
|
||||
points: Number(pay.points || 0),
|
||||
vip1Price: String(pay.vip1Price || '0.00'),
|
||||
vip2Price: String(pay.vip2Price || '0.00'),
|
||||
payLimit: Number(pay.payLimit || 0),
|
||||
downloads: Array.isArray(pay.downloads) ? pay.downloads : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function Studio(): JSX.Element {
|
||||
const [draft, setDraft] = useState<Draft>(initialDraft);
|
||||
const [blocks, setBlocks] = useState(() => parse(draft.content || ''));
|
||||
const [saveState, setSaveState] = useState<SaveStateValue>({ state: 'ready', at: '' });
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [notice, setNotice] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null);
|
||||
const [recovery, setRecovery] = useState<null | { savedAt: string }>(null);
|
||||
const dirtyRef = useRef(false);
|
||||
|
||||
const capabilities = bootstrap.capabilities || {};
|
||||
const limits: Limits = {
|
||||
titleMin: Number(bootstrap.limits?.titleMin || 1),
|
||||
titleMax: Number(bootstrap.limits?.titleMax || 30),
|
||||
titlePlaceholder: String(bootstrap.limits?.titlePlaceholder || __('请输入标题', 'jio')),
|
||||
priceMin: Number(bootstrap.pay?.priceMin || 0),
|
||||
priceMax: Number(bootstrap.pay?.priceMax || 0),
|
||||
pointsMin: Number(bootstrap.pay?.pointsMin || 0),
|
||||
pointsMax: Number(bootstrap.pay?.pointsMax || 0),
|
||||
};
|
||||
const isPublishedEdit = !!bootstrap.isPublished;
|
||||
|
||||
const patchDraft = useCallback((patch: Partial<Draft>) => {
|
||||
dirtyRef.current = true;
|
||||
setDraft((current) => ({ ...current, ...patch }));
|
||||
}, []);
|
||||
|
||||
const onChangeBlocks = useCallback((next: any[]) => {
|
||||
dirtyRef.current = true;
|
||||
setBlocks(next);
|
||||
}, []);
|
||||
|
||||
const serialized = useMemo(() => serialize(blocks), [blocks]);
|
||||
const words = useMemo(() => countWords(serialized.replace(/<[^>]*>/g, ' '), 'words', {}), [serialized]);
|
||||
|
||||
const buildPayload = useCallback(
|
||||
(extra: Record<string, any> = {}) => ({
|
||||
post_id: draft.postId,
|
||||
version_id: draft.versionId,
|
||||
rev: draft.rev,
|
||||
title: draft.title,
|
||||
excerpt: draft.excerpt,
|
||||
content: serialized,
|
||||
categories: draft.categories,
|
||||
tags: draft.tags,
|
||||
custom: draft.custom,
|
||||
featured: draft.featured,
|
||||
pay: normalizePayForRequest(draft.pay),
|
||||
...extra,
|
||||
}),
|
||||
[draft, serialized]
|
||||
);
|
||||
|
||||
const save = useCallback(
|
||||
async (autosave = false): Promise<boolean> => {
|
||||
if (!dirtyRef.current && (draft.postId > 0 || draft.versionId > 0)) {
|
||||
return true;
|
||||
}
|
||||
// 空文档不触发自动保存:服务端会拒绝无标题/无内容的写入,
|
||||
// 保留本地快照即可,避免把“保存失败”当成真的故障。
|
||||
const hasDocument = draft.title.trim() !== '' || serialized.replace(/<[^>]*>/g, '').trim() !== '';
|
||||
if (autosave && !hasDocument) {
|
||||
return true;
|
||||
}
|
||||
setSaveState({ state: 'saving', at: '' });
|
||||
try {
|
||||
const response = await apiPost(bootstrap.creator.saveUrl, buildPayload({ autosave }), nonce);
|
||||
const savedId = Number(response.post_id || draft.postId || 0);
|
||||
const versionId = Number(response.version_id || draft.versionId || 0);
|
||||
const rev = Number(response.rev || response.versionRev || draft.rev || 0);
|
||||
dirtyRef.current = false;
|
||||
setDraft((current) => ({ ...current, postId: savedId, versionId, rev }));
|
||||
setSaveState({ state: 'saved', at: new Date().toLocaleTimeString() });
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
setSaveState({ state: error?.status === 409 ? 'conflict' : 'error', at: '' });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[buildPayload, draft.postId, draft.versionId, draft.rev]
|
||||
);
|
||||
|
||||
const submit = useCallback(
|
||||
async (publishNow: boolean): Promise<void> => {
|
||||
setSaveState({ state: 'submitting', at: '' });
|
||||
try {
|
||||
// 站点启用「前台投稿」人机验证时,先完成挑战再提交;
|
||||
// 服务端仍会独立校验证明,前端只是把凭证带过去。
|
||||
let captchaProof = '';
|
||||
const captcha = (window as any).JioCaptcha;
|
||||
if (bootstrap.captcha?.enabled && captcha && typeof captcha.run === 'function') {
|
||||
const payload = await captcha.run('front_submit');
|
||||
if (payload === null) {
|
||||
setSaveState({ state: 'ready', at: '' });
|
||||
setNotice({ tone: 'error', text: __('请先完成人机验证', 'jio') });
|
||||
return;
|
||||
}
|
||||
captchaProof = String(payload?.jio_captcha_proof || '');
|
||||
}
|
||||
const response = await apiPost(
|
||||
bootstrap.creator.submitUrl,
|
||||
buildPayload({ publish_now: publishNow, captcha_proof: captchaProof }),
|
||||
nonce
|
||||
);
|
||||
dirtyRef.current = false;
|
||||
setSaveState({ state: 'submitted', at: '' });
|
||||
setNotice({ tone: 'ok', text: String(response.message || __('已提交', 'jio')) });
|
||||
setDrawerOpen(false);
|
||||
const target = response.url || response.goto || bootstrap.urls?.center;
|
||||
if (target) {
|
||||
window.setTimeout(() => {
|
||||
window.location.href = target;
|
||||
}, 900);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setSaveState({ state: 'error', at: '' });
|
||||
setNotice({ tone: 'error', text: String(error?.message || __('提交失败', 'jio')) });
|
||||
}
|
||||
},
|
||||
[buildPayload]
|
||||
);
|
||||
|
||||
const preview = useCallback(async (): Promise<void> => {
|
||||
const saved = await save(false);
|
||||
if (!saved) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await apiPost(
|
||||
bootstrap.creator.previewUrl,
|
||||
{ post_id: draft.postId, version_id: draft.versionId },
|
||||
nonce
|
||||
);
|
||||
if (response.url) {
|
||||
window.open(String(response.url), '_blank', 'noopener');
|
||||
}
|
||||
} catch (error: any) {
|
||||
setNotice({ tone: 'error', text: String(error?.message || __('预览失败', 'jio')) });
|
||||
}
|
||||
}, [draft.postId, draft.versionId, save]);
|
||||
|
||||
const { markLocalSaved, clear } = useLocalRecovery({
|
||||
key: STORAGE_KEY,
|
||||
snapshot: { title: draft.title, content: serialized, at: new Date().toISOString() },
|
||||
onFound: (savedAt: string) => setRecovery({ savedAt }),
|
||||
});
|
||||
|
||||
const autosave = useAutosave({
|
||||
enabled: !!capabilities.isLoggedIn,
|
||||
onSave: async () => {
|
||||
const ok = await save(true);
|
||||
if (ok) {
|
||||
clear();
|
||||
}
|
||||
return ok;
|
||||
},
|
||||
onLocal: markLocalSaved,
|
||||
});
|
||||
|
||||
const autosaveSchedule = autosave.schedule;
|
||||
|
||||
useEffect(() => {
|
||||
if (dirtyRef.current) {
|
||||
autosaveSchedule();
|
||||
}
|
||||
}, [draft, serialized, autosaveSchedule]);
|
||||
|
||||
useEffect(() => {
|
||||
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!dirtyRef.current) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
};
|
||||
window.addEventListener('beforeunload', beforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', beforeUnload);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
|
||||
event.preventDefault();
|
||||
void save(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [save]);
|
||||
|
||||
const restore = useCallback(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed.title === 'string') {
|
||||
dirtyRef.current = true;
|
||||
setDraft((current) => ({ ...current, title: parsed.title }));
|
||||
}
|
||||
if (typeof parsed.content === 'string') {
|
||||
dirtyRef.current = true;
|
||||
setBlocks(parse(parsed.content));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 快照损坏时忽略,不影响服务端内容。
|
||||
}
|
||||
setRecovery(null);
|
||||
}, []);
|
||||
|
||||
const selectedBlockCount = useSelect(
|
||||
(select: any) => select('core/block-editor').getBlockCount(),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="jio-creator" data-status={bootstrap.status || ''} data-blocks={selectedBlockCount}>
|
||||
<header className="jio-creator__bar">
|
||||
<a className="jio-creator__back" href={bootstrap.urls?.center || '/'}>
|
||||
<i className="fa fas fa-angle-left" aria-hidden="true" />
|
||||
<span>{__('返回', 'jio')}</span>
|
||||
</a>
|
||||
<div className="jio-creator__brand">Jio Creator Studio</div>
|
||||
<div className="jio-creator__actions">
|
||||
<SaveState value={saveState} />
|
||||
<button type="button" className="jio-creator__ghost" onClick={() => void preview()}>
|
||||
{__('预览', 'jio')}
|
||||
</button>
|
||||
<button type="button" className="jio-creator__primary" onClick={() => setDrawerOpen(true)}>
|
||||
{isPublishedEdit ? __('提交改稿', 'jio') : __('发布', 'jio')}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{recovery && (
|
||||
<div className="jio-creator__notice" role="status">
|
||||
<span>{__('发现本地未同步的草稿。', 'jio')}</span>
|
||||
<button type="button" onClick={restore}>{__('恢复', 'jio')}</button>
|
||||
<button type="button" onClick={() => setRecovery(null)}>{__('忽略', 'jio')}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notice && (
|
||||
<div className={`jio-creator__notice is-${notice.tone}`} role="status">
|
||||
<span>{notice.text}</span>
|
||||
<button type="button" onClick={() => setNotice(null)} aria-label={__('关闭', 'jio')}>
|
||||
<i className="fa fas fa-times" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPublishedEdit && (
|
||||
<p className="jio-creator__hint">
|
||||
{__('这篇改稿不会立即覆盖线上文章;审核通过后才会替换。', 'jio')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<main className="jio-creator__canvas">
|
||||
<label className="jio-creator__title">
|
||||
<span className="screen-reader-text">{__('文章标题', 'jio')}</span>
|
||||
<textarea
|
||||
value={draft.title}
|
||||
onChange={(event) => patchDraft({ title: event.target.value })}
|
||||
rows={1}
|
||||
placeholder={limits.titlePlaceholder || __('请输入标题', 'jio')}
|
||||
maxLength={(Number(limits.titleMax) || 30) * 4}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="jio-creator__excerpt">
|
||||
<span>{__('摘要(可选)', 'jio')}</span>
|
||||
<textarea
|
||||
value={draft.excerpt}
|
||||
onChange={(event) => patchDraft({ excerpt: event.target.value })}
|
||||
rows={2}
|
||||
placeholder={__('一句话概括这篇文章', 'jio')}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="jio-creator__editor">
|
||||
<BlockEditorProvider
|
||||
value={blocks}
|
||||
onInput={onChangeBlocks}
|
||||
onChange={onChangeBlocks}
|
||||
settings={{
|
||||
hasFixedToolbar: false,
|
||||
supportsLayout: false,
|
||||
mediaUpload: (window as any).jioCreatorMediaUpload,
|
||||
}}
|
||||
>
|
||||
<BlockTools>
|
||||
<BlockList />
|
||||
</BlockTools>
|
||||
<aside className="jio-creator__inspector" aria-label={__('区块设置', 'jio')}>
|
||||
<BlockInspector />
|
||||
</aside>
|
||||
</BlockEditorProvider>
|
||||
</div>
|
||||
|
||||
<footer className="jio-creator__footer">
|
||||
<span>{__('字数', 'jio')}:{words}</span>
|
||||
<span>
|
||||
{__('约', 'jio')} {Math.max(1, Math.round(words / 400))} {__('分钟阅读', 'jio')}
|
||||
</span>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
{drawerOpen && (
|
||||
<PublishDrawer
|
||||
draft={draft}
|
||||
setDraft={patchDraft}
|
||||
capabilities={capabilities}
|
||||
limits={limits}
|
||||
taxonomy={{
|
||||
categories: (bootstrap.categories && bootstrap.categories.items) || [],
|
||||
tags: !!bootstrap.tags?.enabled,
|
||||
customDefs: bootstrap.customDefs || [],
|
||||
}}
|
||||
isPublishedEdit={isPublishedEdit}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onSaveDraft={() => void save(false)}
|
||||
onSubmit={(publishNow: boolean) => void submit(publishNow)}
|
||||
privateStorage={!!bootstrap.privateStorage}
|
||||
privateUploads={bootstrap.privateUploads || []}
|
||||
captcha={!!bootstrap.captcha?.enabled}
|
||||
saving={saveState.state === 'saving' || saveState.state === 'submitting'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function mount() {
|
||||
const target = document.getElementById('jio-creator-studio');
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
installMediaUploadBridge(bootstrap, nonce);
|
||||
target.classList.add('is-ready');
|
||||
createRoot(target).render(<Studio />);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', mount);
|
||||
} else {
|
||||
mount();
|
||||
}
|
||||
|
||||
export { Studio };
|
||||
@@ -0,0 +1,512 @@
|
||||
/**
|
||||
* 发布配置抽屉。
|
||||
*
|
||||
* 封面、分类、摘要、付费与资料附件都只在这里出现,正文画布保持干净;
|
||||
* 手机端以全屏面板呈现。所有字段与权限来自服务端 bootstrap,前端不自行放大权限。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from '@wordpress/element';
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
|
||||
import { apiUpload } from '../services/api';
|
||||
|
||||
type Featured = { type: string; data: Record<string, any>; mediaId?: number };
|
||||
type PayState = {
|
||||
enabled: boolean;
|
||||
modo: 'cash' | 'points';
|
||||
price: string;
|
||||
points: number;
|
||||
vip1Price: string;
|
||||
vip2Price: string;
|
||||
payLimit: number;
|
||||
downloads: Array<Record<string, any>>;
|
||||
};
|
||||
|
||||
type Draft = {
|
||||
postId: number;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
categories: number[];
|
||||
tags: string[];
|
||||
custom: Record<string, string[]>;
|
||||
featured: Featured;
|
||||
pay: PayState;
|
||||
};
|
||||
|
||||
type Capabilities = Record<string, boolean>;
|
||||
|
||||
type Limits = {
|
||||
titleMin?: number;
|
||||
titleMax?: number;
|
||||
priceMin?: number;
|
||||
priceMax?: number;
|
||||
pointsMin?: number;
|
||||
pointsMax?: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
draft: Draft;
|
||||
setDraft: (patch: Partial<Draft>) => void;
|
||||
capabilities: Capabilities;
|
||||
limits: Limits;
|
||||
captcha: boolean;
|
||||
taxonomy: {
|
||||
categories: Array<{ id: number; name: string }>;
|
||||
tags: boolean;
|
||||
customDefs: Array<{ key: string; name: string; values: Array<{ key: string; name: string }> }>;
|
||||
};
|
||||
isPublishedEdit: boolean;
|
||||
onClose: () => void;
|
||||
onSaveDraft: () => void;
|
||||
onSubmit: (publishNow: boolean) => void;
|
||||
privateStorage: boolean;
|
||||
privateUploads: Array<Record<string, any>>;
|
||||
saving: boolean;
|
||||
};
|
||||
|
||||
const bootstrap: Record<string, any> = window['jioCreatorStudio'] || {};
|
||||
const nonce: string = bootstrap.nonce || '';
|
||||
|
||||
export function PublishDrawer(props: Props) {
|
||||
const {
|
||||
draft,
|
||||
setDraft,
|
||||
capabilities,
|
||||
limits,
|
||||
taxonomy,
|
||||
isPublishedEdit,
|
||||
onClose,
|
||||
onSaveDraft,
|
||||
onSubmit,
|
||||
privateStorage,
|
||||
privateUploads,
|
||||
saving,
|
||||
captcha,
|
||||
} = props;
|
||||
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [uploadError, setUploadError] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const coverInput = useRef<HTMLInputElement | null>(null);
|
||||
const fileInput = useRef<HTMLInputElement | null>(null);
|
||||
const captchaSlot = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 人机验证由 PHP 渲染(脚本在页面加载时按 uid 注册实例),这里只把挂载点
|
||||
// 移进抽屉,保证验证控件在用户提交时可见、可交互。
|
||||
useEffect(() => {
|
||||
const slot = captchaSlot.current;
|
||||
const mount = document.getElementById('jio-creator-captcha');
|
||||
if (!slot || !mount) {
|
||||
return undefined;
|
||||
}
|
||||
const previous = mount.parentElement;
|
||||
slot.appendChild(mount);
|
||||
mount.removeAttribute('hidden');
|
||||
return () => {
|
||||
if (previous) {
|
||||
previous.appendChild(mount);
|
||||
mount.setAttribute('hidden', '');
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const titleLength = useMemo(() => (draft.title || '').trim().length, [draft.title]);
|
||||
const canSubmit = titleLength >= Number(limits.titleMin || 1);
|
||||
|
||||
const toggleCategory = (id: number) => {
|
||||
const has = draft.categories.includes(id);
|
||||
setDraft({
|
||||
categories: has ? draft.categories.filter((value) => value !== id) : [...draft.categories, id],
|
||||
});
|
||||
};
|
||||
|
||||
const addTag = () => {
|
||||
const value = tagInput.trim();
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
const next = value
|
||||
.split(/[,,\n]+/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
setDraft({ tags: Array.from(new Set([...draft.tags, ...next])) });
|
||||
setTagInput('');
|
||||
};
|
||||
|
||||
const uploadCover = async () => {
|
||||
const file = coverInput.current?.files?.[0];
|
||||
if (!file || !bootstrap.media?.uploadUrl?.image) {
|
||||
return;
|
||||
}
|
||||
setUploadError('');
|
||||
setUploading(true);
|
||||
try {
|
||||
const response = await apiUpload(bootstrap.media.uploadUrl.image, file, nonce);
|
||||
const id = Number(response.attachment_id || response.id || 0);
|
||||
const url = String(response.url || '');
|
||||
if (!id || !url) {
|
||||
throw new Error(__('上传后无法读取图片。', 'jio'));
|
||||
}
|
||||
setDraft({ featured: { type: 'image', mediaId: id, data: { id, url } } });
|
||||
} catch (error: any) {
|
||||
setUploadError(String(error?.message || __('上传失败', 'jio')));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadAttachment = async () => {
|
||||
const file = fileInput.current?.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
setUploadError('');
|
||||
setUploading(true);
|
||||
try {
|
||||
const response = await apiUpload(bootstrap.creator.privateUrl, file, nonce, {
|
||||
post_id: String(draft.postId || 0),
|
||||
});
|
||||
const next = {
|
||||
type: 'protected',
|
||||
assetKey: String(response.asset_key || ''),
|
||||
fileName: String(response.name || file.name),
|
||||
sizeBytes: Number(response.size || file.size),
|
||||
sizeLabel: String(response.size_label || ''),
|
||||
label: '',
|
||||
};
|
||||
setDraft({
|
||||
pay: { ...draft.pay, enabled: true, downloads: [...draft.pay.downloads, next] },
|
||||
});
|
||||
if (fileInput.current) {
|
||||
fileInput.current.value = '';
|
||||
}
|
||||
} catch (error: any) {
|
||||
setUploadError(String(error?.message || __('资料上传失败', 'jio')));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeDownload = (index: number) => {
|
||||
setDraft({
|
||||
pay: { ...draft.pay, downloads: draft.pay.downloads.filter((_, i) => i !== index) },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="jio-creator-drawer" role="dialog" aria-modal="true" aria-labelledby="jio-creator-publish-title">
|
||||
<button type="button" className="jio-creator-drawer__backdrop" aria-label={__('关闭', 'jio')} onClick={onClose} />
|
||||
<div className="jio-creator-drawer__panel">
|
||||
<header className="jio-creator-drawer__head">
|
||||
<h2 id="jio-creator-publish-title">{isPublishedEdit ? __('提交改稿', 'jio') : __('发布文章', 'jio')}</h2>
|
||||
<button type="button" className="jio-creator-drawer__close" onClick={onClose} aria-label={__('关闭', 'jio')}>
|
||||
<i className="fa fas fa-times" aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="jio-creator-drawer__body">
|
||||
{isPublishedEdit && (
|
||||
<p className="jio-creator-drawer__note">
|
||||
{__('审核通过前,读者看到的仍然是当前线上版本。', 'jio')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section className="jio-creator-field">
|
||||
<h3>{__('封面', 'jio')}</h3>
|
||||
{draft.featured?.data?.url ? (
|
||||
<div className="jio-creator-cover">
|
||||
<img src={String(draft.featured.data.url)} alt="" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraft({ featured: { type: '', data: {} } })}
|
||||
aria-label={__('移除封面', 'jio')}
|
||||
>
|
||||
<i className="fa fas fa-times" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
) : capabilities.canUploadImage ? (
|
||||
<label className="jio-creator-cover-empty">
|
||||
<input
|
||||
ref={coverInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={() => void uploadCover()}
|
||||
disabled={uploading}
|
||||
/>
|
||||
<i className="fa fas fa-plus" aria-hidden="true" />
|
||||
<span>{uploading ? __('上传中…', 'jio') : __('上传封面', 'jio')}</span>
|
||||
</label>
|
||||
) : (
|
||||
<p className="jio-creator-muted">{__('当前账号不能设置封面。', 'jio')}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="jio-creator-field">
|
||||
<h3>{__('分类', 'jio')}</h3>
|
||||
<div className="jio-creator-chips">
|
||||
{taxonomy.categories.length === 0 && <span className="jio-creator-muted">{__('暂无可选分类', 'jio')}</span>}
|
||||
{taxonomy.categories.map((category) => (
|
||||
<label
|
||||
key={category.id}
|
||||
className={`jio-creator-chip${draft.categories.includes(category.id) ? ' is-active' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.categories.includes(category.id)}
|
||||
onChange={() => toggleCategory(category.id)}
|
||||
/>
|
||||
<span>{category.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{taxonomy.tags && (
|
||||
<section className="jio-creator-field">
|
||||
<h3>{__('标签', 'jio')}</h3>
|
||||
<div className="jio-creator-chips">
|
||||
{draft.tags.map((tag) => (
|
||||
<span key={tag} className="jio-creator-chip is-active">
|
||||
<span>{tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraft({ tags: draft.tags.filter((value) => value !== tag) })}
|
||||
aria-label={sprintf(__('移除标签 %s', 'jio'), tag)}
|
||||
>
|
||||
<i className="fa fas fa-times" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="jio-creator-inline">
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
placeholder={__('输入标签,逗号分隔', 'jio')}
|
||||
onChange={(event) => setTagInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" onClick={addTag}>{__('添加', 'jio')}</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{taxonomy.customDefs.map((dimension) => (
|
||||
<section className="jio-creator-field" key={dimension.key}>
|
||||
<h3>{dimension.name}</h3>
|
||||
<div className="jio-creator-chips">
|
||||
{dimension.values.map((value) => {
|
||||
const current = draft.custom[dimension.key] || [];
|
||||
const active = current.includes(value.key);
|
||||
return (
|
||||
<label key={value.key} className={`jio-creator-chip${active ? ' is-active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
onChange={() =>
|
||||
setDraft({
|
||||
custom: {
|
||||
...draft.custom,
|
||||
[dimension.key]: active
|
||||
? current.filter((item) => item !== value.key)
|
||||
: [...current, value.key],
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{value.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{capabilities.canPay && (
|
||||
<section className="jio-creator-field">
|
||||
<div className="jio-creator-switch">
|
||||
<div>
|
||||
<h3>{__('付费内容', 'jio')}</h3>
|
||||
<p className="jio-creator-muted">{__('开启后,未购买读者只能看到免费部分。', 'jio')}</p>
|
||||
</div>
|
||||
<label className="jio-creator-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.pay.enabled}
|
||||
onChange={(event) => setDraft({ pay: { ...draft.pay, enabled: event.target.checked } })}
|
||||
/>
|
||||
<span aria-hidden="true" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{draft.pay.enabled && (
|
||||
<div className="jio-creator-pay">
|
||||
<div className="jio-creator-inline">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
checked={draft.pay.modo === 'cash'}
|
||||
onChange={() => setDraft({ pay: { ...draft.pay, modo: 'cash' } })}
|
||||
/>
|
||||
<span>{__('现金', 'jio')}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
checked={draft.pay.modo === 'points'}
|
||||
onChange={() => setDraft({ pay: { ...draft.pay, modo: 'points' } })}
|
||||
/>
|
||||
<span>{__('积分', 'jio')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{draft.pay.modo === 'cash' ? (
|
||||
<label className="jio-creator-input">
|
||||
<span>{__('价格', 'jio')}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={Number(limits.priceMin || 0)}
|
||||
step="0.01"
|
||||
value={draft.pay.price}
|
||||
onChange={(event) => setDraft({ pay: { ...draft.pay, price: event.target.value } })}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<label className="jio-creator-input">
|
||||
<span>{__('积分价格', 'jio')}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="1"
|
||||
value={draft.pay.points}
|
||||
onChange={(event) =>
|
||||
setDraft({ pay: { ...draft.pay, points: Number(event.target.value) || 0 } })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{capabilities.canPayDownload && (
|
||||
<div className="jio-creator-attachments">
|
||||
<div className="jio-creator-attachments__head">
|
||||
<h4>{__('资料附件', 'jio')}</h4>
|
||||
{privateStorage ? (
|
||||
<label className="jio-creator-upload">
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
onChange={() => void uploadAttachment()}
|
||||
disabled={uploading}
|
||||
/>
|
||||
<span>{uploading ? __('上传中…', 'jio') : __('添加 PDF / ZIP', 'jio')}</span>
|
||||
</label>
|
||||
) : (
|
||||
<p className="jio-creator-muted">
|
||||
{__('私有附件存储尚未配置,暂时不能上传付费资料。', 'jio')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uploadError && <p className="jio-creator-error">{uploadError}</p>}
|
||||
|
||||
{draft.pay.downloads.length > 0 && (
|
||||
<ul className="jio-creator-files">
|
||||
{draft.pay.downloads.map((row, index) => (
|
||||
<li key={`${row.assetKey || row.url}-${index}`}>
|
||||
<span className="jio-creator-files__name">
|
||||
{row.assetKey ? (
|
||||
<>
|
||||
<i className="fa fas fa-lock" aria-hidden="true" />
|
||||
{row.fileName || row.label || __('受保护资料', 'jio')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="fa fas fa-link" aria-hidden="true" />
|
||||
{row.label || row.url}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{row.sizeLabel && <span className="jio-creator-muted">{row.sizeLabel}</span>}
|
||||
<button type="button" onClick={() => removeDownload(index)} aria-label={__('移除', 'jio')}>
|
||||
<i className="fa fas fa-times" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{privateUploads.length > 0 && (
|
||||
<details className="jio-creator-reuse">
|
||||
<summary>{__('从已上传资料中选择', 'jio')}</summary>
|
||||
<ul>
|
||||
{privateUploads
|
||||
.filter((item) => !draft.pay.downloads.some((row) => row.assetKey === item.assetKey))
|
||||
.map((item) => (
|
||||
<li key={item.assetKey}>
|
||||
<span>{item.fileName}</span>
|
||||
<span className="jio-creator-muted">{item.sizeLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
pay: {
|
||||
...draft.pay,
|
||||
enabled: true,
|
||||
downloads: [...draft.pay.downloads, { ...item, type: 'protected' }],
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{__('使用', 'jio')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!capabilities.canPay && capabilities.canSaveDraft && (
|
||||
<p className="jio-creator-muted">{__('当前账号不能设置付费内容。', 'jio')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="jio-creator-drawer__foot">
|
||||
{captcha && (
|
||||
<div className="jio-creator-drawer__captcha" ref={captchaSlot} />
|
||||
)}
|
||||
<div className="jio-creator-drawer__actions">
|
||||
<button type="button" className="jio-creator-ghost" onClick={onSaveDraft} disabled={saving}>
|
||||
{__('保存草稿', 'jio')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="jio-creator-primary"
|
||||
onClick={() => onSubmit(false)}
|
||||
disabled={saving || !canSubmit}
|
||||
>
|
||||
{isPublishedEdit ? __('提交审核', 'jio') : capabilities.canPublishNow ? __('发布', 'jio') : __('提交审核', 'jio')}
|
||||
</button>
|
||||
{capabilities.canPublishNow && (
|
||||
<button type="button" className="jio-creator-link" onClick={() => onSubmit(true)} disabled={saving || !canSubmit}>
|
||||
{__('立即发布', 'jio')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Creator Studio 的 REST 访问层。
|
||||
*
|
||||
* 所有请求都带 `X-WP-Nonce`;服务端仍以 Jio 能力矩阵与文章归属做最终判定。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
export type ApiError = Error & { status?: number; code?: string; data?: unknown };
|
||||
|
||||
function toError(payload: any, status: number): ApiError {
|
||||
const message =
|
||||
(payload && (payload.message || payload.error)) ||
|
||||
(status === 401
|
||||
? '登录状态已失效,请刷新页面重新登录。'
|
||||
: '请求失败,请稍后重试。');
|
||||
const error = new Error(String(message)) as ApiError;
|
||||
error.status = status;
|
||||
error.code = payload && payload.code ? String(payload.code) : '';
|
||||
error.data = payload;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function readJson(response: Response): Promise<any> {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiGet(url: string, nonce: string): Promise<any> {
|
||||
const response = await fetch(url, {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-WP-Nonce': nonce },
|
||||
});
|
||||
const payload = await readJson(response);
|
||||
if (!response.ok) {
|
||||
throw toError(payload, response.status);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function apiPost(url: string, body: Record<string, unknown>, nonce: string): Promise<any> {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': nonce,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await readJson(response);
|
||||
if (!response.ok) {
|
||||
throw toError(payload, response.status);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function apiUpload(url: string, file: File, nonce: string, fields: Record<string, string> = {}): Promise<any> {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
Object.entries(fields).forEach(([key, value]) => body.append(key, value));
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-WP-Nonce': nonce },
|
||||
body,
|
||||
});
|
||||
const payload = await readJson(response);
|
||||
if (!response.ok) {
|
||||
throw toError(payload, response.status);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const wpJson = (value: unknown): string => JSON.stringify(value);
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 编辑器媒体上传桥。
|
||||
*
|
||||
* Gutenberg 核心图片区块通过 block-editor settings.mediaUpload 上传;这里把它接到
|
||||
* Jio 前台上传接口,保留 Jio 的能力校验、文件校验与待认领生命周期,而不是让区块
|
||||
* 直接走 /wp/v2/media。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
export function installMediaUploadBridge(bootstrap: Record<string, any>, nonce: string) {
|
||||
const url = bootstrap?.blocks?.mediaUploadUrl;
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
const maxBytes = Number(bootstrap?.media?.maxBytes || 0);
|
||||
const allowed = bootstrap?.capabilities?.canUploadImage !== false;
|
||||
|
||||
window['jioCreatorMediaUpload'] = (payload: any) => {
|
||||
const { file, onError, onSuccess } = payload || {};
|
||||
if (!file || typeof file === 'object' && !(file instanceof File)) {
|
||||
onError?.('无效的文件');
|
||||
return;
|
||||
}
|
||||
if (!allowed) {
|
||||
onError?.('当前账号不能上传图片');
|
||||
return;
|
||||
}
|
||||
if (maxBytes > 0 && file.size > maxBytes) {
|
||||
onError?.('图片大小超过限制');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-WP-Nonce': nonce },
|
||||
body,
|
||||
})
|
||||
.then((response) =>
|
||||
response.json().then((data) => {
|
||||
if (!response.ok) {
|
||||
throw new Error((data && data.message) || '上传失败');
|
||||
}
|
||||
return data;
|
||||
})
|
||||
)
|
||||
.then((data) => {
|
||||
onSuccess?.({
|
||||
id: Number(data.attachment_id || data.id || 0),
|
||||
url: String(data.url || ''),
|
||||
alt: '',
|
||||
caption: '',
|
||||
title: String(data.title || data.name || ''),
|
||||
width: Number(data.width || 0),
|
||||
height: Number(data.height || 0),
|
||||
sizes: data.sizes || {},
|
||||
mime: String(data.mime || ''),
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
onError?.(error?.message || '上传失败');
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,825 @@
|
||||
/**
|
||||
* Jio Creator Studio — 沉浸式写作工作台。
|
||||
*
|
||||
* 视觉方向:纸面写作而不是后台配置。单一深青绿用于“保存/发布”这类推进动作,
|
||||
* 其余保持低对比;发布设置全部收进抽屉,正文区域只有标题、摘要与画布。
|
||||
*/
|
||||
|
||||
$ink: #243330;
|
||||
$ink-soft: #5c6b66;
|
||||
$paper: #fafbfa;
|
||||
$line: #e2e9e5;
|
||||
$accent: #267665;
|
||||
$accent-strong: #1d5f52;
|
||||
$warn: #b4573f;
|
||||
|
||||
/*
|
||||
* 创作页接管整屏:隐藏主题头部/底部与浮动入口,避免与工作台顶栏重复。
|
||||
* 只在投稿模板的 body class 下生效,不影响其他页面。
|
||||
*/
|
||||
.jio-page-submit {
|
||||
background: $paper;
|
||||
}
|
||||
|
||||
.jio-page-submit .header,
|
||||
.jio-page-submit .footer,
|
||||
.jio-page-submit .float-btn,
|
||||
.jio-page-submit .footer-tabbar,
|
||||
.jio-page-submit .jio-float-buttons {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.jio-page-submit #main-content.container {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#jio-creator-studio.is-ready {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.jio-creator {
|
||||
--jc-ink: #{$ink};
|
||||
--jc-ink-soft: #{$ink-soft};
|
||||
--jc-paper: #{$paper};
|
||||
--jc-line: #{$line};
|
||||
--jc-accent: #{$accent};
|
||||
--jc-accent-strong: #{$accent-strong};
|
||||
--jc-warn: #{$warn};
|
||||
--jc-bar: 60px;
|
||||
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background: var(--jc-paper);
|
||||
color: var(--jc-ink);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator__bar {
|
||||
position: sticky;
|
||||
z-index: 30;
|
||||
top: 0;
|
||||
display: flex;
|
||||
height: var(--jc-bar);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 clamp(16px, 4vw, 40px);
|
||||
border-bottom: 1px solid var(--jc-line);
|
||||
background: rgba(250, 251, 250, 0.92);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.jio-creator__back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--jc-ink-soft);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: var(--jc-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator__brand {
|
||||
margin-right: auto;
|
||||
color: var(--jc-ink-soft);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.jio-creator__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.jio-creator__save {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--jc-ink-soft);
|
||||
font-size: 13px;
|
||||
|
||||
> i {
|
||||
color: var(--jc-line);
|
||||
font-size: 7px;
|
||||
}
|
||||
|
||||
time {
|
||||
opacity: 0.7;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&.is-saved > i {
|
||||
color: var(--jc-accent);
|
||||
}
|
||||
|
||||
&.is-error > i,
|
||||
&.is-conflict > i {
|
||||
color: var(--jc-warn);
|
||||
}
|
||||
|
||||
&.is-error,
|
||||
&.is-conflict {
|
||||
color: var(--jc-warn);
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator__ghost,
|
||||
.jio-creator__primary,
|
||||
.jio-creator__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 36px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.jio-creator__ghost {
|
||||
border-color: var(--jc-line);
|
||||
background: transparent;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: var(--jc-accent);
|
||||
color: var(--jc-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator__primary {
|
||||
background: var(--jc-accent);
|
||||
color: #fff;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: var(--jc-accent-strong);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator__notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0 auto;
|
||||
max-width: 820px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--jc-line);
|
||||
background: #f1f5f2;
|
||||
color: var(--jc-ink-soft);
|
||||
font-size: 13px;
|
||||
|
||||
> span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--jc-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.is-error {
|
||||
background: #fdf2ee;
|
||||
color: var(--jc-warn);
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator__hint {
|
||||
max-width: 820px;
|
||||
margin: 18px auto 0;
|
||||
padding: 0 clamp(16px, 4vw, 40px);
|
||||
color: var(--jc-ink-soft);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.jio-creator__canvas {
|
||||
width: min(820px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: clamp(24px, 5vw, 56px) clamp(16px, 4vw, 40px) 120px;
|
||||
}
|
||||
|
||||
.jio-creator__title {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--jc-ink);
|
||||
font-family: "Songti SC", "Noto Serif SC", "Source Han Serif SC", serif;
|
||||
font-size: clamp(26px, 3.4vw, 34px);
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
resize: none;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #b7c2bd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator__excerpt {
|
||||
display: block;
|
||||
margin-bottom: 28px;
|
||||
|
||||
> span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--jc-ink-soft);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 8px 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--jc-line);
|
||||
background: transparent;
|
||||
color: var(--jc-ink-soft);
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
resize: none;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--jc-accent);
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator__editor {
|
||||
position: relative;
|
||||
|
||||
.block-editor-writing-flow {
|
||||
font-size: 17px;
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
.block-editor-default-block-appender textarea.block-editor-default-block-appender__content {
|
||||
color: #b7c2bd;
|
||||
}
|
||||
|
||||
.block-editor-block-list__layout {
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.wp-block {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator__inspector {
|
||||
margin-top: 24px;
|
||||
|
||||
.block-editor-block-inspector {
|
||||
border-top: 1px solid var(--jc-line);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.block-editor-block-inspector:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.components-panel__body {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--jc-line);
|
||||
color: var(--jc-ink-soft);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ---------- 发布抽屉 ---------- */
|
||||
|
||||
.jio-creator-drawer {
|
||||
position: fixed;
|
||||
z-index: 500;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.jio-creator-drawer__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 0;
|
||||
background: rgba(23, 34, 31, 0.32);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.jio-creator-drawer__panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: min(460px, 100%);
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
box-shadow: -20px 0 60px rgba(20, 32, 28, 0.16);
|
||||
}
|
||||
|
||||
.jio-creator-drawer__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid $line;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-drawer__close {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $ink-soft;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.jio-creator-drawer__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px 32px;
|
||||
}
|
||||
|
||||
.jio-creator-drawer__note {
|
||||
margin: 0 0 20px;
|
||||
padding: 12px 14px;
|
||||
border-left: 3px solid $accent;
|
||||
background: #f2f7f4;
|
||||
color: $ink-soft;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.jio-creator-drawer__foot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 24px calc(16px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid $line;
|
||||
background: #fff;
|
||||
|
||||
.jio-creator-primary {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-drawer__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.jio-creator-drawer__captcha {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.jio-creator-field {
|
||||
margin-bottom: 26px;
|
||||
|
||||
> h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $ink;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-muted {
|
||||
margin: 0;
|
||||
color: $ink-soft;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.jio-creator-error {
|
||||
margin: 8px 0 0;
|
||||
color: $warn;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.jio-creator-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.jio-creator-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid $line;
|
||||
border-radius: 999px;
|
||||
color: $ink-soft;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-color: $accent;
|
||||
background: #eef5f2;
|
||||
color: $accent-strong;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
|
||||
input[type="text"],
|
||||
input:not([type]) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input:not([type]) {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid $line;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-switch {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-toggle {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
width: 42px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
background: #d6dfda;
|
||||
transition: background 0.18s ease;
|
||||
|
||||
&::after {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 3px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
content: "";
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
}
|
||||
|
||||
input:checked + span {
|
||||
background: $accent;
|
||||
}
|
||||
|
||||
input:checked + span::after {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
input:focus-visible + span {
|
||||
outline: 2px solid $accent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-pay {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid $line;
|
||||
border-radius: 10px;
|
||||
background: #fcfdfc;
|
||||
}
|
||||
|
||||
.jio-creator-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
|
||||
input {
|
||||
width: 140px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid $line;
|
||||
border-radius: 8px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-cover {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
button {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-cover-empty {
|
||||
display: flex;
|
||||
min-height: 120px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border: 1px dashed $line;
|
||||
border-radius: 10px;
|
||||
background: #fafcfb;
|
||||
color: $ink-soft;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-attachments {
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid $line;
|
||||
}
|
||||
|
||||
.jio-creator-attachments__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-upload {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid $line;
|
||||
border-radius: 8px;
|
||||
color: $accent-strong;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-files {
|
||||
margin: 12px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f4f1;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-left: auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $ink-soft;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-files__name {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
i {
|
||||
color: $accent;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-reuse {
|
||||
margin-top: 14px;
|
||||
font-size: 13px;
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: $ink-soft;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 10px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-left: auto;
|
||||
border: 1px solid $line;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator-link {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--jc-ink-soft, #{$ink-soft});
|
||||
font-size: 13px;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---------- 手机 / 平板 ---------- */
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.jio-creator {
|
||||
--jc-bar: 54px;
|
||||
}
|
||||
|
||||
.jio-creator__brand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.jio-creator__bar {
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.jio-creator__save time {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.jio-creator__ghost {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.jio-creator__primary {
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.jio-creator__canvas {
|
||||
padding: 20px 16px calc(120px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.jio-creator__title textarea {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.jio-creator__editor .block-editor-writing-flow {
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.jio-creator__inspector {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.jio-creator-drawer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.jio-creator-drawer__backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.jio-creator-drawer__panel {
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.jio-creator-drawer__foot {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.jio-creator * {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 付费配置 → 请求体。
|
||||
*
|
||||
* 受保护附件只提交 asset_key,不提交任何文件路径或公开 URL;服务端会再次
|
||||
* 校验附件归属并重新清洗。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
type PayState = {
|
||||
enabled: boolean;
|
||||
modo: 'cash' | 'points';
|
||||
price: string;
|
||||
points: number;
|
||||
vip1Price: string;
|
||||
vip2Price: string;
|
||||
payLimit: number;
|
||||
downloads: Array<Record<string, any>>;
|
||||
};
|
||||
|
||||
export function normalizePayForRequest(pay: PayState) {
|
||||
const downloads = (pay.downloads || [])
|
||||
.map((row) => {
|
||||
const assetKey = typeof row.assetKey === 'string' ? row.assetKey : '';
|
||||
if (assetKey) {
|
||||
return {
|
||||
type: 'protected',
|
||||
asset_key: assetKey,
|
||||
label: typeof row.label === 'string' ? row.label : '',
|
||||
};
|
||||
}
|
||||
const url = typeof row.url === 'string' ? row.url.trim() : '';
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: 'direct',
|
||||
url,
|
||||
label: typeof row.label === 'string' ? row.label : '',
|
||||
extract_code: typeof row.extractCode === 'string' ? row.extractCode : '',
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
enabled: !!pay.enabled,
|
||||
fields: {
|
||||
pay_type: pay.enabled ? (downloads.length > 0 ? 'download' : 'reading') : 'no',
|
||||
pay_modo: pay.modo === 'points' ? 'points' : '0',
|
||||
price: pay.modo === 'points' ? '0.00' : String(pay.price || '0.00'),
|
||||
points_price: pay.modo === 'points' ? Math.max(0, Number(pay.points) || 0) : 0,
|
||||
vip1_price: pay.modo === 'points' ? '0.00' : String(pay.vip1Price || '0.00'),
|
||||
vip2_price: pay.modo === 'points' ? '0.00' : String(pay.vip2Price || '0.00'),
|
||||
pay_limit: Number(pay.payLimit) || 0,
|
||||
downloads,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["react", "react-dom"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 受保护下载按钮:先换取短期令牌,再用令牌地址下载。
|
||||
*
|
||||
* 按钮由下载区模板输出,只携带随机 asset_key,不携带文件路径或公开 URL。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var i18n = (window.jioData && window.jioData.protectedDownload) || {};
|
||||
var tokenUrl = i18n.tokenUrl || '';
|
||||
|
||||
function toast(message, type) {
|
||||
if (typeof window.notyf === 'function') {
|
||||
window.notyf(message, type || 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function message(data) {
|
||||
if (data && typeof data.message === 'string' && data.message !== '') {
|
||||
return data.message;
|
||||
}
|
||||
return i18n.error || '下载失败,请稍后重试';
|
||||
}
|
||||
|
||||
function download(button) {
|
||||
var assetKey = button.getAttribute('data-jio-protected-asset') || '';
|
||||
var postId = button.getAttribute('data-jio-protected-post') || '';
|
||||
if (!assetKey || !postId || !tokenUrl) {
|
||||
return;
|
||||
}
|
||||
if (button.getAttribute('aria-busy') === 'true') {
|
||||
return;
|
||||
}
|
||||
button.setAttribute('aria-busy', 'true');
|
||||
|
||||
var body = new URLSearchParams();
|
||||
body.set('post_id', postId);
|
||||
body.set('asset_key', assetKey);
|
||||
|
||||
fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
'X-WP-Nonce': (window.jioData && window.jioData.restNonce) || ''
|
||||
},
|
||||
body: body.toString()
|
||||
})
|
||||
.then(function (response) {
|
||||
return response.json().then(function (data) {
|
||||
return { ok: response.ok, data: data };
|
||||
});
|
||||
})
|
||||
.then(function (result) {
|
||||
if (!result.ok || !result.data || !result.data.url) {
|
||||
throw new Error(message(result.data));
|
||||
}
|
||||
window.location.href = result.data.url;
|
||||
})
|
||||
.catch(function (error) {
|
||||
toast(error && error.message ? error.message : (i18n.error || '下载失败,请稍后重试'));
|
||||
})
|
||||
.finally(function () {
|
||||
button.removeAttribute('aria-busy');
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var button = event.target.closest('[data-jio-protected-asset]');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
download(button);
|
||||
});
|
||||
})();
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
(function($,window2,document2){"use strict";function closeNotyf($element){$element.addClass("notyn-out"),window2.setTimeout(function(){$element.remove()},1e3)}function notyf2(message,type,time,id){var $body=jQuery(document2.body);jQuery(".notyn").length||$body.append('<div class="notyn"></div>'),type=type||"success",type==="error"&&(type="danger"),time=time||5e3,time=time<100?time*1e3:time;var idAttribute=id?' id="'+id+'"':"",$notification=jQuery('<div class="noty1"'+idAttribute+'><div class="notyf '+type+'">'+message+"</div></div>"),shouldClose=!id;id&&jQuery("#"+id).length?(jQuery("#"+id).find(".notyf").removeClass().addClass("notyf "+type).html(message),$notification=jQuery("#"+id),shouldClose=!0):jQuery(".notyn").append($notification),shouldClose&&window2.setTimeout(function(){closeNotyf($notification)},time)}jQuery(document2.body).on("click",".noty1",function(){closeNotyf(jQuery(this))}),window2.notyf=notyf2})(jQuery,window,document),(function(window2){"use strict";var allowedKinds={identity:!0,badges:!0,orders:!0,vip:!0},allowedEvents={click:!0,shell:!0,"first-content":!0,ready:!0,"action-start":!0,"action-end":!0,unauthorized:!0,error:!0},sessions=Object.create(null),sequence=0,maxSessions=100;function normalize(value,fallback){var normalized=String(value||fallback||"").toLowerCase().replace(/[^a-z0-9-]/g,"");return normalized||fallback}function now(){return window2.performance&&typeof window2.performance.now=="function"?window2.performance.now():0}function markName(session,event){return"jio-modal-"+session.kind+"-"+session.id+"-"+event}function emit(session,event){if(!(!window2.performance||typeof window2.performance.mark!="function")){var name=markName(session,event);if(window2.performance.mark(name),session.marks[event]=name,typeof window2.performance.measure=="function"&&session.marks.click&&event!=="click")try{window2.performance.measure("jio-modal-"+session.kind+"-"+session.id+"-click-to-"+event,session.marks.click,name)}catch{}}}function start(kind){var safeKind=normalize(kind,"unknown");allowedKinds[safeKind]||(safeKind="unknown"),sequence+=1;var id=String(sequence),session={id,kind:safeKind,startedAt:now(),marks:Object.create(null)};sessions[id]=session,emit(session,"click");for(var ids=Object.keys(sessions);ids.length>maxSessions;)delete sessions[ids.shift()];return id}function mark(id,event){var session=sessions[String(id||"")],safeEvent=normalize(event,"error");!session||!allowedEvents[safeEvent]||emit(session,safeEvent)}function finish(id){mark(id,"ready")}function fail(id){mark(id,"error")}window2.JioModalTelemetry={start,mark,finish,error:fail}})(window),(function(window2){"use strict";function data(){return window2.jioData||window2.jio||{}}function createIdempotencyKey(){if(window2.crypto&&typeof window2.crypto.randomUUID=="function")return window2.crypto.randomUUID();if(!window2.crypto||typeof window2.crypto.getRandomValues!="function")throw new Error("当前浏览器无法生成安全的操作标识");var bytes=new Uint8Array(16);window2.crypto.getRandomValues(bytes),bytes[6]=bytes[6]&15|64,bytes[8]=bytes[8]&63|128;var hex=Array.prototype.map.call(bytes,function(value){return value.toString(16).padStart(2,"0")}).join("");return hex.slice(0,8)+"-"+hex.slice(8,12)+"-"+hex.slice(12,16)+"-"+hex.slice(16,20)+"-"+hex.slice(20)}function restBase(){var config=data(),raw=config.restUrl||config.apiUrl||config.rest_url||"",siteUrl=config.siteUrl||config.homeUrl||window2.location.origin+"/";return window2.location.protocol==="https:"&&siteUrl.substring(0,5)==="http:"&&(siteUrl="https:"+siteUrl.substring(5)),raw?String(raw).replace(/\/$/,""):String(siteUrl).replace(/\/$/,"")+"/index.php?rest_route=/jio/v1"}function appendQuery(url2,query){if(!query)return url2;var parts=[];return typeof query=="string"?parts=query.replace(/^\?/,"").split("&").filter(Boolean):typeof query=="object"&&Object.keys(query).forEach(function(key){query[key]===void 0||query[key]===null||parts.push(encodeURIComponent(key)+"="+encodeURIComponent(String(query[key])))}),parts.length?url2+(url2.indexOf("?")>=0?"&":"?")+parts.join("&"):url2}function url(path,query){path=String(path||"");var inlineQuery="",queryIndex=path.indexOf("?");if(queryIndex>=0&&(inlineQuery=path.slice(queryIndex+1),path=path.slice(0,queryIndex)),/^https?:\/\//i.test(path))return appendQuery(path,query||inlineQuery);path.charAt(0)!=="/"&&(path="/"+path);var base=restBase(),result=(base.indexOf("rest_route=")>=0,base+path);return appendQuery(result,query||inlineQuery)}function headers(options){var config=data(),source=options&&options.headers||{},result={},nonce=config.restNonce||config.nonce||"";return Object.keys(source).forEach(function(key){result[key]=source[key]}),result.Accept||(result.Accept="application/json, text/html;q=0.9, */*;q=0.8"),nonce&&!result["X-WP-Nonce"]&&(result["X-WP-Nonce"]=nonce),options&&options.body!==void 0&&options.body!==null&&!(window2.FormData&&options.body instanceof window2.FormData)&&!result["Content-Type"]&&(result["CoLine truncated
|
||||
(function($,window2,document2){"use strict";function closeNotyf($element){$element.addClass("notyn-out"),window2.setTimeout(function(){$element.remove()},1e3)}function notyf2(message,type,time,id){var $body=jQuery(document2.body);jQuery(".notyn").length||$body.append('<div class="notyn"></div>'),type=type||"success",type==="error"&&(type="danger"),time=time||5e3,time=time<100?time*1e3:time;var idAttribute=id?' id="'+id+'"':"",$notification=jQuery('<div class="noty1"'+idAttribute+'><div class="notyf '+type+'">'+message+"</div></div>"),shouldClose=!id;id&&jQuery("#"+id).length?(jQuery("#"+id).find(".notyf").removeClass().addClass("notyf "+type).html(message),$notification=jQuery("#"+id),shouldClose=!0):jQuery(".notyn").append($notification),shouldClose&&window2.setTimeout(function(){closeNotyf($notification)},time)}jQuery(document2.body).on("click",".noty1",function(){closeNotyf(jQuery(this))}),window2.notyf=notyf2})(jQuery,window,document),(function(window2){"use strict";var allowedKinds={identity:!0,badges:!0,orders:!0,vip:!0},allowedEvents={click:!0,shell:!0,"first-content":!0,ready:!0,"action-start":!0,"action-end":!0,unauthorized:!0,error:!0},sessions=Object.create(null),sequence=0,maxSessions=100;function normalize(value,fallback){var normalized=String(value||fallback||"").toLowerCase().replace(/[^a-z0-9-]/g,"");return normalized||fallback}function now(){return window2.performance&&typeof window2.performance.now=="function"?window2.performance.now():0}function markName(session,event){return"jio-modal-"+session.kind+"-"+session.id+"-"+event}function emit(session,event){if(!(!window2.performance||typeof window2.performance.mark!="function")){var name=markName(session,event);if(window2.performance.mark(name),session.marks[event]=name,typeof window2.performance.measure=="function"&&session.marks.click&&event!=="click")try{window2.performance.measure("jio-modal-"+session.kind+"-"+session.id+"-click-to-"+event,session.marks.click,name)}catch{}}}function start(kind){var safeKind=normalize(kind,"unknown");allowedKinds[safeKind]||(safeKind="unknown"),sequence+=1;var id=String(sequence),session={id,kind:safeKind,startedAt:now(),marks:Object.create(null)};sessions[id]=session,emit(session,"click");for(var ids=Object.keys(sessions);ids.length>maxSessions;)delete sessions[ids.shift()];return id}function mark(id,event){var session=sessions[String(id||"")],safeEvent=normalize(event,"error");!session||!allowedEvents[safeEvent]||emit(session,safeEvent)}function finish(id){mark(id,"ready")}function fail(id){mark(id,"error")}window2.JioModalTelemetry={start,mark,finish,error:fail}})(window),(function(window2){"use strict";function data(){return window2.jioData||window2.jio||{}}function createIdempotencyKey(){if(window2.crypto&&typeof window2.crypto.randomUUID=="function")return window2.crypto.randomUUID();if(!window2.crypto||typeof window2.crypto.getRandomValues!="function")throw new Error("当前浏览器无法生成安全的操作标识");var bytes=new Uint8Array(16);window2.crypto.getRandomValues(bytes),bytes[6]=bytes[6]&15|64,bytes[8]=bytes[8]&63|128;var hex=Array.prototype.map.call(bytes,function(value){return value.toString(16).padStart(2,"0")}).join("");return hex.slice(0,8)+"-"+hex.slice(8,12)+"-"+hex.slice(12,16)+"-"+hex.slice(16,20)+"-"+hex.slice(20)}function restBase(){var config=data(),raw=config.restUrl||config.apiUrl||config.rest_url||"",siteUrl=config.siteUrl||config.homeUrl||window2.location.origin+"/";return window2.location.protocol==="https:"&&siteUrl.substring(0,5)==="http:"&&(siteUrl="https:"+siteUrl.substring(5)),raw?String(raw).replace(/\/$/,""):String(siteUrl).replace(/\/$/,"")+"/index.php?rest_route=/jio/v1"}function appendQuery(url2,query){if(!query)return url2;var parts=[];return typeof query=="string"?parts=query.replace(/^\?/,"").split("&").filter(Boolean):typeof query=="object"&&Object.keys(query).forEach(function(key){query[key]===void 0||query[key]===null||parts.push(encodeURIComponent(key)+"="+encodeURIComponent(String(query[key])))}),parts.length?url2+(url2.indexOf("?")>=0?"&":"?")+parts.join("&"):url2}function url(path,query){path=String(path||"");var inlineQuery="",queryIndex=path.indexOf("?");if(queryIndex>=0&&(inlineQuery=path.slice(queryIndex+1),path=path.slice(0,queryIndex)),/^https?:\/\//i.test(path))return appendQuery(path,query||inlineQuery);path.charAt(0)!=="/"&&(path="/"+path);var base=restBase(),result=(base.indexOf("rest_route=")>=0,base+path);return appendQuery(result,query||inlineQuery)}function headers(options){var config=data(),source=options&&options.headers||{},result={},nonce=config.restNonce||config.nonce||"";return Object.keys(source).forEach(function(key){result[key]=source[key]}),result.Accept||(result.Accept="application/json, text/html;q=0.9, */*;q=0.8"),nonce&&!result["X-WP-Nonce"]&&(result["X-WP-Nonce"]=nonce),options&&options.body!==void 0&&options.body!==null&&!(window2.FormData&&options.body instanceof window2.FormData)&&!result["Content-Type"]&&(result["CoLine truncated
|
||||
@@ -310,6 +310,26 @@ if (class_exists('\Jio\Core\CategoryRewrite')) {
|
||||
if (class_exists('\Jio\Core\FrontSubmit')) {
|
||||
\Jio\Core\FrontSubmit::get_instance()->init();
|
||||
}
|
||||
if (class_exists('\Jio\Core\SubmissionVersion')) {
|
||||
\Jio\Core\SubmissionVersion::get_instance()->init();
|
||||
}
|
||||
if (class_exists('\Jio\Core\PreviewToken')) {
|
||||
\Jio\Core\PreviewToken::get_instance()->init();
|
||||
}
|
||||
if (class_exists('\Jio\Core\CreatorRest')) {
|
||||
add_action('rest_api_init', static function (): void {
|
||||
(new \Jio\Core\CreatorRest())->register_routes();
|
||||
});
|
||||
}
|
||||
if (class_exists('\Jio\Core\CreatorStudio')) {
|
||||
\Jio\Core\CreatorStudio::get_instance()->init();
|
||||
}
|
||||
if (class_exists('\Jio\Content\PrivateAssets')) {
|
||||
\Jio\Content\PrivateAssets::get_instance()->init();
|
||||
}
|
||||
if (class_exists('\Jio\Content\ProtectedDownload')) {
|
||||
\Jio\Content\ProtectedDownload::get_instance()->init();
|
||||
}
|
||||
if (class_exists('\Jio\Attachment\UploadLimits')) {
|
||||
\Jio\Attachment\UploadLimits::get_instance()->init();
|
||||
}
|
||||
@@ -421,6 +441,11 @@ if (is_admin() && class_exists('\Jio\Admin\Native\PostListEnhance')) {
|
||||
\Jio\Admin\Native\PostListEnhance::get_instance()->init();
|
||||
}
|
||||
|
||||
// 前台改稿版本的原生审核页(运营动作,不入 SPA)
|
||||
if (is_admin() && class_exists('\Jio\Admin\Native\SubmissionReview')) {
|
||||
\Jio\Admin\Native\SubmissionReview::get_instance()->init();
|
||||
}
|
||||
|
||||
// WordPress 原生用户菜单:消息/推广码/认证/禁封(legacy users.php 子菜单对齐)
|
||||
if (is_admin() && class_exists('\Jio\Admin\Native\UsersMenus')) {
|
||||
\Jio\Admin\Native\UsersMenus::get_instance()->init();
|
||||
|
||||
+39
-8
@@ -335,6 +335,19 @@ if (!function_exists('jio_get_user_content_paid_order')) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('jio_bump_content_download_count')) {
|
||||
/** 资源下载计数 +1(受控下载成功交付后调用)。 */
|
||||
function jio_bump_content_download_count(int $post_id, int $delta = 1): int {
|
||||
if ($post_id <= 0) {
|
||||
return 0;
|
||||
}
|
||||
$key = \Jio\Core\DomainModel::RESOURCE_DOWNLOAD_COUNT_KEY;
|
||||
$next = max(0, (int) get_post_meta($post_id, $key, true) + $delta);
|
||||
update_post_meta($post_id, $key, $next);
|
||||
return $next;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment real sales volume for a paid content post (idempotent per order via order meta note flag optional).
|
||||
*/
|
||||
@@ -1484,19 +1497,32 @@ if (!function_exists('jio_get_paid_download_actions_html')) {
|
||||
}
|
||||
|
||||
$buttons = '';
|
||||
$protected_post_id = (int) ($meta['post_id'] ?? $meta['ID'] ?? get_the_ID() ?? 0);
|
||||
if ($protected_post_id <= 0 && function_exists('get_the_ID')) {
|
||||
$protected_post_id = (int) get_the_ID();
|
||||
}
|
||||
foreach ($downloads as $index => $download) {
|
||||
if (!is_array($download)) {
|
||||
continue;
|
||||
}
|
||||
$url = (string) ($download['url'] ?? '');
|
||||
if ($url === '') {
|
||||
$asset_key = class_exists('\Jio\Content\PrivateAssets')
|
||||
? \Jio\Content\PrivateAssets::sanitize_asset_key((string) ($download['asset_key'] ?? ''))
|
||||
: '';
|
||||
$is_protected = $asset_key !== '';
|
||||
$url = $is_protected ? '' : (string) ($download['url'] ?? '');
|
||||
if (!$is_protected && $url === '') {
|
||||
// 直连模式没有可用的原始地址,空源不渲染。
|
||||
continue;
|
||||
}
|
||||
$label = trim((string) ($download['label'] ?? ''));
|
||||
$service = function_exists('jio_detect_cloud_service')
|
||||
? jio_detect_cloud_service($url)
|
||||
: ['key' => 'other', 'name' => __('本地下载', 'jio')];
|
||||
if ($label === '' && $is_protected) {
|
||||
$label = trim((string) ($download['file_name'] ?? ''));
|
||||
}
|
||||
$service = $is_protected
|
||||
? ['key' => 'protected', 'name' => __('受保护下载', 'jio')]
|
||||
: (function_exists('jio_detect_cloud_service')
|
||||
? jio_detect_cloud_service($url)
|
||||
: ['key' => 'other', 'name' => __('本地下载', 'jio')]);
|
||||
if ($label === '') {
|
||||
$label = (string) ($service['name'] ?? __('本地下载', 'jio'));
|
||||
}
|
||||
@@ -1514,8 +1540,13 @@ if (!function_exists('jio_get_paid_download_actions_html')) {
|
||||
$copy_label = $extract_code !== '' ? $extract_code : ($copy_key !== '' ? $copy_key : __('更多内容', 'jio'));
|
||||
|
||||
$buttons .= '<div class="but-download flex ac">';
|
||||
$buttons .= '<a class="mr10 but ' . esc_attr($button_class) . '" href="' . esc_url($url) . '" target="_blank" rel="nofollow noopener">';
|
||||
$buttons .= $icon_html . esc_html($label) . '</a>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
if ($is_protected) {
|
||||
$buttons .= '<button type="button" class="mr10 but ' . esc_attr($button_class) . '" data-jio-protected-asset="' . esc_attr($asset_key) . '" data-jio-protected-post="' . esc_attr((string) $protected_post_id) . '">';
|
||||
$buttons .= $icon_html . esc_html($label) . '</button>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
} else {
|
||||
$buttons .= '<a class="mr10 but ' . esc_attr($button_class) . '" href="' . esc_url($url) . '" target="_blank" rel="nofollow noopener">';
|
||||
$buttons .= $icon_html . esc_html($label) . '</a>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
if ($copy_value !== '') {
|
||||
$tooltip = $copy_key !== '' ? ' data-toggle="tooltip" title="' . esc_attr(sprintf(__('点击复制%s', 'jio'), $copy_key)) . '"' : '';
|
||||
$buttons .= '<button type="button" class="but"' . $tooltip . ' data-clipboard-tag="' . esc_attr($copy_key !== '' ? $copy_key : __('内容', 'jio')) . '" data-clipboard-text="' . esc_attr($copy_value) . '">' . esc_html($copy_label) . '</button>';
|
||||
@@ -1613,7 +1644,7 @@ if (!function_exists('jio_get_paid_box_html')) {
|
||||
$root_data = '';
|
||||
if ($pay_type === JIO_PAY_TYPE_DOWNLOAD) {
|
||||
$root_data = ' data-jio-download-box data-post-id="' . esc_attr((string) $post->ID) . '"';
|
||||
$download_box = '<div class="hidden-box show"><div class="hidden-text"><i class="fa fas fa-download mr6" aria-hidden="true"></i>' . esc_html__('资源下载', 'jio') . '</div>' . jio_get_paid_download_actions_html($meta) . '</div>';
|
||||
$download_box = '<div class="hidden-box show"><div class="hidden-text"><i class="fa fas fa-download mr6" aria-hidden="true"></i>' . esc_html__('资源下载', 'jio') . '</div>' . jio_get_paid_download_actions_html($meta + ['post_id' => (int) $post->ID]) . '</div>';
|
||||
$demo = jio_get_demo_link_html($post, 'but jb-yellow padding-lg');
|
||||
if ($demo !== '') {
|
||||
$download_box .= '<div class="mt10">' . $demo . '</div>';
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
/**
|
||||
* 原生后台:已发布文章的改稿审核。
|
||||
*
|
||||
* 运营在此查看待审改稿、对比原文章,并通过 admin-post 动作一次性应用或退回。
|
||||
* 不提供直接编辑改稿内容的入口,避免绕过作者侧校验与版本指纹。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
namespace Jio\Admin\Native;
|
||||
|
||||
use Jio\Core\SubmissionVersion;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class SubmissionReview {
|
||||
|
||||
public const SLUG = 'jio_submission_reviews';
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
add_action('admin_menu', [$this, 'register_menu']);
|
||||
add_action('admin_notices', [$this, 'render_notice']);
|
||||
}
|
||||
|
||||
public function register_menu(): void {
|
||||
add_submenu_page(
|
||||
'edit.php',
|
||||
__('改稿审核', 'jio'),
|
||||
__('改稿审核', 'jio'),
|
||||
'edit_others_posts',
|
||||
self::SLUG,
|
||||
[$this, 'render_page']
|
||||
);
|
||||
}
|
||||
|
||||
public function render_notice(): void {
|
||||
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
|
||||
if (!$screen || strpos((string) $screen->id, self::SLUG) === false) {
|
||||
return;
|
||||
}
|
||||
$notice = isset($_GET['jio_review_notice']) ? sanitize_key((string) wp_unslash($_GET['jio_review_notice'])) : '';
|
||||
if ($notice === '') {
|
||||
return;
|
||||
}
|
||||
$message = isset($_GET['jio_review_message']) ? sanitize_text_field(rawurldecode((string) wp_unslash($_GET['jio_review_message']))) : '';
|
||||
$class = $notice === 'ok' ? 'notice-success' : 'notice-error';
|
||||
echo '<div class="notice ' . esc_attr($class) . ' is-dismissible"><p>' . esc_html($message) . '</p></div>';
|
||||
}
|
||||
|
||||
public function render_page(): void {
|
||||
if (!SubmissionVersion::can_review(get_current_user_id())) {
|
||||
wp_die(esc_html__('没有审核权限。', 'jio'));
|
||||
}
|
||||
|
||||
$status = isset($_GET['review_status']) ? sanitize_key((string) wp_unslash($_GET['review_status'])) : SubmissionVersion::STATUS_PENDING;
|
||||
$status = in_array($status, [SubmissionVersion::STATUS_PENDING, SubmissionVersion::STATUS_REJECTED, SubmissionVersion::STATUS_APPROVED], true)
|
||||
? $status
|
||||
: SubmissionVersion::STATUS_PENDING;
|
||||
|
||||
$versions = get_posts([
|
||||
'post_type' => SubmissionVersion::POST_TYPE,
|
||||
'post_status' => ['pending', 'draft', 'private'],
|
||||
'posts_per_page' => 40,
|
||||
'orderby' => 'modified',
|
||||
'order' => 'DESC',
|
||||
'meta_key' => SubmissionVersion::META_STATUS,
|
||||
'meta_value' => $status,
|
||||
]);
|
||||
|
||||
$tabs = [
|
||||
SubmissionVersion::STATUS_PENDING => __('待审核', 'jio'),
|
||||
SubmissionVersion::STATUS_REJECTED => __('已退回', 'jio'),
|
||||
SubmissionVersion::STATUS_APPROVED => __('已应用', 'jio'),
|
||||
];
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('改稿审核', 'jio'); ?></h1>
|
||||
<p class="description">
|
||||
<?php esc_html_e('作者对已发布文章的修改以独立版本保存;通过后才会替换线上文章。', 'jio'); ?>
|
||||
</p>
|
||||
|
||||
<ul class="subsubsub">
|
||||
<?php foreach ($tabs as $key => $label) : ?>
|
||||
<li>
|
||||
<a href="<?php echo esc_url(add_query_arg(['page' => self::SLUG, 'review_status' => $key], admin_url('edit.php'))); ?>"
|
||||
class="<?php echo $key === $status ? 'current' : ''; ?>">
|
||||
<?php echo esc_html($label); ?>
|
||||
</a>
|
||||
<?php echo $key !== array_key_last($tabs) ? ' |' : ''; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
|
||||
<table class="widefat striped" style="margin-top:16px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e('文章', 'jio'); ?></th>
|
||||
<th><?php esc_html_e('作者', 'jio'); ?></th>
|
||||
<th><?php esc_html_e('提交时间', 'jio'); ?></th>
|
||||
<th><?php esc_html_e('来源', 'jio'); ?></th>
|
||||
<th><?php esc_html_e('操作', 'jio'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (!$versions) : ?>
|
||||
<tr><td colspan="5"><?php esc_html_e('暂无记录。', 'jio'); ?></td></tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($versions as $version) :
|
||||
$version_id = (int) $version->ID;
|
||||
$source_id = SubmissionVersion::source_id($version_id);
|
||||
$author = get_userdata((int) $version->post_author);
|
||||
$note = (string) get_post_meta($version_id, SubmissionVersion::META_NOTE, true);
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<strong><?php echo esc_html($version->post_title !== '' ? $version->post_title : __('(无标题)', 'jio')); ?></strong>
|
||||
<?php if ($note !== '') : ?>
|
||||
<p class="description"><?php echo esc_html(sprintf(__('退回原因:%s', 'jio'), $note)); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($source_id > 0) : ?>
|
||||
<p class="description">
|
||||
<a href="<?php echo esc_url(get_edit_post_link($source_id)); ?>">
|
||||
<?php esc_html_e('查看线上文章', 'jio'); ?>
|
||||
</a>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo $author ? esc_html($author->display_name) : '—'; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php
|
||||
$submitted = (string) get_post_meta($version_id, SubmissionVersion::META_SUBMITTED_AT, true);
|
||||
echo esc_html($submitted !== '' ? $submitted : $version->post_modified);
|
||||
?>
|
||||
</td>
|
||||
<td>
|
||||
<?php esc_html_e('作者改稿', 'jio'); ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($status === SubmissionVersion::STATUS_PENDING) : ?>
|
||||
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline;">
|
||||
<input type="hidden" name="action" value="jio_submission_approve">
|
||||
<input type="hidden" name="version_id" value="<?php echo esc_attr((string) $version_id); ?>">
|
||||
<?php wp_nonce_field('jio_review_approve_' . $version_id); ?>
|
||||
<button type="submit" class="button button-primary"><?php esc_html_e('应用改稿', 'jio'); ?></button>
|
||||
</form>
|
||||
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline;margin-left:8px;">
|
||||
<input type="hidden" name="action" value="jio_submission_reject">
|
||||
<input type="hidden" name="version_id" value="<?php echo esc_attr((string) $version_id); ?>">
|
||||
<?php wp_nonce_field('jio_review_reject_' . $version_id); ?>
|
||||
<input type="text" name="note" placeholder="<?php esc_attr_e('退回原因(可选)', 'jio'); ?>" class="regular-text" style="width:180px;">
|
||||
<button type="submit" class="button"><?php esc_html_e('退回', 'jio'); ?></button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
/**
|
||||
* 知识付费附件的私有存储。
|
||||
*
|
||||
* 与 WordPress 媒体库不同,这里的文件不写入公开 uploads 目录,也不生成任何
|
||||
* 公网可访问的 URL。记录只保存随机 asset_key 与内部 storage_name;真正取回文件
|
||||
* 必须经过 ProtectedDownload 的授权与短期令牌。
|
||||
*
|
||||
* 未配置可靠的私有目录时 fail closed:上传与下载端点都直接拒绝,避免把“看似
|
||||
* 受保护”的直链交给用户。配置方式(wp-config.php 或主题 filter):
|
||||
*
|
||||
* define('JIO_PRIVATE_STORAGE_DIR', '/var/private/jio-assets');
|
||||
*
|
||||
* 目录位于 ABSPATH 之外会被视为已验证;若目录在站点根目录内,必须通过
|
||||
* JIO_PRIVATE_STORAGE_VERIFIED 常量或 jio_private_storage_verified filter
|
||||
* 显式声明已在 Web 服务器层禁止访问(如 nginx location deny all)。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
namespace Jio\Content;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class PrivateAssets {
|
||||
|
||||
/** 私有附件表名(见 DBMigratorPart3::table)。 */
|
||||
public const ASSET_KEY_LENGTH = 48;
|
||||
|
||||
/** 未关联文章的附件保留时长(与前台待认领上传一致)。 */
|
||||
private const ORPHAN_TTL = DAY_IN_SECONDS;
|
||||
private const CRON_HOOK = 'jio_private_asset_cleanup';
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
add_action(self::CRON_HOOK, [$this, 'cleanup_orphans']);
|
||||
add_action('init', [$this, 'maybe_schedule'], 21);
|
||||
}
|
||||
|
||||
public function maybe_schedule(): void {
|
||||
if (!wp_next_scheduled(self::CRON_HOOK)) {
|
||||
wp_schedule_event(time() + HOUR_IN_SECONDS, 'hourly', self::CRON_HOOK);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已配置可信的私有存储。
|
||||
*/
|
||||
public static function is_available(): bool {
|
||||
$dir = self::storage_dir();
|
||||
if ($dir === '' || !is_dir($dir) || !is_writable($dir)) {
|
||||
return false;
|
||||
}
|
||||
return self::is_verified($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* 私有文件根目录;未配置时返回空串。
|
||||
*/
|
||||
public static function storage_dir(): string {
|
||||
$dir = '';
|
||||
if (defined('JIO_PRIVATE_STORAGE_DIR')) {
|
||||
$dir = (string) constant('JIO_PRIVATE_STORAGE_DIR');
|
||||
}
|
||||
$dir = (string) apply_filters('jio_private_storage_dir', $dir);
|
||||
$dir = rtrim(trim($dir), '/\\');
|
||||
if ($dir === '') {
|
||||
return '';
|
||||
}
|
||||
return wp_normalize_path($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* 目录是否被判定为不可通过 Web 直接访问。
|
||||
*/
|
||||
private static function is_verified(string $dir): bool {
|
||||
$root = wp_normalize_path(rtrim(ABSPATH, '/\\'));
|
||||
$outside = strpos($dir . '/', $root . '/') !== 0;
|
||||
$explicit = (defined('JIO_PRIVATE_STORAGE_VERIFIED') && (bool) constant('JIO_PRIVATE_STORAGE_VERIFIED'))
|
||||
|| (bool) apply_filters('jio_private_storage_verified', false, $dir);
|
||||
return $outside || $explicit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传并落盘一个私有附件。
|
||||
*
|
||||
* @param array<string, mixed> $file $_FILES 风格数组。
|
||||
* @param int $user_id 上传者。
|
||||
* @param int $post_id 关联文章(0 = 待认领)。
|
||||
* @return array<string, mixed>|\WP_Error
|
||||
*/
|
||||
public function store_upload(array $file, int $user_id, int $post_id = 0) {
|
||||
if (!self::is_available()) {
|
||||
return new \WP_Error(
|
||||
'jio_private_storage_unavailable',
|
||||
__('私有附件存储未配置,暂不能上传付费资料。', 'jio'),
|
||||
['status' => 501]
|
||||
);
|
||||
}
|
||||
if ($user_id <= 0) {
|
||||
return new \WP_Error('jio_private_login_required', __('请先登录。', 'jio'), ['status' => 401]);
|
||||
}
|
||||
|
||||
$error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
|
||||
if ($error !== UPLOAD_ERR_OK || empty($file['tmp_name']) || !is_uploaded_file((string) $file['tmp_name'])) {
|
||||
return new \WP_Error('jio_private_upload_failed', __('请选择要上传的资料文件。', 'jio'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$max_size = \Jio\Core\FrontSubmit::upload_limit_bytes('resource');
|
||||
$size = (int) ($file['size'] ?? 0);
|
||||
if ($size <= 0 || ($max_size > 0 && $size > $max_size)) {
|
||||
return new \WP_Error(
|
||||
'jio_private_upload_too_large',
|
||||
sprintf(__('文件大小不能超过 %s。', 'jio'), size_format($max_size)),
|
||||
['status' => 413]
|
||||
);
|
||||
}
|
||||
|
||||
$name = sanitize_file_name((string) ($file['name'] ?? ''));
|
||||
$check = wp_check_filetype_and_ext((string) $file['tmp_name'], $name, get_allowed_mime_types());
|
||||
$mime = (string) ($check['type'] ?? '');
|
||||
if ($name === '' || $mime === '') {
|
||||
return new \WP_Error('jio_private_upload_type', __('不支持的文件类型。', 'jio'), ['status' => 400]);
|
||||
}
|
||||
if (strpos($mime, 'video/') === 0) {
|
||||
return new \WP_Error('jio_private_upload_type', __('私有附件不支持视频文件。', 'jio'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$asset_key = self::new_asset_key();
|
||||
$storage_name = $asset_key . '-' . $name;
|
||||
$target = self::storage_dir() . '/' . $storage_name;
|
||||
|
||||
if (!@move_uploaded_file((string) $file['tmp_name'], $target)) {
|
||||
return new \WP_Error('jio_private_upload_save', __('资料保存失败,请稍后重试。', 'jio'), ['status' => 500]);
|
||||
}
|
||||
@chmod($target, 0600);
|
||||
|
||||
$row_id = $this->insert_row([
|
||||
'asset_key' => $asset_key,
|
||||
'post_id' => max(0, $post_id),
|
||||
'owner_id' => $user_id,
|
||||
'file_name' => $name,
|
||||
'mime' => $mime,
|
||||
'size_bytes' => $size,
|
||||
'storage_name' => $storage_name,
|
||||
'created_at' => current_time('mysql'),
|
||||
]);
|
||||
if ($row_id <= 0) {
|
||||
@unlink($target);
|
||||
return new \WP_Error('jio_private_upload_save', __('资料保存失败,请稍后重试。', 'jio'), ['status' => 500]);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'asset_key' => $asset_key,
|
||||
'asset_id' => $row_id,
|
||||
'name' => $name,
|
||||
'mime' => $mime,
|
||||
'size' => $size,
|
||||
'size_label' => size_format($size),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 asset_key 读取一条附件记录。
|
||||
*
|
||||
* @return array{id:int,asset_key:string,post_id:int,owner_id:int,file_name:string,mime:string,size_bytes:int,storage_name:string,created_at:string}|null
|
||||
*/
|
||||
public static function get(string $asset_key): ?array {
|
||||
global $wpdb;
|
||||
$asset_key = self::sanitize_asset_key($asset_key);
|
||||
if ($asset_key === '') {
|
||||
return null;
|
||||
}
|
||||
$table = \Jio\Core\DBMigrator::table('private_asset');
|
||||
if ($table === '') {
|
||||
return null;
|
||||
}
|
||||
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE asset_key = %s LIMIT 1", $asset_key), ARRAY_A);
|
||||
if (!is_array($row)) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'asset_key' => (string) ($row['asset_key'] ?? ''),
|
||||
'post_id' => (int) ($row['post_id'] ?? 0),
|
||||
'owner_id' => (int) ($row['owner_id'] ?? 0),
|
||||
'file_name' => (string) ($row['file_name'] ?? ''),
|
||||
'mime' => (string) ($row['mime'] ?? ''),
|
||||
'size_bytes' => (int) ($row['size_bytes'] ?? 0),
|
||||
'storage_name' => (string) ($row['storage_name'] ?? ''),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把待认领附件绑定到文章(作者本人)。
|
||||
*
|
||||
* 已绑定到其他文章的附件不允许被再次改绑,避免把线上文章的资料挪走。
|
||||
*/
|
||||
public static function claim(string $asset_key, int $post_id, int $user_id): bool {
|
||||
global $wpdb;
|
||||
$asset = self::get($asset_key);
|
||||
if ($asset === null || $post_id <= 0 || $asset['owner_id'] !== $user_id) {
|
||||
return false;
|
||||
}
|
||||
if ($asset['post_id'] === $post_id) {
|
||||
return true;
|
||||
}
|
||||
if ($asset['post_id'] !== 0) {
|
||||
return false;
|
||||
}
|
||||
$table = \Jio\Core\DBMigrator::table('private_asset');
|
||||
$updated = $wpdb->update($table, ['post_id' => $post_id], ['id' => $asset['id'], 'post_id' => 0], ['%d'], ['%d', '%d']);
|
||||
return (bool) $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发表后把文章附件表里的 post_id 指向正式文章。
|
||||
*
|
||||
* @param list<string> $keys
|
||||
*/
|
||||
public static function claim_many(array $keys, int $post_id, int $user_id): void {
|
||||
foreach (array_unique($keys) as $key) {
|
||||
if (is_string($key) && $key !== '') {
|
||||
self::claim($key, $post_id, $user_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绝对文件路径;越界或记录损坏时返回空串。
|
||||
*/
|
||||
public static function file_path(array $asset): string {
|
||||
$dir = self::storage_dir();
|
||||
$name = (string) ($asset['storage_name'] ?? '');
|
||||
if ($dir === '' || $name === '' || strpos($name, '/') !== false || strpos($name, '\\') !== false) {
|
||||
return '';
|
||||
}
|
||||
$path = $dir . '/' . $name;
|
||||
return is_file($path) ? $path : '';
|
||||
}
|
||||
|
||||
public static function sanitize_asset_key(string $key): string {
|
||||
$key = strtolower(trim($key));
|
||||
return preg_match('/^[a-z0-9]{32,48}$/', $key) ? $key : '';
|
||||
}
|
||||
|
||||
private static function new_asset_key(): string {
|
||||
return bin2hex(random_bytes(24));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function insert_row(array $data): int {
|
||||
global $wpdb;
|
||||
$table = \Jio\Core\DBMigrator::table('private_asset');
|
||||
if ($table === '') {
|
||||
return 0;
|
||||
}
|
||||
$ok = $wpdb->insert($table, $data, ['%s', '%d', '%d', '%s', '%s', '%d', '%s', '%s']);
|
||||
return $ok ? (int) $wpdb->insert_id : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理长时间未关联文章的私有附件(文件 + 记录)。
|
||||
*/
|
||||
public function cleanup_orphans(): void {
|
||||
global $wpdb;
|
||||
$table = \Jio\Core\DBMigrator::table('private_asset');
|
||||
if ($table === '') {
|
||||
return;
|
||||
}
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE post_id = 0 AND created_at < %s ORDER BY id ASC LIMIT 100",
|
||||
gmdate('Y-m-d H:i:s', time() - self::ORPHAN_TTL)
|
||||
), ARRAY_A);
|
||||
foreach (is_array($rows) ? $rows : [] as $row) {
|
||||
$path = self::file_path([
|
||||
'storage_name' => (string) ($row['storage_name'] ?? ''),
|
||||
]);
|
||||
if ($path !== '') {
|
||||
@unlink($path);
|
||||
}
|
||||
$wpdb->delete($table, ['id' => (int) ($row['id'] ?? 0)], ['%d']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
<?php
|
||||
/**
|
||||
* 知识付费附件的受控下载。
|
||||
*
|
||||
* 私有附件不生成公网 URL。已获授权的用户先请求一个短期一次性令牌,再由本类
|
||||
* 读取文件并流式输出。令牌绑定文章、附件、用户(访客绑定 IP/UA 指纹)、到期
|
||||
* 时间与可用次数;到期或次数用尽返回 410,需要重新签发。
|
||||
*
|
||||
* 授权判定复用既有单一真相 \Jio\Content\Visibility::can_download_resource()
|
||||
* (订单 / VIP / 会员等级 / 免费配额),本类不另立付费规则。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
namespace Jio\Content;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class ProtectedDownload {
|
||||
|
||||
public const REST_NAMESPACE = 'jio/v1';
|
||||
/** 默认令牌有效期(秒)。 */
|
||||
public const DEFAULT_TTL = 900;
|
||||
/** 默认最多下载次数。 */
|
||||
public const DEFAULT_MAX_USES = 3;
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
add_action('rest_api_init', [$this, 'register_routes']);
|
||||
}
|
||||
|
||||
public function register_routes(): void {
|
||||
register_rest_route(self::REST_NAMESPACE, '/protected-download/token', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'rest_issue_token'],
|
||||
'permission_callback' => static function (): bool {
|
||||
return true;
|
||||
},
|
||||
'args' => [
|
||||
'post_id' => ['required' => true, 'sanitize_callback' => 'absint'],
|
||||
'asset_key' => ['required' => true, 'sanitize_callback' => [PrivateAssets::class, 'sanitize_asset_key']],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route(self::REST_NAMESPACE, '/protected-download/file/(?P<token>[a-f0-9]{48})', [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'rest_stream'],
|
||||
'permission_callback' => static function (): bool {
|
||||
return true;
|
||||
},
|
||||
'args' => [
|
||||
'token' => ['sanitize_callback' => 'sanitize_text_field'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为当前访问者签发下载令牌。
|
||||
*/
|
||||
public function rest_issue_token(\WP_REST_Request $request) {
|
||||
if (!PrivateAssets::is_available()) {
|
||||
return new \WP_Error(
|
||||
'jio_private_storage_unavailable',
|
||||
__('私有附件存储未配置,暂时无法下载。', 'jio'),
|
||||
['status' => 501]
|
||||
);
|
||||
}
|
||||
|
||||
$post_id = absint($request->get_param('post_id'));
|
||||
$asset_key = PrivateAssets::sanitize_asset_key((string) $request->get_param('asset_key'));
|
||||
$asset = PrivateAssets::get($asset_key);
|
||||
if ($post_id <= 0 || $asset === null) {
|
||||
return new \WP_Error('jio_private_asset_missing', __('附件不存在。', 'jio'), ['status' => 404]);
|
||||
}
|
||||
if (PrivateAssets::file_path($asset) === '') {
|
||||
return new \WP_Error('jio_private_asset_missing', __('附件文件已离线。', 'jio'), ['status' => 404]);
|
||||
}
|
||||
if (!self::asset_belongs_to_post($asset_key, $post_id)) {
|
||||
return new \WP_Error('jio_private_asset_mismatch', __('该附件不属于此内容。', 'jio'), ['status' => 403]);
|
||||
}
|
||||
|
||||
$gate = $this->authorize($post_id);
|
||||
if (is_wp_error($gate)) {
|
||||
return $gate;
|
||||
}
|
||||
|
||||
$issued = $this->issue($asset, $post_id);
|
||||
if (is_wp_error($issued)) {
|
||||
return $issued;
|
||||
}
|
||||
|
||||
return new \WP_REST_Response([
|
||||
'success' => true,
|
||||
'url' => $issued['url'],
|
||||
'expires_at' => $issued['expires_at'],
|
||||
'max_uses' => $issued['max_uses'],
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 归还令牌对应的下载地址。
|
||||
*
|
||||
* @param array<string, mixed> $asset
|
||||
*/
|
||||
public static function url(array $asset, int $post_id): string {
|
||||
return add_query_arg([
|
||||
'post_id' => $post_id,
|
||||
'asset_key' => (string) ($asset['asset_key'] ?? ''),
|
||||
], rest_url(self::REST_NAMESPACE . '/protected-download/token'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验下载权限(订单 / VIP / 免费配额),复用既有可见性服务。
|
||||
*
|
||||
* @return true|\WP_Error
|
||||
*/
|
||||
private function authorize(int $post_id) {
|
||||
$post = get_post($post_id);
|
||||
if (!$post instanceof \WP_Post || $post->post_status !== 'publish') {
|
||||
return new \WP_Error('jio_private_not_published', __('内容尚未发布。', 'jio'), ['status' => 404]);
|
||||
}
|
||||
|
||||
$uid = get_current_user_id();
|
||||
if ($uid <= 0 && !function_exists('jio_allow_guest_content_pay')) {
|
||||
return new \WP_Error('jio_need_login', __('请先登录后再下载。', 'jio'), [
|
||||
'status' => 401,
|
||||
'login_url' => wp_login_url(get_permalink($post)),
|
||||
]);
|
||||
}
|
||||
|
||||
if (!function_exists('jio_can_download')) {
|
||||
return new \WP_Error('jio_download_unavailable', __('下载服务暂不可用。', 'jio'), ['status' => 503]);
|
||||
}
|
||||
$gate = jio_can_download($post_id);
|
||||
if (is_wp_error($gate)) {
|
||||
$data = (array) $gate->get_error_data();
|
||||
$status = (int) ($data['status'] ?? 403);
|
||||
if ($status <= 0 || $status > 499) {
|
||||
$status = 403;
|
||||
}
|
||||
return new \WP_Error($gate->get_error_code(), $gate->get_error_message(), ['status' => $status] + $data);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private function asset_belongs_to_post(string $asset_key, int $post_id): bool {
|
||||
$asset = PrivateAssets::get($asset_key);
|
||||
if ($asset === null) {
|
||||
return false;
|
||||
}
|
||||
if ($asset['post_id'] === $post_id) {
|
||||
return true;
|
||||
}
|
||||
// 待认领附件(post_id = 0)仅作者本人可在编辑/预览时使用。
|
||||
return $asset['post_id'] === 0
|
||||
&& $asset['owner_id'] > 0
|
||||
&& $asset['owner_id'] === get_current_user_id();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $asset
|
||||
* @return array{url:string,expires_at:int,max_uses:int}|\WP_Error
|
||||
*/
|
||||
private function issue(array $asset, int $post_id) {
|
||||
global $wpdb;
|
||||
$table = \Jio\Core\DBMigrator::table('dl_token');
|
||||
if ($table === '') {
|
||||
return new \WP_Error('jio_download_unavailable', __('下载服务暂不可用。', 'jio'), ['status' => 503]);
|
||||
}
|
||||
|
||||
$ttl = max(60, (int) apply_filters('jio_protected_download_ttl', self::DEFAULT_TTL, $post_id));
|
||||
$max_uses = max(1, (int) apply_filters('jio_protected_download_max_uses', self::DEFAULT_MAX_USES, $post_id));
|
||||
$uid = get_current_user_id();
|
||||
|
||||
$token = bin2hex(random_bytes(24));
|
||||
$expires_at = time() + $ttl;
|
||||
$ok = $wpdb->insert($table, [
|
||||
'token' => $token,
|
||||
'user_id' => max(0, $uid),
|
||||
'post_id' => $post_id,
|
||||
'asset_id' => (int) ($asset['id'] ?? 0),
|
||||
'source_index' => (int) ($asset['source_index'] ?? 0),
|
||||
'cost' => 0,
|
||||
'ip_bind' => $uid > 0 ? null : self::request_fingerprint(),
|
||||
'ua_hash' => $uid > 0 ? null : sha1(self::user_agent()),
|
||||
'used_count' => 0,
|
||||
'max_uses' => $max_uses,
|
||||
'expires_at' => gmdate('Y-m-d H:i:s', $expires_at),
|
||||
'created_at' => gmdate('Y-m-d H:i:s'),
|
||||
], ['%s', '%d', '%d', '%d', '%d', '%d', '%s', '%s', '%d', '%d', '%s', '%s']);
|
||||
if (!$ok) {
|
||||
return new \WP_Error('jio_download_unavailable', __('下载服务暂不可用。', 'jio'), ['status' => 500]);
|
||||
}
|
||||
|
||||
return [
|
||||
'url' => rest_url(self::REST_NAMESPACE . '/protected-download/file/' . $token),
|
||||
'expires_at' => $expires_at,
|
||||
'max_uses' => $max_uses,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验令牌并流式输出文件。
|
||||
*/
|
||||
public function rest_stream(\WP_REST_Request $request) {
|
||||
$token = strtolower((string) $request->get_param('token'));
|
||||
if (!preg_match('/^[a-f0-9]{48}$/', $token)) {
|
||||
return new \WP_Error('jio_token_invalid', __('下载链接无效。', 'jio'), ['status' => 400]);
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = \Jio\Core\DBMigrator::table('dl_token');
|
||||
if ($table === '') {
|
||||
return new \WP_Error('jio_download_unavailable', __('下载服务暂不可用。', 'jio'), ['status' => 503]);
|
||||
}
|
||||
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE token = %s LIMIT 1", $token), ARRAY_A);
|
||||
if (!is_array($row)) {
|
||||
return new \WP_Error('jio_token_invalid', __('下载链接无效。', 'jio'), ['status' => 404]);
|
||||
}
|
||||
|
||||
$now = time();
|
||||
if (strtotime((string) ($row['expires_at'] ?? '') . ' UTC') < $now) {
|
||||
return new \WP_Error('jio_token_expired', __('下载链接已过期,请重新获取。', 'jio'), ['status' => 410]);
|
||||
}
|
||||
if ((int) ($row['used_count'] ?? 0) >= (int) ($row['max_uses'] ?? 1)) {
|
||||
return new \WP_Error('jio_token_exhausted', __('下载次数已用完,请重新获取。', 'jio'), ['status' => 410]);
|
||||
}
|
||||
|
||||
$uid = get_current_user_id();
|
||||
$bound_uid = (int) ($row['user_id'] ?? 0);
|
||||
if ($bound_uid > 0 && $uid !== $bound_uid) {
|
||||
return new \WP_Error('jio_token_forbidden', __('该下载链接不属于当前账号。', 'jio'), ['status' => 403]);
|
||||
}
|
||||
if ($bound_uid <= 0) {
|
||||
$fp = (string) ($row['ip_bind'] ?? '');
|
||||
$ua = (string) ($row['ua_hash'] ?? '');
|
||||
if ($fp !== '' && !hash_equals($fp, self::request_fingerprint())) {
|
||||
return new \WP_Error('jio_token_forbidden', __('下载链接与当前网络不匹配。', 'jio'), ['status' => 403]);
|
||||
}
|
||||
if ($ua !== '' && !hash_equals($ua, sha1(self::user_agent()))) {
|
||||
return new \WP_Error('jio_token_forbidden', __('下载链接与当前设备不匹配。', 'jio'), ['status' => 403]);
|
||||
}
|
||||
}
|
||||
|
||||
$post_id = (int) ($row['post_id'] ?? 0);
|
||||
// 到期前权限可能变化(退款 / VIP 到期 / 配额耗尽),每次下载都重新判定。
|
||||
$gate = $this->authorize($post_id);
|
||||
if (is_wp_error($gate)) {
|
||||
return $gate;
|
||||
}
|
||||
|
||||
$asset = PrivateAssets::get((string) ($this->asset_key_for_id((int) ($row['asset_id'] ?? 0))));
|
||||
if ($asset === null) {
|
||||
return new \WP_Error('jio_private_asset_missing', __('附件不存在。', 'jio'), ['status' => 404]);
|
||||
}
|
||||
$path = PrivateAssets::file_path($asset);
|
||||
if ($path === '') {
|
||||
return new \WP_Error('jio_private_asset_missing', __('附件文件已离线。', 'jio'), ['status' => 404]);
|
||||
}
|
||||
|
||||
// 先原子预占一次下载额度,传输中断/失败时再归还,避免并发绕过次数限制。
|
||||
$reserved = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$table} SET used_count = used_count + 1 WHERE token = %s AND used_count < max_uses AND expires_at > %s",
|
||||
$token,
|
||||
gmdate('Y-m-d H:i:s', $now)
|
||||
));
|
||||
if (!$reserved) {
|
||||
return new \WP_Error('jio_token_exhausted', __('下载次数已用完,请重新获取。', 'jio'), ['status' => 410]);
|
||||
}
|
||||
|
||||
$delivered = $this->stream($path, $asset);
|
||||
if (!$delivered) {
|
||||
$wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$table} SET used_count = GREATEST(used_count - 1, 0) WHERE token = %s",
|
||||
$token
|
||||
));
|
||||
return new \WP_Error('jio_download_interrupted', __('下载中断,请重试。', 'jio'), ['status' => 503]);
|
||||
}
|
||||
|
||||
if (function_exists('jio_bump_content_download_count')) {
|
||||
jio_bump_content_download_count($post_id);
|
||||
}
|
||||
|
||||
// 文件已直接输出到响应流。
|
||||
exit;
|
||||
}
|
||||
|
||||
private function asset_key_for_id(int $asset_id): string {
|
||||
global $wpdb;
|
||||
if ($asset_id <= 0) {
|
||||
return '';
|
||||
}
|
||||
$table = \Jio\Core\DBMigrator::table('private_asset');
|
||||
if ($table === '') {
|
||||
return '';
|
||||
}
|
||||
$key = $wpdb->get_var($wpdb->prepare("SELECT asset_key FROM {$table} WHERE id = %d LIMIT 1", $asset_id));
|
||||
return is_string($key) ? $key : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出文件;仅在完整写入后返回 true。
|
||||
*
|
||||
* @param array<string, mixed> $asset
|
||||
*/
|
||||
private function stream(string $path, array $asset): bool {
|
||||
$size = (int) @filesize($path);
|
||||
if ($size <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@set_time_limit(0);
|
||||
ignore_user_abort(true);
|
||||
while (ob_get_level() > 0) {
|
||||
@ob_end_clean();
|
||||
}
|
||||
|
||||
$name = (string) ($asset['file_name'] ?? 'download');
|
||||
$mime = (string) ($asset['mime'] ?? 'application/octet-stream');
|
||||
|
||||
nocache_headers();
|
||||
header('Content-Type: ' . ($mime !== '' ? $mime : 'application/octet-stream'));
|
||||
header('Content-Disposition: attachment; filename="' . str_replace('"', '', $name) . '"');
|
||||
header('Content-Length: ' . $size);
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Robots-Tag: noindex, nofollow');
|
||||
|
||||
$handle = @fopen($path, 'rb');
|
||||
if (!$handle) {
|
||||
return false;
|
||||
}
|
||||
$sent = 0;
|
||||
while (!feof($handle)) {
|
||||
if (connection_aborted()) {
|
||||
fclose($handle);
|
||||
return false;
|
||||
}
|
||||
$chunk = fread($handle, 8192);
|
||||
if ($chunk === false || $chunk === '') {
|
||||
break;
|
||||
}
|
||||
echo $chunk; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- raw file bytes
|
||||
flush();
|
||||
$sent += strlen($chunk);
|
||||
}
|
||||
fclose($handle);
|
||||
|
||||
return $sent === $size;
|
||||
}
|
||||
|
||||
private static function request_fingerprint(): string {
|
||||
$ip = function_exists('jio_get_client_ip') ? (string) jio_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? '');
|
||||
return $ip !== '' ? hash('sha256', $ip) : '';
|
||||
}
|
||||
|
||||
private static function user_agent(): string {
|
||||
return substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
<?php
|
||||
/**
|
||||
* Jio Creator Studio 的 REST 接口。
|
||||
*
|
||||
* 只做前台创作体验需要的编排:读取编辑上下文、保存/自动保存、提交审核、
|
||||
* 预览链接、私有资料上传。所有文章写入仍复用 FrontSubmit 的权限、字段清洗
|
||||
* 与状态机;已发布文章的改动走 SubmissionVersion,不直接覆盖线上内容。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
namespace Jio\Core;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class CreatorRest {
|
||||
|
||||
public const NS = 'jio/v1';
|
||||
|
||||
public function register_routes(): void {
|
||||
register_rest_route(self::NS, '/creator/bootstrap', [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'rest_bootstrap'],
|
||||
'permission_callback' => [$this, 'can_use_studio'],
|
||||
'args' => [
|
||||
'post_id' => ['default' => 0, 'sanitize_callback' => 'absint'],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route(self::NS, '/creator/save', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'rest_save'],
|
||||
'permission_callback' => [$this, 'can_use_studio'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::NS, '/creator/submit', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'rest_submit'],
|
||||
'permission_callback' => [$this, 'can_use_studio'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::NS, '/creator/preview', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'rest_preview'],
|
||||
'permission_callback' => [$this, 'can_use_studio'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::NS, '/creator/private-upload', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'rest_private_upload'],
|
||||
'permission_callback' => [$this, 'can_upload_private'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::NS, '/creator/private-uploads', [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'rest_list_private_uploads'],
|
||||
'permission_callback' => [$this, 'can_upload_private'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function can_use_studio($request = null): bool {
|
||||
$user_id = get_current_user_id();
|
||||
if ($user_id <= 0) {
|
||||
return false;
|
||||
}
|
||||
$front = FrontSubmit::get_instance();
|
||||
if (empty($front->settings()['enabled'])) {
|
||||
return false;
|
||||
}
|
||||
$post_id = 0;
|
||||
if ($request instanceof \WP_REST_Request) {
|
||||
$post_id = absint($request->get_param('post_id'));
|
||||
} elseif (isset($_REQUEST['post_id'])) {
|
||||
$post_id = absint(wp_unslash($_REQUEST['post_id']));
|
||||
}
|
||||
if ($post_id > 0) {
|
||||
return $front->can_edit_post($post_id, $user_id);
|
||||
}
|
||||
return $front->can_submit($user_id);
|
||||
}
|
||||
|
||||
public function can_upload_private(): bool {
|
||||
$user_id = get_current_user_id();
|
||||
if ($user_id <= 0) {
|
||||
return false;
|
||||
}
|
||||
return FrontSubmit::get_instance()->user_can_cap('new_post_pay_download', $user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑上下文:作者打开创作页时的一切初始数据。
|
||||
*/
|
||||
public function rest_bootstrap(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$post_id = absint($request->get_param('post_id'));
|
||||
$user_id = get_current_user_id();
|
||||
|
||||
return new \WP_REST_Response($this->bootstrap_payload($post_id, $user_id), 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function bootstrap_payload(int $post_id, int $user_id): array {
|
||||
$front = FrontSubmit::get_instance();
|
||||
$settings = $front->settings();
|
||||
$source = $post_id > 0 ? get_post($post_id) : null;
|
||||
$version = null;
|
||||
$version_id = 0;
|
||||
|
||||
if ($source instanceof \WP_Post && SubmissionVersion::requires_version($post_id)) {
|
||||
$version = SubmissionVersion::get_working($post_id, $user_id);
|
||||
$version_id = $version instanceof \WP_Post ? (int) $version->ID : 0;
|
||||
}
|
||||
|
||||
$editable = $version instanceof \WP_Post
|
||||
? $version
|
||||
: ($source instanceof \WP_Post ? $source : null);
|
||||
|
||||
$pay_meta = $editable instanceof \WP_Post && function_exists('jio_get_pay_meta')
|
||||
? jio_get_pay_meta($editable->ID)
|
||||
: DomainModel::default_pay_meta();
|
||||
$snapshot = $version_id > 0 ? SubmissionVersion::snapshot($version_id) : [];
|
||||
|
||||
$post_meta = $source instanceof \WP_Post
|
||||
? (array) get_post_meta($source->ID, DomainModel::POST_META_KEY, true)
|
||||
: [];
|
||||
|
||||
$terms = $this->term_payload($settings, $source, $snapshot);
|
||||
|
||||
return [
|
||||
'sourceId' => $post_id,
|
||||
'versionId' => $version_id,
|
||||
'versionRev' => $version_id > 0 ? SubmissionVersion::revision($version_id) : 0,
|
||||
'status' => $version_id > 0 ? SubmissionVersion::status($version_id) : ($source->post_status ?? ''),
|
||||
'reviewNote' => $version_id > 0 ? (string) get_post_meta($version_id, SubmissionVersion::META_NOTE, true) : '',
|
||||
'title' => $editable instanceof \WP_Post ? $editable->post_title : '',
|
||||
'excerpt' => $editable instanceof \WP_Post ? $editable->post_excerpt : '',
|
||||
'content' => $editable instanceof \WP_Post ? $editable->post_content : '',
|
||||
'permalink' => $source instanceof \WP_Post ? get_permalink($source) : '',
|
||||
'isPublished'=> $source instanceof \WP_Post && $source->post_status === 'publish',
|
||||
'categories' => $terms['categories'],
|
||||
'tags' => $terms['tags'],
|
||||
'custom' => $terms['custom'],
|
||||
'customDefs' => $terms['customDefs'],
|
||||
'featured' => $this->creator_featured_payload($source, $post_meta),
|
||||
'pay' => $this->pay_payload((array) $pay_meta, $snapshot, $settings),
|
||||
'capabilities' => $this->capability_payload($front, $user_id, $post_id),
|
||||
'limits' => [
|
||||
'titleMin' => (int) $settings['title_min'],
|
||||
'titleMax' => (int) $settings['title_max'],
|
||||
'titlePlaceholder' => (string) $settings['title_placeholder'],
|
||||
'contentPlaceholder' => (string) $settings['content_placeholder'],
|
||||
],
|
||||
'privateUploads' => $this->private_upload_rows($user_id, $post_id),
|
||||
'privateStorage' => PrivateAssets::is_available(),
|
||||
'media' => [
|
||||
'listUrl' => esc_url_raw(rest_url(self::NS . '/front-submit/media')),
|
||||
'uploadUrl' => [
|
||||
'image' => esc_url_raw(rest_url(self::NS . '/front-submit/upload/image')),
|
||||
],
|
||||
'maxBytes' => FrontSubmit::upload_limit_bytes('image'),
|
||||
'batch' => class_exists('\Jio\Attachment\UploadLimits') ? \Jio\Attachment\UploadLimits::image_batch_limit() : 6,
|
||||
],
|
||||
'rest' => [
|
||||
'root' => esc_url_raw(rest_url()),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
],
|
||||
'urls' => [
|
||||
'submit' => function_exists('jio_get_new_post_url') ? jio_get_new_post_url() : '',
|
||||
'newPost' => function_exists('jio_get_new_post_url') ? jio_get_new_post_url() : '',
|
||||
'center' => function_exists('jio_get_user_center_url') ? jio_get_user_center_url('posts') : '',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存或自动保存。已发布文章写入版本记录;新文章走既有 persist()。
|
||||
*/
|
||||
public function rest_save(\WP_REST_Request $request) {
|
||||
$user_id = get_current_user_id();
|
||||
$post_id = absint($request->get_param('post_id'));
|
||||
$is_auto = (bool) $request->get_param('autosave');
|
||||
$payload = [
|
||||
'title' => (string) $request->get_param('title'),
|
||||
'content' => (string) $request->get_param('content'),
|
||||
'excerpt' => (string) $request->get_param('excerpt'),
|
||||
'categories' => (array) $request->get_param('categories'),
|
||||
'tags' => (array) $request->get_param('tags'),
|
||||
'custom' => (array) $request->get_param('custom'),
|
||||
'featured' => (array) $request->get_param('featured'),
|
||||
'pay' => (array) $request->get_param('pay'),
|
||||
];
|
||||
|
||||
if ($post_id > 0 && SubmissionVersion::requires_version($post_id)) {
|
||||
$result = SubmissionVersion::get_instance()->save_working($post_id, $user_id, [
|
||||
'title' => $payload['title'] !== '' ? $payload['title'] : null,
|
||||
'content' => $payload['content'],
|
||||
'excerpt' => $payload['excerpt'],
|
||||
'base_rev' => absint($request->get_param('rev')),
|
||||
'snapshot' => $this->snapshot_from_payload($payload),
|
||||
], false);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
$result['autosave'] = $is_auto;
|
||||
return new \WP_REST_Response($result, 200);
|
||||
}
|
||||
|
||||
$front = FrontSubmit::get_instance();
|
||||
$data = [
|
||||
'posts_id' => $post_id,
|
||||
'post_title' => $payload['title'],
|
||||
'post_content' => $this->block_content($payload['content']),
|
||||
'excerpt' => $payload['excerpt'],
|
||||
'category' => $payload['categories'],
|
||||
'tags' => implode(',', array_map('strval', $payload['tags'])),
|
||||
'featured_data'=> $payload['featured'],
|
||||
];
|
||||
foreach ($payload['custom'] as $key => $value) {
|
||||
$data['cf_' . sanitize_key((string) $key)] = $value;
|
||||
}
|
||||
if ($payload['pay'] !== []) {
|
||||
$data['pay_enabled'] = !empty($payload['pay']['enabled']) ? '1' : '';
|
||||
$data['jio_pay'] = (array) ($payload['pay']['fields'] ?? []);
|
||||
}
|
||||
|
||||
$result = $front->persist($data, 'posts_draft');
|
||||
if (empty($result['success'])) {
|
||||
return new \WP_Error('jio_creator_save_failed', (string) ($result['message'] ?? __('保存失败', 'jio')), ['status' => 400]);
|
||||
}
|
||||
$result['autosave'] = $is_auto;
|
||||
$result['versionRev'] = max(1, (int) ($result['rev'] ?? 1));
|
||||
return new \WP_REST_Response($result, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交审核 / 直接发布。
|
||||
*/
|
||||
public function rest_submit(\WP_REST_Request $request) {
|
||||
$user_id = get_current_user_id();
|
||||
$post_id = absint($request->get_param('post_id'));
|
||||
$front = FrontSubmit::get_instance();
|
||||
|
||||
// 与 admin-ajax 投稿入口保持同一道人机验证闸门(启用 front_submit 场景时)。
|
||||
if (function_exists('jio_require_captcha')) {
|
||||
$captcha = jio_require_captcha('front_submit', [
|
||||
'jio_captcha_proof' => sanitize_text_field((string) $request->get_param('captcha_proof')),
|
||||
]);
|
||||
if ($captcha !== true) {
|
||||
$message = is_wp_error($captcha) ? $captcha->get_error_message() : __('人机验证失败,请重试', 'jio');
|
||||
return new \WP_Error('jio_captcha_failed', $message, ['status' => 400]);
|
||||
}
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'title' => (string) $request->get_param('title'),
|
||||
'content' => (string) $request->get_param('content'),
|
||||
'excerpt' => (string) $request->get_param('excerpt'),
|
||||
'categories' => (array) $request->get_param('categories'),
|
||||
'tags' => (array) $request->get_param('tags'),
|
||||
'custom' => (array) $request->get_param('custom'),
|
||||
'featured' => (array) $request->get_param('featured'),
|
||||
'pay' => (array) $request->get_param('pay'),
|
||||
];
|
||||
|
||||
if ($post_id > 0 && SubmissionVersion::requires_version($post_id)) {
|
||||
$invalid = $this->validate_submission($payload, $user_id);
|
||||
if ($invalid !== '') {
|
||||
return new \WP_Error('jio_creator_invalid', $invalid, ['status' => 400]);
|
||||
}
|
||||
$version = SubmissionVersion::get_instance()->save_working($post_id, $user_id, [
|
||||
'title' => $payload['title'],
|
||||
'content' => $payload['content'],
|
||||
'excerpt' => $payload['excerpt'],
|
||||
'base_rev' => absint($request->get_param('rev')),
|
||||
'snapshot' => $this->snapshot_from_payload($payload),
|
||||
], true);
|
||||
if (is_wp_error($version)) {
|
||||
return $version;
|
||||
}
|
||||
|
||||
// 具备免审发布权限且明确选择立即发布时,才直接应用到线上文章。
|
||||
if ($request->get_param('publish_now') && $front->user_can_cap('new_post_audit_no', $user_id)) {
|
||||
$applied = SubmissionVersion::get_instance()->publish_working($post_id, $user_id);
|
||||
if (empty($applied['success'])) {
|
||||
return new \WP_Error('jio_creator_publish_failed', (string) ($applied['message'] ?? __('发布失败', 'jio')), ['status' => 400]);
|
||||
}
|
||||
return new \WP_REST_Response($applied, 200);
|
||||
}
|
||||
|
||||
return new \WP_REST_Response($version, 200);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'posts_id' => $post_id,
|
||||
'post_title' => $payload['title'],
|
||||
'post_content' => $this->block_content($payload['content']),
|
||||
'excerpt' => $payload['excerpt'],
|
||||
'category' => $payload['categories'],
|
||||
'tags' => implode(',', array_map('strval', $payload['tags'])),
|
||||
'featured_data' => $payload['featured'],
|
||||
];
|
||||
foreach ($payload['custom'] as $key => $value) {
|
||||
$data['cf_' . sanitize_key((string) $key)] = $value;
|
||||
}
|
||||
if ($payload['pay'] !== []) {
|
||||
$data['pay_enabled'] = !empty($payload['pay']['enabled']) ? '1' : '';
|
||||
$data['jio_pay'] = (array) ($payload['pay']['fields'] ?? []);
|
||||
}
|
||||
|
||||
$result = $front->persist($data, 'posts_save');
|
||||
if (empty($result['success'])) {
|
||||
return new \WP_Error('jio_creator_submit_failed', (string) ($result['message'] ?? __('提交失败', 'jio')), ['status' => 400]);
|
||||
}
|
||||
|
||||
$this->claim_private_assets($payload, (int) ($result['post_id'] ?? 0), $user_id);
|
||||
return new \WP_REST_Response($result, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览链接:已发布文章的改稿版本使用一次性签名地址,不公开版本内容。
|
||||
*/
|
||||
public function rest_preview(\WP_REST_Request $request) {
|
||||
$user_id = get_current_user_id();
|
||||
$post_id = absint($request->get_param('post_id'));
|
||||
$version_id = absint($request->get_param('version_id'));
|
||||
|
||||
if ($version_id > 0) {
|
||||
$version = get_post($version_id);
|
||||
if (!$version instanceof \WP_Post || $version->post_type !== SubmissionVersion::POST_TYPE) {
|
||||
return new \WP_Error('jio_review_preview_missing', __('改稿不存在。', 'jio'), ['status' => 404]);
|
||||
}
|
||||
if ((int) $version->post_author !== $user_id && !SubmissionVersion::can_review($user_id)) {
|
||||
return new \WP_Error('jio_review_preview_forbidden', __('没有预览权限。', 'jio'), ['status' => 403]);
|
||||
}
|
||||
$token = PreviewToken::issue($version_id, $user_id);
|
||||
$source_id = SubmissionVersion::source_id($version_id);
|
||||
$source = $source_id > 0 ? get_post($source_id) : null;
|
||||
$base = $source instanceof \WP_Post ? (string) get_permalink($source) : home_url('/');
|
||||
return new \WP_REST_Response([
|
||||
'url' => add_query_arg('jio_studio_preview', $token, $base),
|
||||
], 200);
|
||||
}
|
||||
|
||||
if ($post_id <= 0) {
|
||||
return new \WP_Error('jio_review_preview_missing', __('尚未保存文章。', 'jio'), ['status' => 400]);
|
||||
}
|
||||
$post = get_post($post_id);
|
||||
if (!$post instanceof \WP_Post) {
|
||||
return new \WP_Error('jio_review_preview_missing', __('文章不存在。', 'jio'), ['status' => 404]);
|
||||
}
|
||||
return new \WP_REST_Response([
|
||||
'url' => (string) get_preview_post_link($post),
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 私有资料上传(付费附件)。
|
||||
*/
|
||||
public function rest_private_upload(\WP_REST_Request $request) {
|
||||
$file = $request->get_file_params()['file'] ?? null;
|
||||
if (!is_array($file)) {
|
||||
return new \WP_Error('jio_private_upload_failed', __('请选择要上传的资料文件。', 'jio'), ['status' => 400]);
|
||||
}
|
||||
$post_id = absint($request->get_param('post_id'));
|
||||
$result = PrivateAssets::get_instance()->store_upload($file, get_current_user_id(), $post_id);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
return new \WP_REST_Response($result, 201);
|
||||
}
|
||||
|
||||
public function rest_list_private_uploads(\WP_REST_Request $request): \WP_REST_Response {
|
||||
return new \WP_REST_Response([
|
||||
'items' => $this->private_upload_rows(get_current_user_id(), absint($request->get_param('post_id'))),
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function private_upload_rows(int $user_id, int $post_id): array {
|
||||
global $wpdb;
|
||||
$table = DBMigrator::table('private_asset');
|
||||
if ($table === '' || $user_id <= 0) {
|
||||
return [];
|
||||
}
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT asset_key, post_id, file_name, mime, size_bytes, created_at FROM {$table}
|
||||
WHERE owner_id = %d AND (post_id = %d OR post_id = 0) ORDER BY id DESC LIMIT 50",
|
||||
$user_id,
|
||||
$post_id
|
||||
), ARRAY_A);
|
||||
|
||||
$items = [];
|
||||
foreach (is_array($rows) ? $rows : [] as $row) {
|
||||
$items[] = [
|
||||
'assetKey' => (string) $row['asset_key'],
|
||||
'postId' => (int) $row['post_id'],
|
||||
'fileName' => (string) $row['file_name'],
|
||||
'mime' => (string) $row['mime'],
|
||||
'sizeBytes' => (int) $row['size_bytes'],
|
||||
'sizeLabel' => size_format((int) $row['size_bytes']),
|
||||
];
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交前的必填校验,与 FrontSubmitSave 保持同一口径。
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function validate_submission(array $payload, int $user_id): string {
|
||||
$settings = FrontSubmit::get_instance()->settings();
|
||||
$title = trim(wp_strip_all_tags((string) ($payload['title'] ?? '')));
|
||||
$content = trim(wp_strip_all_tags((string) ($payload['content'] ?? '')));
|
||||
if ($title === '') {
|
||||
return __('请填写文章标题', 'jio');
|
||||
}
|
||||
$length = function_exists('mb_strlen') ? mb_strlen($title) : strlen($title);
|
||||
if ($length < (int) $settings['title_min']) {
|
||||
return __('标题太短!', 'jio');
|
||||
}
|
||||
if ($length > (int) $settings['title_max']) {
|
||||
return sprintf(__('标题太长了,不能超过%d个字', 'jio'), (int) $settings['title_max']);
|
||||
}
|
||||
if ((function_exists('mb_strlen') ? mb_strlen($content) : strlen($content)) < 10) {
|
||||
return __('文章内容过少', 'jio');
|
||||
}
|
||||
if (empty($payload['categories'])) {
|
||||
return __('请选择文章分类', 'jio');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 组合提交时的版本快照(分类、标签、封面、付费)。
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function snapshot_from_payload(array $payload): array {
|
||||
$downloads = [];
|
||||
foreach ((array) ($payload['pay']['fields']['downloads'] ?? []) as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$key = PrivateAssets::sanitize_asset_key((string) ($row['asset_key'] ?? ''));
|
||||
if ($key !== '') {
|
||||
$asset = PrivateAssets::get($key);
|
||||
if ($asset === null || $asset['owner_id'] !== get_current_user_id()) {
|
||||
continue;
|
||||
}
|
||||
$downloads[] = [
|
||||
'type' => 'protected',
|
||||
'asset_key' => $key,
|
||||
'file_name' => $asset['file_name'],
|
||||
'size_bytes' => $asset['size_bytes'],
|
||||
'label' => (string) ($row['label'] ?? ''),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
$url = esc_url_raw((string) ($row['url'] ?? ''));
|
||||
if ($url !== '') {
|
||||
$downloads[] = [
|
||||
'type' => 'direct',
|
||||
'url' => $url,
|
||||
'label' => (string) ($row['label'] ?? ''),
|
||||
'extract_code' => (string) ($row['extract_code'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'categories' => array_values(array_filter(array_map('absint', (array) $payload['categories']))),
|
||||
'tags' => array_values(array_filter(array_map('sanitize_text_field', (array) $payload['tags']))),
|
||||
'custom' => is_array($payload['custom']) ? $payload['custom'] : [],
|
||||
'featured' => is_array($payload['featured']) ? $payload['featured'] : [],
|
||||
'pay' => [
|
||||
'enabled' => !empty($payload['pay']['enabled']),
|
||||
'fields' => array_merge(
|
||||
(array) ($payload['pay']['fields'] ?? []),
|
||||
['downloads' => $downloads]
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function claim_private_assets(array $payload, int $post_id, int $user_id): void {
|
||||
if ($post_id <= 0) {
|
||||
return;
|
||||
}
|
||||
$keys = [];
|
||||
foreach ((array) ($payload['pay']['fields']['downloads'] ?? []) as $row) {
|
||||
if (is_array($row)) {
|
||||
$key = PrivateAssets::sanitize_asset_key((string) ($row['asset_key'] ?? ''));
|
||||
if ($key !== '') {
|
||||
$keys[] = $key;
|
||||
}
|
||||
}
|
||||
}
|
||||
PrivateAssets::claim_many($keys, $post_id, $user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端提交的是 JSON 区块,落到 post_content 前统一交给 wp_kses。
|
||||
*/
|
||||
private function block_content(string $content): string {
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $settings
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function term_payload(array $settings, ?\WP_Post $source, array $snapshot): array {
|
||||
$cat_args = [
|
||||
'taxonomy' => 'category',
|
||||
'hide_empty' => false,
|
||||
'orderby' => 'name',
|
||||
'order' => 'ASC',
|
||||
];
|
||||
if (!empty($settings['allowed_cat_ids'])) {
|
||||
$cat_args['include'] = $settings['allowed_cat_ids'];
|
||||
} else {
|
||||
$cat_args['parent'] = 0;
|
||||
}
|
||||
$terms = get_terms($cat_args);
|
||||
$categories = [];
|
||||
foreach (is_array($terms) ? $terms : [] as $term) {
|
||||
$categories[] = ['id' => (int) $term->term_id, 'name' => (string) $term->name];
|
||||
}
|
||||
|
||||
$current = $source instanceof \WP_Post && $snapshot === []
|
||||
? array_map('intval', wp_get_post_categories($source->ID))
|
||||
: array_map('intval', (array) ($snapshot['categories'] ?? []));
|
||||
$tags = $source instanceof \WP_Post && $snapshot === []
|
||||
? wp_get_post_tags($source->ID, ['fields' => 'names'])
|
||||
: array_map('strval', (array) ($snapshot['tags'] ?? []));
|
||||
|
||||
$custom_defs = [];
|
||||
$custom = [];
|
||||
if (!empty($settings['custom_filter_s']) && class_exists('\Jio\Settings\Settings')) {
|
||||
$filter_all = \Jio\Settings\Settings::get_post_filter_settings();
|
||||
$dims = is_array($filter_all['custom_filter'] ?? null) ? $filter_all['custom_filter'] : [];
|
||||
foreach ($dims as $dim) {
|
||||
$key = sanitize_key((string) ($dim['key'] ?? ''));
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$vals = [];
|
||||
foreach ((array) ($dim['vals'] ?? []) as $value) {
|
||||
$vkey = (string) ($value['key'] ?? '');
|
||||
if ($vkey === '') {
|
||||
continue;
|
||||
}
|
||||
$vals[] = ['key' => $vkey, 'name' => (string) ($value['name'] ?? $vkey)];
|
||||
}
|
||||
$custom_defs[] = ['key' => $key, 'name' => (string) ($dim['name'] ?? $key), 'values' => $vals];
|
||||
$saved = $snapshot !== [] ? ($snapshot['custom'][$key] ?? []) : ($source instanceof \WP_Post ? get_post_meta($source->ID, '_jio_cf_' . $key, true) : []);
|
||||
$custom[$key] = is_array($saved) ? array_values(array_map('strval', $saved)) : ($saved !== '' ? [(string) $saved] : []);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'categories' => ['items' => $categories, 'selected' => $current],
|
||||
'tags' => ['items' => is_array($tags) ? array_values($tags) : [], 'enabled' => !empty($settings['allow_tags'])],
|
||||
'custom' => $custom,
|
||||
'customDefs' => $custom_defs,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $post_meta
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function creator_featured_payload(?\WP_Post $source, array $post_meta): array {
|
||||
$type = '';
|
||||
$data = [];
|
||||
$id = 0;
|
||||
if ($source instanceof \WP_Post) {
|
||||
$thumb_id = (int) get_post_thumbnail_id($source->ID);
|
||||
$url = $thumb_id > 0 ? (string) wp_get_attachment_image_url($thumb_id, 'full') : (string) ($post_meta['thumbnail_url'] ?? '');
|
||||
if ($url !== '') {
|
||||
$type = 'image';
|
||||
$id = $thumb_id;
|
||||
$data = ['url' => $url, 'id' => $thumb_id];
|
||||
}
|
||||
}
|
||||
$front = FrontSubmit::get_instance();
|
||||
$user = get_current_user_id();
|
||||
return [
|
||||
'type' => $type,
|
||||
'data' => $data,
|
||||
'mediaId' => $id,
|
||||
'canImage' => $front->user_can_cap('new_post_image_cover', $user),
|
||||
'canSlide' => $front->user_can_cap('new_post_slide_cover', $user),
|
||||
'canVideo' => $front->user_can_cap('new_post_video_cover', $user),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $pay_meta
|
||||
* @param array<string, mixed> $settings
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function pay_payload(array $pay_meta, array $snapshot, array $settings): array {
|
||||
$front = FrontSubmit::get_instance();
|
||||
$user = get_current_user_id();
|
||||
$pay = $snapshot['pay']['fields'] ?? $pay_meta;
|
||||
$enabled = $snapshot !== [] ? !empty($snapshot['pay']['enabled']) : (($pay_meta['pay_type'] ?? 'no') !== 'no');
|
||||
|
||||
$downloads = [];
|
||||
foreach ((array) ($pay['downloads'] ?? []) as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$key = PrivateAssets::sanitize_asset_key((string) ($row['asset_key'] ?? ''));
|
||||
$downloads[] = [
|
||||
'type' => $key !== '' ? 'protected' : (string) ($row['type'] ?? 'direct'),
|
||||
'assetKey' => $key,
|
||||
'url' => $key !== '' ? '' : (string) ($row['url'] ?? ''),
|
||||
'label' => (string) ($row['label'] ?? ''),
|
||||
'fileName' => (string) ($row['file_name'] ?? ''),
|
||||
'sizeBytes' => (int) ($row['size_bytes'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => (bool) $enabled,
|
||||
'canPay' => $front->user_can_cap('new_post_pay', $user),
|
||||
'canDownload' => $front->user_can_cap('new_post_pay_download', $user),
|
||||
'payModo' => (($pay['pay_modo'] ?? '0') === 'points') ? 'points' : 'cash',
|
||||
'price' => (string) ($pay['price'] ?? '0.00'),
|
||||
'points' => (int) ($pay['points_price'] ?? 0),
|
||||
'vip1Price' => (string) ($pay['vip1_price'] ?? '0.00'),
|
||||
'vip2Price' => (string) ($pay['vip2_price'] ?? '0.00'),
|
||||
'payLimit' => (int) ($pay['pay_limit'] ?? 0),
|
||||
'downloads' => $downloads,
|
||||
'freeAllowed' => !empty($settings['pay_free_s']),
|
||||
'priceMin' => (int) ($settings['pay_price_min'] ?? 0),
|
||||
'priceMax' => (int) ($settings['pay_price_max'] ?? 0),
|
||||
'pointsMin' => (int) ($settings['pay_points_min'] ?? 0),
|
||||
'pointsMax' => (int) ($settings['pay_points_max'] ?? 0),
|
||||
'privateStorage' => PrivateAssets::is_available(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, bool>
|
||||
*/
|
||||
private function capability_payload(FrontSubmit $front, int $user_id, int $post_id): array {
|
||||
$cap = static function (string $name) use ($front, $user_id, $post_id): bool {
|
||||
return $front->user_can_cap($name, $user_id, $post_id > 0 ? $post_id : null);
|
||||
};
|
||||
return [
|
||||
'canUploadImage' => $cap('upload_submit_image'),
|
||||
'canUploadVideo' => $cap('new_post_upload_video'),
|
||||
'canUploadFile' => $cap('upload_resource_attachment'),
|
||||
'canEmbed' => $cap('new_post_iframe_video'),
|
||||
'canHide' => $cap('new_post_hide'),
|
||||
'canPay' => $cap('new_post_pay'),
|
||||
'canPayDownload' => $cap('new_post_pay_download'),
|
||||
'canPublishNow' => $cap('new_post_audit_no'),
|
||||
'canEdit' => $post_id > 0 ? $front->can_edit_post($post_id, $user_id) : $front->can_submit($user_id),
|
||||
'isLoggedIn' => $user_id > 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
/**
|
||||
* Jio Creator Studio 前台创作工作台。
|
||||
*
|
||||
* 与默认 WordPress 后台编辑器隔离的独立入口:只承载前台写作体验所需的静态资源、
|
||||
* 初始上下文与挂载点。写作界面是 Gutenberg 内核 + 自定义 React 工作台。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
namespace Jio\Core;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class CreatorStudio {
|
||||
|
||||
/** 构建产物目录(相对主题根)。 */
|
||||
public const BUILD_DIR = 'assets/creator-studio/build';
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
add_action('wp_enqueue_scripts', [$this, 'maybe_enqueue'], 20);
|
||||
}
|
||||
|
||||
/** 当前请求是否为投稿模板。 */
|
||||
public function is_studio_request(): bool {
|
||||
return is_page_template('templates/pages/page-submit.php');
|
||||
}
|
||||
|
||||
/** 构建产物存在且带依赖清单。 */
|
||||
public static function is_built(): bool {
|
||||
$dir = get_template_directory() . '/' . self::BUILD_DIR;
|
||||
return file_exists($dir . '/index.js') && file_exists($dir . '/index.asset.php');
|
||||
}
|
||||
|
||||
public function maybe_enqueue(): void {
|
||||
if (!$this->is_studio_request() || !self::is_built()) {
|
||||
return;
|
||||
}
|
||||
if (get_current_user_id() <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$dir = get_template_directory() . '/' . self::BUILD_DIR;
|
||||
$uri = get_template_directory_uri() . '/' . self::BUILD_DIR;
|
||||
$meta = include $dir . '/index.asset.php';
|
||||
$deps = is_array($meta['dependencies'] ?? null) ? $meta['dependencies'] : [];
|
||||
$ver = (string) ($meta['version'] ?? AKSRT_VERSION);
|
||||
|
||||
// 区块样式是前台编辑体验的一部分;编辑器专属样式只在已注册时追加,
|
||||
// 避免在没有注册该 handle 的前台请求上产生悬空依赖。
|
||||
$style_deps = ['jio-public'];
|
||||
foreach (['wp-block-library', 'wp-block-editor', 'wp-components'] as $handle) {
|
||||
if (wp_style_is($handle, 'registered')) {
|
||||
$style_deps[] = $handle;
|
||||
}
|
||||
}
|
||||
|
||||
wp_enqueue_style(
|
||||
'jio-creator-studio',
|
||||
$uri . '/index.css',
|
||||
$style_deps,
|
||||
$ver
|
||||
);
|
||||
|
||||
wp_enqueue_script(
|
||||
'jio-creator-studio',
|
||||
$uri . '/index.js',
|
||||
$deps,
|
||||
$ver,
|
||||
true
|
||||
);
|
||||
|
||||
// Jio 自有区块:编辑器里已注册的同一份定义,前台工作台复用,
|
||||
// 避免维护第二套 Jio Blocks。前台没有注册 wp-media-utils,
|
||||
// 该脚本只依赖 block-editor 的 MediaUpload,不需要它。
|
||||
$blocks_js = get_template_directory() . '/assets/js/gutenberg-blocks.js';
|
||||
if (file_exists($blocks_js) && !wp_script_is('jio-gutenberg-blocks', 'enqueued')) {
|
||||
wp_enqueue_script(
|
||||
'jio-gutenberg-blocks',
|
||||
get_template_directory_uri() . '/assets/js/gutenberg-blocks.js',
|
||||
['wp-blocks', 'wp-element', 'wp-components', 'wp-block-editor', 'wp-hooks', 'wp-data'],
|
||||
(string) filemtime($blocks_js),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// 区块前台样式(jio/carousel 等自定义区块的输出样式)。
|
||||
$blocks_css = get_template_directory() . '/assets/css/gutenberg-blocks.css';
|
||||
if (file_exists($blocks_css)) {
|
||||
wp_enqueue_style(
|
||||
'jio-gutenberg-blocks-front',
|
||||
get_template_directory_uri() . '/assets/css/gutenberg-blocks.css',
|
||||
[],
|
||||
(string) filemtime($blocks_css)
|
||||
);
|
||||
}
|
||||
|
||||
wp_add_inline_script(
|
||||
'jio-creator-studio',
|
||||
'window.jioCreatorStudio = ' . wp_json_encode($this->bootstrap()) . ';',
|
||||
'before'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染给前端的启动上下文。
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function bootstrap(): array {
|
||||
$post_id = isset($_GET['edit']) ? absint($_GET['edit']) : 0;
|
||||
$rest = new CreatorRest();
|
||||
$data = $rest->bootstrap_payload($post_id, get_current_user_id());
|
||||
|
||||
return array_merge($data, [
|
||||
'root' => rest_url(),
|
||||
'ajax' => admin_url('admin-ajax.php'),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
'captcha' => [
|
||||
'enabled' => function_exists('jio_is_captcha_enabled_for') && jio_is_captcha_enabled_for('front_submit'),
|
||||
],
|
||||
'creator' => [
|
||||
'saveUrl' => esc_url_raw(rest_url(CreatorRest::NS . '/creator/save')),
|
||||
'submitUrl' => esc_url_raw(rest_url(CreatorRest::NS . '/creator/submit')),
|
||||
'previewUrl'=> esc_url_raw(rest_url(CreatorRest::NS . '/creator/preview')),
|
||||
'privateUrl'=> esc_url_raw(rest_url(CreatorRest::NS . '/creator/private-upload')),
|
||||
'bootstrapUrl' => esc_url_raw(rest_url(CreatorRest::NS . '/creator/bootstrap')),
|
||||
],
|
||||
'blocks' => [
|
||||
'mediaUploadUrl' => esc_url_raw(rest_url(CreatorRest::NS . '/front-submit/upload/image')),
|
||||
'mediaListUrl' => esc_url_raw(rest_url(CreatorRest::NS . '/front-submit/media/image')),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,10 @@ class DBMigrator {
|
||||
// 催单冷却改由 last_urge_time 判定,不再依赖 transient;催单同时写入 system 类型的 ticket_reply。
|
||||
// 3.3.0: 主题品牌由 AKSRT 更名为 Jio,表名前缀 aksrt_ → jio_。dbDelta 只建不删,
|
||||
// 因此版本必须提升,否则既有安装仍读旧表;新安装直接建 jio_* 表。
|
||||
const SCHEMA_VERSION = '3.3.0';
|
||||
// 3.4.0: 知识付费附件改为服务端受保护交付。新增 jio_private_asset(私有附件
|
||||
// 记录,文件存放在独立私有目录而非公开上传目录),jio_dl_token 增加 asset_id
|
||||
// 以把下载令牌绑定到稳定的私有附件,而不是可变的下载条目序号。
|
||||
const SCHEMA_VERSION = '3.4.0';
|
||||
|
||||
const OPT_KEY = 'jio_db_schema_version';
|
||||
|
||||
|
||||
@@ -139,6 +139,7 @@ trait DBMigratorPart2 {
|
||||
token CHAR(48) NOT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
post_id BIGINT UNSIGNED NOT NULL,
|
||||
asset_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
source_index INT NOT NULL DEFAULT 0,
|
||||
cost INT NOT NULL DEFAULT 0,
|
||||
ip_bind VARCHAR(64) DEFAULT NULL,
|
||||
@@ -150,10 +151,29 @@ trait DBMigratorPart2 {
|
||||
PRIMARY KEY (token),
|
||||
KEY idx_expires (expires_at),
|
||||
KEY idx_user (user_id),
|
||||
KEY idx_post (post_id)
|
||||
KEY idx_post (post_id),
|
||||
KEY idx_asset (asset_id)
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_dl_token);
|
||||
|
||||
// ---------- 4. 私有附件(知识付费资料,不经公开上传目录) ----------
|
||||
$sql_private_asset = "CREATE TABLE {$prefix}jio_private_asset (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
asset_key CHAR(48) NOT NULL,
|
||||
post_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
owner_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
file_name VARCHAR(191) NOT NULL DEFAULT '',
|
||||
mime VARCHAR(100) NOT NULL DEFAULT '',
|
||||
size_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
storage_name VARCHAR(191) NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_asset_key (asset_key),
|
||||
KEY idx_post (post_id),
|
||||
KEY idx_owner (owner_id)
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($sql_private_asset);
|
||||
|
||||
// ---------- 5. 用户通知收件箱 ----------
|
||||
$sql_notification = "CREATE TABLE {$prefix}jio_notification (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
@@ -23,6 +23,7 @@ trait DBMigratorPart3 {
|
||||
'financial_flow_entry' => 'jio_financial_flow_entry',
|
||||
'signin_log' => 'jio_signin_log',
|
||||
'dl_token' => 'jio_dl_token',
|
||||
'private_asset' => 'jio_private_asset',
|
||||
'notification' => 'jio_notification',
|
||||
'order' => 'jio_order',
|
||||
'order_item' => 'jio_order_item',
|
||||
|
||||
@@ -243,14 +243,14 @@ class DomainModel {
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return array<int, array{label:string,url:string,type:string,extract_code:string,copy_key:string,copy_val:string,icon:string,class:string,attachment_id:int}>
|
||||
* @return array<int, array{label:string,url:string,type:string,asset_key:string,file_name:string,size_bytes:int,extract_code:string,copy_key:string,copy_val:string,icon:string,class:string,attachment_id:int}>
|
||||
*/
|
||||
public static function sanitize_downloads($value): array {
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$allowed_types = ['direct', 'net_disk', 'external'];
|
||||
$allowed_types = ['direct', 'net_disk', 'external', 'protected'];
|
||||
$allowed_classes = self::DOWNLOAD_BUTTON_CLASSES;
|
||||
$downloads = [];
|
||||
|
||||
@@ -260,14 +260,26 @@ class DomainModel {
|
||||
}
|
||||
|
||||
$url = isset($row['url']) ? esc_url_raw((string) $row['url']) : '';
|
||||
if ($url === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = isset($row['type']) && in_array($row['type'], $allowed_types, true)
|
||||
? (string) $row['type']
|
||||
: 'direct';
|
||||
|
||||
// 受保护附件只保存随机 asset_key;文件本体在私有存储中,绝不暴露路径。
|
||||
$asset_key = class_exists('\Jio\Content\PrivateAssets')
|
||||
? \Jio\Content\PrivateAssets::sanitize_asset_key((string) ($row['asset_key'] ?? ''))
|
||||
: '';
|
||||
$is_protected = $type === 'protected' || $asset_key !== '';
|
||||
if ($is_protected && $asset_key === '') {
|
||||
continue;
|
||||
}
|
||||
if (!$is_protected && $url === '') {
|
||||
continue;
|
||||
}
|
||||
if ($is_protected) {
|
||||
$type = 'protected';
|
||||
$url = '';
|
||||
}
|
||||
|
||||
$extract_code = '';
|
||||
foreach ([
|
||||
'extract_code',
|
||||
@@ -299,6 +311,9 @@ class DomainModel {
|
||||
'label' => isset($row['label']) ? sanitize_text_field((string) $row['label']) : '',
|
||||
'url' => $url,
|
||||
'type' => $type,
|
||||
'asset_key' => $asset_key,
|
||||
'file_name' => $asset_key !== '' ? sanitize_file_name((string) ($row['file_name'] ?? '')) : '',
|
||||
'size_bytes' => $asset_key !== '' ? max(0, (int) ($row['size_bytes'] ?? 0)) : 0,
|
||||
'extract_code' => $extract_code,
|
||||
'copy_key' => isset($row['copy_key']) ? sanitize_text_field((string) $row['copy_key']) : '',
|
||||
'copy_val' => isset($row['copy_val']) ? sanitize_text_field((string) $row['copy_val']) : '',
|
||||
|
||||
@@ -12,6 +12,15 @@ defined('ABSPATH') || exit;
|
||||
*/
|
||||
trait FrontSubmitFeatured {
|
||||
|
||||
/**
|
||||
* Persist a featured payload for a post the user may edit.
|
||||
*
|
||||
* @param array<string, mixed> $featured Featured payload (`type` + `data`).
|
||||
*/
|
||||
public function apply_creator_featured(int $post_id, array $featured, int $user_id): void {
|
||||
$this->save_featured_meta($post_id, ['featured_data' => $featured], $user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
|
||||
@@ -18,26 +18,48 @@ trait FrontSubmitPay {
|
||||
* @return array{success:bool,message?:string}
|
||||
*/
|
||||
private function save_pay_meta(int $post_id, array $data, int $user_id, array $settings): array {
|
||||
$prepared = $this->prepare_pay_meta($post_id, $data, $user_id, $settings);
|
||||
if (empty($prepared['success'])) {
|
||||
return $prepared;
|
||||
}
|
||||
if (!empty($prepared['write'])) {
|
||||
$this->write_pay_meta($post_id, $prepared['meta']);
|
||||
}
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the submitted paid-content payload without writing anything.
|
||||
*
|
||||
* Returns `write => false` when the request did not carry pay fields, so
|
||||
* callers can persist the post only after they know the payload is valid.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<string, mixed> $settings
|
||||
* @return array{success:bool,message?:string,write?:bool,meta?:array<string,mixed>}
|
||||
*/
|
||||
public function prepare_pay_meta(int $post_id, array $data, int $user_id, array $settings): array {
|
||||
if (!$this->user_can_cap('new_post_pay', $user_id)) {
|
||||
return ['success' => true];
|
||||
return ['success' => true, 'write' => false];
|
||||
}
|
||||
|
||||
// Only touch pay meta when the form sent pay fields.
|
||||
$has_pay_payload = isset($data['jio_pay']) || isset($data['pay_enabled']);
|
||||
if (!$has_pay_payload) {
|
||||
return ['success' => true];
|
||||
return ['success' => true, 'write' => false];
|
||||
}
|
||||
|
||||
$raw = is_array($data['jio_pay'] ?? null) ? $data['jio_pay'] : [];
|
||||
$enabled = !empty($data['pay_enabled']);
|
||||
|
||||
$existing = function_exists('jio_get_pay_meta') ? jio_get_pay_meta($post_id) : DomainModel::default_pay_meta();
|
||||
$existing = $post_id > 0 && function_exists('jio_get_pay_meta')
|
||||
? jio_get_pay_meta($post_id)
|
||||
: DomainModel::default_pay_meta();
|
||||
$meta = is_array($existing) ? $existing : DomainModel::default_pay_meta();
|
||||
|
||||
if (!$enabled) {
|
||||
$meta['pay_type'] = DomainModel::PAY_TYPE_NO;
|
||||
$this->write_pay_meta($post_id, $meta);
|
||||
return ['success' => true];
|
||||
return ['success' => true, 'write' => true, 'meta' => $meta];
|
||||
}
|
||||
|
||||
$pay_modo = sanitize_key((string) ($raw['pay_modo'] ?? '0'));
|
||||
@@ -170,7 +192,24 @@ trait FrontSubmitPay {
|
||||
$pay_limit = (int) ($raw['pay_limit'] ?? 0);
|
||||
$meta['pay_limit'] = in_array($pay_limit, [0, 1, 2], true) ? $pay_limit : 0;
|
||||
|
||||
$this->write_pay_meta($post_id, $meta);
|
||||
return ['success' => true, 'write' => true, 'meta' => $meta];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an already-prepared pay payload to a post (version approval path).
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @param array<string, mixed> $settings
|
||||
* @return array{success:bool,message?:string}
|
||||
*/
|
||||
public function apply_pay_payload(int $post_id, array $data, int $user_id, array $settings): array {
|
||||
$prepared = $this->prepare_pay_meta($post_id, $data, $user_id, $settings);
|
||||
if (empty($prepared['success'])) {
|
||||
return $prepared;
|
||||
}
|
||||
if (!empty($prepared['write'])) {
|
||||
$this->write_pay_meta($post_id, $prepared['meta']);
|
||||
}
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,13 @@ trait FrontSubmitSave {
|
||||
$status = $this->resolve_publish_status($user_id);
|
||||
}
|
||||
|
||||
// Paid fields are validated before anything is written, so an invalid
|
||||
// pay configuration can no longer leave a half-saved post behind.
|
||||
$pay_result = $this->prepare_pay_meta((int) $posts_id, $data, $user_id, $settings);
|
||||
if (empty($pay_result['success'])) {
|
||||
return $pay_result;
|
||||
}
|
||||
|
||||
$can_iframe = $this->user_can_cap('new_post_iframe_video', $user_id);
|
||||
if ($can_iframe) {
|
||||
add_filter('wp_kses_allowed_html', [$this, 'allow_iframe_kses'], 99, 2);
|
||||
@@ -191,20 +198,15 @@ trait FrontSubmitSave {
|
||||
|
||||
$this->apply_custom_filters((int) $in_id, $data, $settings);
|
||||
|
||||
$pay_result = $this->save_pay_meta((int) $in_id, $data, $user_id, $settings);
|
||||
if ($pay_result['success']) {
|
||||
$this->save_featured_meta((int) $in_id, $data, $user_id);
|
||||
if (!empty($pay_result['write'])) {
|
||||
$this->write_pay_meta((int) $in_id, $pay_result['meta']);
|
||||
}
|
||||
$this->save_featured_meta((int) $in_id, $data, $user_id);
|
||||
|
||||
// The post is already persisted even when pay validation fails.
|
||||
// The post is already persisted at this point; attachment relations and
|
||||
// hooks below must still run for a successful save.
|
||||
$post = get_post((int) $in_id);
|
||||
$this->sync_post_attachments($post);
|
||||
if (!$pay_result['success']) {
|
||||
$pay_result['post_id'] = (int) $in_id;
|
||||
$pay_result['posts_id'] = (int) $in_id;
|
||||
return $pay_result;
|
||||
}
|
||||
|
||||
if ($post instanceof \WP_Post) {
|
||||
do_action($is_edit ? 'jio_new_edit_posts' : 'jio_new_add_posts', $post);
|
||||
if ($status === 'pending') {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
/**
|
||||
* 改稿预览令牌。
|
||||
*
|
||||
* 已发布文章处于审核中的改稿不能通过公开永久链接查看。作者/审核者请求预览时
|
||||
* 得到一个短期、一次性绑定到版本与用户的令牌,在源文章页面上临时替换展示内容;
|
||||
* 不改变数据库中的线上文章,也不把版本暴露给未授权访问者。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
namespace Jio\Core;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class PreviewToken {
|
||||
|
||||
private const TTL = 900;
|
||||
private const PREFIX = 'jio_studio_preview_';
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
add_action('template_redirect', [$this, 'maybe_preview'], 0);
|
||||
add_filter('wp_robots', [$this, 'robots_noindex']);
|
||||
}
|
||||
|
||||
public static function issue(int $version_id, int $user_id): string {
|
||||
$token = bin2hex(random_bytes(24));
|
||||
set_transient(self::PREFIX . $token, [
|
||||
'version_id' => $version_id,
|
||||
'user_id' => $user_id,
|
||||
], self::TTL);
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{version_id:int,user_id:int}|null
|
||||
*/
|
||||
private static function resolve(string $token): ?array {
|
||||
if (!preg_match('/^[a-f0-9]{48}$/', $token)) {
|
||||
return null;
|
||||
}
|
||||
$data = get_transient(self::PREFIX . $token);
|
||||
if (!is_array($data)) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'version_id' => (int) ($data['version_id'] ?? 0),
|
||||
'user_id' => (int) ($data['user_id'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
public function maybe_preview(): void {
|
||||
$token = isset($_GET['jio_studio_preview']) ? sanitize_text_field(wp_unslash((string) $_GET['jio_studio_preview'])) : '';
|
||||
if ($token === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$resolved = self::resolve($token);
|
||||
$user_id = get_current_user_id();
|
||||
if ($resolved === null || $user_id <= 0
|
||||
|| ($resolved['user_id'] !== $user_id && !SubmissionVersion::can_review($user_id))
|
||||
) {
|
||||
status_header(403);
|
||||
wp_die(esc_html__('预览链接无效或已过期。', 'jio'), esc_html__('预览不可用', 'jio'), ['response' => 403]);
|
||||
}
|
||||
|
||||
$version = get_post($resolved['version_id']);
|
||||
if (!$version instanceof \WP_Post || $version->post_type !== SubmissionVersion::POST_TYPE) {
|
||||
status_header(404);
|
||||
wp_die(esc_html__('改稿不存在。', 'jio'), esc_html__('预览不可用', 'jio'), ['response' => 404]);
|
||||
}
|
||||
|
||||
$source_id = SubmissionVersion::source_id($resolved['version_id']);
|
||||
$source = $source_id > 0 ? get_post($source_id) : null;
|
||||
if (!$source instanceof \WP_Post) {
|
||||
status_header(404);
|
||||
wp_die(esc_html__('原文章不存在。', 'jio'), esc_html__('预览不可用', 'jio'), ['response' => 404]);
|
||||
}
|
||||
|
||||
// 仅在本次请求内替换展示内容,不写回源文章。
|
||||
$preview = clone $source;
|
||||
$preview->post_title = $version->post_title;
|
||||
$preview->post_content = $version->post_content;
|
||||
$preview->post_excerpt = $version->post_excerpt;
|
||||
|
||||
global $post, $wp_query;
|
||||
$post = $preview;
|
||||
if ($wp_query instanceof \WP_Query && !empty($wp_query->posts)) {
|
||||
$wp_query->posts[0] = $preview;
|
||||
$wp_query->post = $preview;
|
||||
}
|
||||
setup_postdata($preview);
|
||||
|
||||
$GLOBALS['jio_studio_preview_mode'] = true;
|
||||
nocache_headers();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, bool> $robots
|
||||
* @return array<string, bool>
|
||||
*/
|
||||
public function robots_noindex(array $robots): array {
|
||||
if (!empty($GLOBALS['jio_studio_preview_mode'])) {
|
||||
$robots['noindex'] = true;
|
||||
$robots['nofollow'] = true;
|
||||
}
|
||||
return $robots;
|
||||
}
|
||||
}
|
||||
@@ -484,6 +484,11 @@ trait ScriptsPart1c {
|
||||
})(),
|
||||
'commentIasMax' => class_exists('\Jio\Settings\Settings') ? \Jio\Settings\Settings::get_comment_display_settings()['paging_ajax_ias_max'] : 3,
|
||||
'restUrl' => esc_url_raw(rest_url('jio/v1')),
|
||||
// 受保护下载:按钮只携带 asset_key,先换短期令牌再取文件。
|
||||
'protectedDownload' => [
|
||||
'tokenUrl' => esc_url_raw(rest_url('jio/v1/protected-download/token')),
|
||||
'error' => __('下载失败,请稍后重试', 'jio'),
|
||||
],
|
||||
// Alias used by modal-auth / follow / report (same value as restUrl).
|
||||
'apiUrl' => esc_url_raw(rest_url('jio/v1')),
|
||||
// 举报弹窗数据(report.js):预置原因读取后台“禁封/举报”的 ban_preset_reason
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
<?php
|
||||
/**
|
||||
* 已发布文章的改稿版本(Jio Creator Studio)。
|
||||
*
|
||||
* 作者修改一篇已发布文章时,改动不会直接写入线上文章,而是写入一个私有的
|
||||
* 投稿版本(私有 post type)。线上文章的内容、永久链接与付费条款在审核通过
|
||||
* 前保持不变;审核通过后由本类一次性把版本内容应用到源文章。
|
||||
*
|
||||
* 新文章仍走既有 FrontSubmit::persist() 的 draft → pending/publish 流程;只有
|
||||
* 源文章已发布时才进入版本流程,避免线上内容被静默改写。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
namespace Jio\Core;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class SubmissionVersion {
|
||||
|
||||
public const POST_TYPE = 'jio_submission';
|
||||
|
||||
/** 版本记录上的 meta。 */
|
||||
public const META_SOURCE = '_jio_source_post';
|
||||
public const META_SOURCE_REV = '_jio_source_rev';
|
||||
public const META_REV = '_jio_version_rev';
|
||||
public const META_STATUS = '_jio_review_status';
|
||||
public const META_NOTE = '_jio_review_note';
|
||||
public const META_SNAPSHOT = '_jio_snapshot';
|
||||
public const META_SUBMITTED_AT = '_jio_submitted_at';
|
||||
|
||||
public const STATUS_WORKING = 'working';
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
add_action('init', [$this, 'register_post_type'], 2);
|
||||
add_action('admin_post_jio_submission_approve', [$this, 'admin_post_approve']);
|
||||
add_action('admin_post_jio_submission_reject', [$this, 'admin_post_reject']);
|
||||
add_filter('wp_robots', [$this, 'robots_noindex']);
|
||||
}
|
||||
|
||||
public function admin_post_approve(): void {
|
||||
$this->handle_review_action('approve');
|
||||
}
|
||||
|
||||
public function admin_post_reject(): void {
|
||||
$this->handle_review_action('reject');
|
||||
}
|
||||
|
||||
private function handle_review_action(string $action): void {
|
||||
if (!SubmissionVersion::can_review(get_current_user_id())) {
|
||||
wp_die(esc_html__('没有审核权限。', 'jio'), '', ['response' => 403]);
|
||||
}
|
||||
$version_id = absint($_POST['version_id'] ?? 0);
|
||||
check_admin_referer('jio_review_' . $action . '_' . $version_id);
|
||||
|
||||
$result = $action === 'approve'
|
||||
? $this->approve($version_id, get_current_user_id(), !empty($_POST['force']))
|
||||
: $this->reject($version_id, get_current_user_id(), (string) ($_POST['note'] ?? ''));
|
||||
|
||||
$ok = !is_wp_error($result) && !empty($result['success']);
|
||||
$msg = is_wp_error($result) ? $result->get_error_message() : (string) ($result['message'] ?? '');
|
||||
|
||||
$redirect = add_query_arg(
|
||||
['jio_review_notice' => $ok ? 'ok' : 'error', 'jio_review_message' => rawurlencode($msg)],
|
||||
wp_get_referer() ?: admin_url('edit.php?post_type=' . self::POST_TYPE)
|
||||
);
|
||||
wp_safe_redirect($redirect);
|
||||
exit;
|
||||
}
|
||||
|
||||
public function register_post_type(): void {
|
||||
register_post_type(self::POST_TYPE, [
|
||||
'labels' => [
|
||||
'name' => __('投稿版本', 'jio'),
|
||||
'singular_name' => __('投稿版本', 'jio'),
|
||||
],
|
||||
'public' => false,
|
||||
'publicly_queryable' => false,
|
||||
'exclude_from_search' => true,
|
||||
'show_ui' => false,
|
||||
'show_in_rest' => false,
|
||||
'rewrite' => false,
|
||||
'query_var' => false,
|
||||
'capability_type' => 'post',
|
||||
'map_meta_cap' => true,
|
||||
'supports' => ['title', 'editor', 'revisions', 'author'],
|
||||
'can_export' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, bool> $robots
|
||||
* @return array<string, bool>
|
||||
*/
|
||||
public function robots_noindex(array $robots): array {
|
||||
if (is_singular(self::POST_TYPE)) {
|
||||
$robots['noindex'] = true;
|
||||
$robots['nofollow'] = true;
|
||||
}
|
||||
return $robots;
|
||||
}
|
||||
|
||||
/**
|
||||
* 该源文章是否需要走版本流程(仅已发布文章)。
|
||||
*/
|
||||
public static function requires_version(int $source_id): bool {
|
||||
$post = get_post($source_id);
|
||||
return $post instanceof \WP_Post
|
||||
&& $post->post_type === 'post'
|
||||
&& $post->post_status === 'publish';
|
||||
}
|
||||
|
||||
/**
|
||||
* 作者针对某篇源文章当前可见的工作版本(工作 / 待审 / 被退回)。
|
||||
*/
|
||||
public static function get_working(int $source_id, int $user_id): ?\WP_Post {
|
||||
if ($source_id <= 0 || $user_id <= 0) {
|
||||
return null;
|
||||
}
|
||||
$posts = get_posts([
|
||||
'post_type' => self::POST_TYPE,
|
||||
'post_status' => ['draft', 'pending', 'publish', 'private'],
|
||||
'author' => $user_id,
|
||||
'posts_per_page' => 1,
|
||||
'orderby' => 'modified',
|
||||
'order' => 'DESC',
|
||||
'no_found_rows' => true,
|
||||
'meta_key' => self::META_SOURCE,
|
||||
'meta_value' => (string) $source_id,
|
||||
]);
|
||||
foreach ($posts as $post) {
|
||||
if (!in_array(self::status((int) $post->ID), [self::STATUS_APPROVED], true)) {
|
||||
return $post;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function status(int $version_id): string {
|
||||
$status = (string) get_post_meta($version_id, self::META_STATUS, true);
|
||||
return in_array($status, [self::STATUS_WORKING, self::STATUS_PENDING, self::STATUS_APPROVED, self::STATUS_REJECTED], true)
|
||||
? $status
|
||||
: self::STATUS_WORKING;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function snapshot(int $version_id): array {
|
||||
$raw = get_post_meta($version_id, self::META_SNAPSHOT, true);
|
||||
return is_array($raw) ? $raw : [];
|
||||
}
|
||||
|
||||
public static function revision(int $version_id): int {
|
||||
return max(0, (int) get_post_meta($version_id, self::META_REV, true));
|
||||
}
|
||||
|
||||
public static function source_id(int $version_id): int {
|
||||
return max(0, (int) get_post_meta($version_id, self::META_SOURCE, true));
|
||||
}
|
||||
|
||||
/**
|
||||
* 源文章的版本指纹:改动后用于检测审核期间的并发修改。
|
||||
*/
|
||||
public static function source_revision(int $source_id): string {
|
||||
$post = get_post($source_id);
|
||||
if (!$post instanceof \WP_Post) {
|
||||
return '';
|
||||
}
|
||||
return sha1(implode('|', [
|
||||
$post->post_modified_gmt,
|
||||
(string) strlen((string) $post->post_content),
|
||||
md5((string) $post->post_content),
|
||||
md5((string) $post->post_title),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建或更新作者的工作版本。
|
||||
*
|
||||
* @param array{title?:string,content?:string,excerpt?:string,snapshot?:array<string,mixed>,base_rev?:int} $data
|
||||
* @return array{success:bool,message?:string,version_id?:int,rev?:int,conflict?:bool}|\WP_Error
|
||||
*/
|
||||
public function save_working(int $source_id, int $user_id, array $data, bool $submit = false) {
|
||||
if (!self::requires_version($source_id)) {
|
||||
return new \WP_Error('jio_version_not_required', __('该文章不需要版本流程。', 'jio'), ['status' => 400]);
|
||||
}
|
||||
$source = get_post($source_id);
|
||||
if (!$source instanceof \WP_Post || (int) $source->post_author !== $user_id) {
|
||||
return new \WP_Error('jio_version_forbidden', __('没有编辑此文章的权限。', 'jio'), ['status' => 403]);
|
||||
}
|
||||
|
||||
$version = self::get_working($source_id, $user_id);
|
||||
$base_rev = max(0, (int) ($data['base_rev'] ?? 0));
|
||||
|
||||
if ($version instanceof \WP_Post) {
|
||||
$current_rev = self::revision((int) $version->ID);
|
||||
if ($base_rev !== 0 && $base_rev !== $current_rev) {
|
||||
return new \WP_Error(
|
||||
'jio_version_conflict',
|
||||
__('该文章已在其他页面更新,请刷新后再保存。', 'jio'),
|
||||
['status' => 409, 'current_rev' => $current_rev, 'version_id' => (int) $version->ID]
|
||||
);
|
||||
}
|
||||
$version_id = (int) $version->ID;
|
||||
} else {
|
||||
$version_id = wp_insert_post([
|
||||
'post_type' => self::POST_TYPE,
|
||||
'post_status' => 'draft',
|
||||
'post_author' => $user_id,
|
||||
'post_title' => (string) $source->post_title,
|
||||
'post_content' => (string) $source->post_content,
|
||||
'post_excerpt' => (string) $source->post_excerpt,
|
||||
'meta_input' => [
|
||||
self::META_SOURCE => $source_id,
|
||||
self::META_SOURCE_REV => self::source_revision($source_id),
|
||||
self::META_REV => 0,
|
||||
self::META_STATUS => self::STATUS_WORKING,
|
||||
],
|
||||
], true);
|
||||
if (is_wp_error($version_id) || !$version_id) {
|
||||
return new \WP_Error('jio_version_failed', __('无法创建改稿版本,请稍后再试。', 'jio'), ['status' => 500]);
|
||||
}
|
||||
}
|
||||
|
||||
$title = isset($data['title']) ? sanitize_text_field((string) $data['title']) : (string) $source->post_title;
|
||||
$excerpt = isset($data['excerpt']) ? sanitize_textarea_field((string) $data['excerpt']) : (string) $source->post_excerpt;
|
||||
$content = isset($data['content']) ? (string) $data['content'] : (string) $source->post_content;
|
||||
|
||||
$can_iframe = $this->user_can_cap('new_post_iframe_video', $user_id);
|
||||
if ($can_iframe) {
|
||||
add_filter('wp_kses_allowed_html', [FrontSubmit::get_instance(), 'allow_iframe_kses'], 99, 2);
|
||||
}
|
||||
$content = wp_kses_post($content);
|
||||
if ($can_iframe) {
|
||||
remove_filter('wp_kses_allowed_html', [FrontSubmit::get_instance(), 'allow_iframe_kses'], 99);
|
||||
}
|
||||
|
||||
$next_rev = $base_rev > 0 ? $base_rev + 1 : ($version instanceof \WP_Post ? self::revision($version_id) + 1 : 1);
|
||||
|
||||
// 已经在审核队列中的改稿:作者继续编辑(含自动保存)时保持 pending,
|
||||
// 否则一次自动保存会把待审内容悄悄移出审核队列。
|
||||
$current_status = $version instanceof \WP_Post ? self::status($version_id) : self::STATUS_WORKING;
|
||||
$stays_pending = !$submit && $current_status === self::STATUS_PENDING;
|
||||
$next_status = $submit || $stays_pending ? self::STATUS_PENDING : self::STATUS_WORKING;
|
||||
|
||||
$update = [
|
||||
'ID' => $version_id,
|
||||
'post_title' => $title,
|
||||
'post_content' => $content,
|
||||
'post_excerpt' => $excerpt,
|
||||
'post_status' => $next_status === self::STATUS_PENDING ? 'pending' : 'draft',
|
||||
];
|
||||
$updated = wp_update_post($update, true);
|
||||
if (is_wp_error($updated)) {
|
||||
return new \WP_Error('jio_version_failed', $updated->get_error_message(), ['status' => 500]);
|
||||
}
|
||||
|
||||
if (isset($data['snapshot']) && is_array($data['snapshot'])) {
|
||||
$snapshot = $this->sanitize_snapshot($data['snapshot']);
|
||||
update_post_meta($version_id, self::META_SNAPSHOT, $snapshot);
|
||||
// 私有资料在上传后即绑定到源文章:审核通过后可直接交付,也不会被
|
||||
// 未关联附件的定时清理误删。
|
||||
$this->claim_snapshot_assets($snapshot, $source_id, $user_id);
|
||||
}
|
||||
update_post_meta($version_id, self::META_REV, $next_rev);
|
||||
update_post_meta($version_id, self::META_STATUS, $next_status);
|
||||
if ($submit) {
|
||||
update_post_meta($version_id, self::META_SUBMITTED_AT, current_time('mysql'));
|
||||
update_post_meta($version_id, self::META_SOURCE_REV, self::source_revision($source_id));
|
||||
delete_post_meta($version_id, self::META_NOTE);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'version_id' => $version_id,
|
||||
'rev' => $next_rev,
|
||||
'status' => $next_status,
|
||||
'message' => $submit
|
||||
? __('改稿已提交审核,线上文章保持不变。', 'jio')
|
||||
: ($stays_pending ? __('待审改稿已更新', 'jio') : __('改稿已保存', 'jio')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核通过:把版本快照一次性应用到源文章。
|
||||
*
|
||||
* @return array{success:bool,message?:string,post_id?:int}|\WP_Error
|
||||
*/
|
||||
public function approve(int $version_id, int $reviewer_id, bool $force = false) {
|
||||
$version = get_post($version_id);
|
||||
if (!$version instanceof \WP_Post || $version->post_type !== self::POST_TYPE) {
|
||||
return new \WP_Error('jio_version_missing', __('改稿版本不存在。', 'jio'), ['status' => 404]);
|
||||
}
|
||||
if (!self::can_review($reviewer_id)) {
|
||||
return new \WP_Error('jio_review_forbidden', __('没有审核权限。', 'jio'), ['status' => 403]);
|
||||
}
|
||||
|
||||
$source_id = self::source_id($version_id);
|
||||
$source = $source_id > 0 ? get_post($source_id) : null;
|
||||
if (!$source instanceof \WP_Post || $source->post_type !== 'post') {
|
||||
return new \WP_Error('jio_version_source_missing', __('原文章不存在。', 'jio'), ['status' => 404]);
|
||||
}
|
||||
|
||||
$snapshot_rev = (string) get_post_meta($version_id, self::META_SOURCE_REV, true);
|
||||
if (!$force && $snapshot_rev !== '' && $snapshot_rev !== self::source_revision($source_id)) {
|
||||
return new \WP_Error(
|
||||
'jio_version_source_changed',
|
||||
__('原文章在审核期间已被修改,请先确认后再应用。', 'jio'),
|
||||
['status' => 409]
|
||||
);
|
||||
}
|
||||
|
||||
$snapshot = self::snapshot($version_id);
|
||||
$update = [
|
||||
'ID' => $source_id,
|
||||
'post_title' => (string) $version->post_title,
|
||||
'post_content' => (string) $version->post_content,
|
||||
'post_excerpt' => (string) $version->post_excerpt,
|
||||
];
|
||||
$result = wp_update_post($update, true);
|
||||
if (is_wp_error($result)) {
|
||||
return new \WP_Error('jio_version_apply_failed', $result->get_error_message(), ['status' => 500]);
|
||||
}
|
||||
|
||||
$this->apply_snapshot($source_id, $snapshot, (int) $source->post_author);
|
||||
|
||||
wp_update_post([
|
||||
'ID' => $version_id,
|
||||
'post_status' => 'private',
|
||||
]);
|
||||
update_post_meta($version_id, self::META_STATUS, self::STATUS_APPROVED);
|
||||
update_post_meta($version_id, self::META_REV, self::revision($version_id) + 1);
|
||||
|
||||
do_action('jio_submission_version_approved', $source_id, $version_id, $reviewer_id);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'post_id' => $source_id,
|
||||
'message' => __('改稿已应用到线上文章。', 'jio'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 作者直接应用自己的改稿(具备免审发布权限时)。
|
||||
*/
|
||||
public function publish_working(int $source_id, int $user_id): array {
|
||||
$version = self::get_working($source_id, $user_id);
|
||||
if (!$version instanceof \WP_Post) {
|
||||
return ['success' => false, 'message' => __('没有待应用的改稿。', 'jio')];
|
||||
}
|
||||
$source = get_post($source_id);
|
||||
if (!$source instanceof \WP_Post || (int) $source->post_author !== $user_id) {
|
||||
return ['success' => false, 'message' => __('没有编辑此文章的权限。', 'jio')];
|
||||
}
|
||||
|
||||
$result = wp_update_post([
|
||||
'ID' => $source_id,
|
||||
'post_title' => (string) $version->post_title,
|
||||
'post_content' => (string) $version->post_content,
|
||||
'post_excerpt' => (string) $version->post_excerpt,
|
||||
], true);
|
||||
if (is_wp_error($result)) {
|
||||
return ['success' => false, 'message' => $result->get_error_message()];
|
||||
}
|
||||
|
||||
$this->apply_snapshot($source_id, self::snapshot((int) $version->ID), $user_id);
|
||||
|
||||
wp_update_post(['ID' => (int) $version->ID, 'post_status' => 'private']);
|
||||
update_post_meta((int) $version->ID, self::META_STATUS, self::STATUS_APPROVED);
|
||||
update_post_meta((int) $version->ID, self::META_REV, self::revision((int) $version->ID) + 1);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'post_id' => $source_id,
|
||||
'message' => __('改稿已发布。', 'jio'),
|
||||
'url' => get_permalink($source_id),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 退回改稿。
|
||||
*/
|
||||
public function reject(int $version_id, int $reviewer_id, string $note = ''): array {
|
||||
if (!self::can_review($reviewer_id)) {
|
||||
return ['success' => false, 'message' => __('没有审核权限。', 'jio')];
|
||||
}
|
||||
$version = get_post($version_id);
|
||||
if (!$version instanceof \WP_Post || $version->post_type !== self::POST_TYPE) {
|
||||
return ['success' => false, 'message' => __('改稿版本不存在。', 'jio')];
|
||||
}
|
||||
|
||||
wp_update_post(['ID' => $version_id, 'post_status' => 'draft']);
|
||||
update_post_meta($version_id, self::META_STATUS, self::STATUS_REJECTED);
|
||||
update_post_meta($version_id, self::META_NOTE, sanitize_textarea_field($note));
|
||||
update_post_meta($version_id, self::META_REV, self::revision($version_id) + 1);
|
||||
|
||||
do_action('jio_submission_version_rejected', $version_id, $reviewer_id, $note);
|
||||
|
||||
return ['success' => true, 'message' => __('已退回改稿,线上文章未变动。', 'jio')];
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入版本快照中的分类、标签、自定义筛选、封面与付费设置。
|
||||
*
|
||||
* @param array<string, mixed> $snapshot
|
||||
*/
|
||||
private function apply_snapshot(int $post_id, array $snapshot, int $author_id): void {
|
||||
if ($snapshot === []) {
|
||||
return;
|
||||
}
|
||||
$settings = FrontSubmit::get_instance()->settings();
|
||||
|
||||
if (isset($snapshot['categories']) && is_array($snapshot['categories'])) {
|
||||
$cats = array_values(array_unique(array_filter(array_map('absint', $snapshot['categories']))));
|
||||
$allowed = is_array($settings['allowed_cat_ids'] ?? null) ? array_map('intval', $settings['allowed_cat_ids']) : [];
|
||||
if ($allowed !== [] && $cats !== []) {
|
||||
$cats = array_values(array_intersect($cats, $allowed));
|
||||
}
|
||||
wp_set_post_categories($post_id, $cats);
|
||||
}
|
||||
if (array_key_exists('tags', $snapshot)) {
|
||||
$tags = is_array($snapshot['tags']) ? $snapshot['tags'] : [];
|
||||
wp_set_post_tags($post_id, array_values(array_filter(array_map('sanitize_text_field', $tags))));
|
||||
}
|
||||
if (isset($snapshot['custom']) && is_array($snapshot['custom'])) {
|
||||
foreach ($snapshot['custom'] as $key => $value) {
|
||||
$meta_key = '_jio_cf_' . sanitize_key((string) $key);
|
||||
if (is_array($value)) {
|
||||
$vals = array_values(array_filter(array_map('sanitize_key', $value)));
|
||||
$vals ? update_post_meta($post_id, $meta_key, $vals) : delete_post_meta($post_id, $meta_key);
|
||||
} else {
|
||||
$val = sanitize_key((string) $value);
|
||||
$val !== '' ? update_post_meta($post_id, $meta_key, $val) : delete_post_meta($post_id, $meta_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$front = FrontSubmit::get_instance();
|
||||
if (isset($snapshot['featured']) && is_array($snapshot['featured'])) {
|
||||
$front->apply_creator_featured($post_id, $snapshot['featured'], $author_id);
|
||||
}
|
||||
if (isset($snapshot['pay']) && is_array($snapshot['pay'])) {
|
||||
$pay_data = [
|
||||
'pay_enabled' => !empty($snapshot['pay']['enabled']) ? '1' : '',
|
||||
'jio_pay' => is_array($snapshot['pay']['fields'] ?? null) ? $snapshot['pay']['fields'] : [],
|
||||
];
|
||||
$front->apply_pay_payload($post_id, $pay_data, $author_id, $settings);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sanitize_snapshot(array $snapshot): array {
|
||||
$clean = [];
|
||||
if (isset($snapshot['categories']) && is_array($snapshot['categories'])) {
|
||||
$clean['categories'] = array_values(array_unique(array_filter(array_map('absint', $snapshot['categories']))));
|
||||
}
|
||||
if (isset($snapshot['tags'])) {
|
||||
$tags = is_array($snapshot['tags']) ? $snapshot['tags'] : preg_split('/[,,\n]+/u', (string) $snapshot['tags']);
|
||||
$clean['tags'] = array_values(array_filter(array_map('sanitize_text_field', (array) $tags)));
|
||||
}
|
||||
if (isset($snapshot['custom']) && is_array($snapshot['custom'])) {
|
||||
$custom = [];
|
||||
foreach ($snapshot['custom'] as $key => $value) {
|
||||
$key = sanitize_key((string) $key);
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$custom[$key] = is_array($value)
|
||||
? array_values(array_filter(array_map('sanitize_key', $value)))
|
||||
: sanitize_key((string) $value);
|
||||
}
|
||||
$clean['custom'] = $custom;
|
||||
}
|
||||
if (isset($snapshot['featured']) && is_array($snapshot['featured'])) {
|
||||
$clean['featured'] = $snapshot['featured'];
|
||||
}
|
||||
if (isset($snapshot['pay']) && is_array($snapshot['pay'])) {
|
||||
$clean['pay'] = [
|
||||
'enabled' => !empty($snapshot['pay']['enabled']),
|
||||
'fields' => is_array($snapshot['pay']['fields'] ?? null) ? $snapshot['pay']['fields'] : [],
|
||||
];
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把版本快照里引用的私有资料绑定到源文章。
|
||||
*
|
||||
* @param array<string, mixed> $snapshot
|
||||
*/
|
||||
private function claim_snapshot_assets(array $snapshot, int $source_id, int $user_id): void {
|
||||
$keys = [];
|
||||
foreach ((array) ($snapshot['pay']['fields']['downloads'] ?? []) as $row) {
|
||||
if (is_array($row)) {
|
||||
$key = PrivateAssets::sanitize_asset_key((string) ($row['asset_key'] ?? ''));
|
||||
if ($key !== '') {
|
||||
$keys[] = $key;
|
||||
}
|
||||
}
|
||||
}
|
||||
PrivateAssets::claim_many($keys, $source_id, $user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否具备审核权限。
|
||||
*/
|
||||
public static function can_review(int $user_id = 0): bool {
|
||||
$user_id = $user_id > 0 ? $user_id : get_current_user_id();
|
||||
if ($user_id <= 0) {
|
||||
return false;
|
||||
}
|
||||
return user_can($user_id, 'manage_options') || user_can($user_id, 'edit_others_posts');
|
||||
}
|
||||
|
||||
public static function status_label(string $status): string {
|
||||
switch ($status) {
|
||||
case self::STATUS_PENDING:
|
||||
return __('待审核', 'jio');
|
||||
case self::STATUS_APPROVED:
|
||||
return __('已应用', 'jio');
|
||||
case self::STATUS_REJECTED:
|
||||
return __('已退回', 'jio');
|
||||
default:
|
||||
return __('草稿', 'jio');
|
||||
}
|
||||
}
|
||||
|
||||
private function user_can_cap(string $cap, int $user_id): bool {
|
||||
return FrontSubmit::get_instance()->user_can_cap($cap, $user_id);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,38 @@ if (!empty($_GET['edit']) && empty($ctx['edit_post'])) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Jio Creator Studio:登录用户 + 构建产物存在时使用沉浸式 Gutenberg 工作台。
|
||||
// 否则保留原有 TinyMCE 表单作为降级路径(游客投稿、未构建、无权限)。
|
||||
$studio = class_exists('\Jio\Core\CreatorStudio') ? \Jio\Core\CreatorStudio::get_instance() : null;
|
||||
$use_studio = $studio
|
||||
&& $studio::is_built()
|
||||
&& get_current_user_id() > 0
|
||||
&& !empty($ctx['is_can_new']);
|
||||
if ($use_studio) {
|
||||
get_header();
|
||||
?>
|
||||
<main id="main-content" role="main" tabindex="-1">
|
||||
<div id="jio-creator-studio">
|
||||
<div class="jio-studio-skeleton" aria-live="polite">
|
||||
<?php esc_html_e('正在加载创作工作台…', 'jio'); ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
// 人机验证挂载点:由发布抽屉在提交时移入并展示,脚本扫描只执行一次。
|
||||
if (function_exists('jio_render_captcha') && function_exists('jio_is_captcha_enabled_for') && jio_is_captcha_enabled_for('front_submit')) :
|
||||
?>
|
||||
<div id="jio-creator-captcha" hidden>
|
||||
<?php jio_render_captcha('front_submit'); ?>
|
||||
</div>
|
||||
<?php
|
||||
endif;
|
||||
?>
|
||||
</main>
|
||||
<?php
|
||||
get_footer();
|
||||
return;
|
||||
}
|
||||
|
||||
// wp_editor() is rendered after get_header(); queue its runtime before wp_head.
|
||||
wp_enqueue_editor();
|
||||
|
||||
|
||||
@@ -336,10 +336,22 @@ if ($is_need_purchase && $user_amount > 0) {
|
||||
<?php foreach ($downloads as $idx => $row) :
|
||||
$label = isset($row['label']) ? trim((string) $row['label']) : '';
|
||||
$type = isset($row['type']) ? (string) $row['type'] : 'direct';
|
||||
$asset_key = class_exists('\Jio\Content\PrivateAssets')
|
||||
? \Jio\Content\PrivateAssets::sanitize_asset_key((string) ($row['asset_key'] ?? ''))
|
||||
: '';
|
||||
$is_protected = $asset_key !== '';
|
||||
$url = isset($row['url']) ? (string) $row['url'] : '';
|
||||
if ($url === '') {
|
||||
// 直连模式没有可用的原始地址,空源不渲染。
|
||||
continue;
|
||||
if (!$is_protected) {
|
||||
if ($url === '') {
|
||||
// 直连模式没有可用的原始地址,空源不渲染。
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// 受保护附件按设计没有公开 URL,只带 asset_key。
|
||||
$url = '';
|
||||
}
|
||||
if ($is_protected && $label === '') {
|
||||
$label = trim((string) ($row['file_name'] ?? ''));
|
||||
}
|
||||
$extract_code = isset($row['extract_code']) ? trim((string) $row['extract_code']) : '';
|
||||
$has_extract = $extract_code !== '';
|
||||
@@ -349,7 +361,9 @@ if ($is_need_purchase && $user_amount > 0) {
|
||||
$button_class = isset($row['class']) ? trim((string) $row['class']) : '';
|
||||
$has_copy = $copy_val !== '';
|
||||
|
||||
$service = function_exists('jio_detect_cloud_service') ? jio_detect_cloud_service($url) : ['key' => 'other', 'name' => $label, 'color' => '#6b7280'];
|
||||
$service = $is_protected
|
||||
? ['key' => 'protected', 'name' => __('受保护下载', 'jio'), 'color' => '#267665']
|
||||
: (function_exists('jio_detect_cloud_service') ? jio_detect_cloud_service($url) : ['key' => 'other', 'name' => $label, 'color' => '#6b7280']);
|
||||
if ($label === '') {
|
||||
$label = strpos($service['name'], '本地') === 0 ? sprintf(__('下载源 %d', 'jio'), $idx + 1) : (string) $service['name'];
|
||||
}
|
||||
@@ -376,6 +390,21 @@ if ($is_need_purchase && $user_amount > 0) {
|
||||
?>
|
||||
<li class="resource-download-box__item">
|
||||
<div class="resource-download-box__row">
|
||||
<?php if ($is_protected && !$is_need_login) : ?>
|
||||
<button
|
||||
type="button"
|
||||
class="resource-download-box__btn btn-primary resource-download-box__btn--service-protected<?php echo esc_attr($btn_modifier); ?><?php echo esc_attr($btn_extra_class); ?>"
|
||||
data-jio-protected-asset="<?php echo esc_attr($asset_key); ?>"
|
||||
data-jio-protected-post="<?php echo esc_attr((string) $post_id); ?>"
|
||||
style="--service-color: <?php echo esc_attr($service['color']); ?>"
|
||||
>
|
||||
<?php echo $btn_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
<span><?php echo esc_html($btn_text); ?></span>
|
||||
<span class="resource-download-box__service-badge" style="background: <?php echo esc_attr($service['color']); ?>">
|
||||
<?php echo esc_html($service['name']); ?>
|
||||
</span>
|
||||
</button>
|
||||
<?php else : ?>
|
||||
<a
|
||||
class="resource-download-box__btn btn-primary resource-download-box__btn--service-<?php echo esc_attr($service['key']); ?><?php echo esc_attr($btn_modifier); ?><?php echo esc_attr($btn_extra_class); ?>"
|
||||
href="<?php echo esc_url($btn_href); ?>"<?php echo $btn_target; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><?php echo $btn_rel; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
|
||||
@@ -389,6 +418,7 @@ if ($is_need_purchase && $user_amount > 0) {
|
||||
<?php echo esc_html($service['name']); ?>
|
||||
</span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php if ($has_extract || $has_copy) : ?>
|
||||
<button type="button" class="resource-download-box__btn resource-download-box__btn--extract"
|
||||
<?php if ($has_copy && $copy_key !== '') : ?>data-toggle="tooltip" title="<?php echo esc_attr(sprintf(__('点击复制%s', 'jio'), $copy_key)); ?>"<?php endif; ?>
|
||||
|
||||
@@ -15,7 +15,15 @@ $status_labels = ['publish' => __('已发布', 'jio'), 'draft' => __('草稿', '
|
||||
<?php while ($q->have_posts()) : $q->the_post();
|
||||
$post_id = get_the_ID();
|
||||
$post_status = get_post_status();
|
||||
$edit_url = $front_submit && $front_submit->can_edit_post($post_id, (int) $user_id) ? jio_get_new_post_url($post_id) : '';
|
||||
$review_note = '';
|
||||
if ($front_submit && class_exists('\\Jio\\Core\\SubmissionVersion') && \Jio\Core\SubmissionVersion::requires_version((int) $post_id)) {
|
||||
$working = \Jio\Core\SubmissionVersion::get_working((int) $post_id, (int) $user_id);
|
||||
if ($working instanceof WP_Post) {
|
||||
$review_note = \Jio\Core\SubmissionVersion::status_label(\Jio\Core\SubmissionVersion::status((int) $working->ID));
|
||||
}
|
||||
}
|
||||
// 编辑入口始终指向源文章;创作工作台会自行解析作者当前的工作版本。
|
||||
$edit_url = $front_submit && $front_submit->can_edit_post($post_id, (int) $user_id) ? jio_get_new_post_url((int) $post_id) : '';
|
||||
$can_delete = $front_submit && $front_submit->can_delete_post($post_id, (int) $user_id);
|
||||
$price = (float) get_post_meta($post_id, '_jio_pay_price', true);
|
||||
?>
|
||||
@@ -23,7 +31,7 @@ $status_labels = ['publish' => __('已发布', 'jio'), 'draft' => __('草稿', '
|
||||
<a class="creator-post-row__thumb" href="<?php echo esc_url(get_permalink($post_id)); ?>" aria-label="<?php echo esc_attr(get_the_title()); ?>">
|
||||
<?php if (has_post_thumbnail($post_id)) : the_post_thumbnail('medium'); else : ?><span><i class="fa fas fa-pen" aria-hidden="true"></i></span><?php endif; ?>
|
||||
</a>
|
||||
<div class="creator-post-row__body"><div class="creator-post-row__heading"><h4><a href="<?php echo esc_url(get_permalink($post_id)); ?>"><?php echo esc_html(get_the_title()); ?></a></h4><span class="creator-post-row__status creator-post-row__status--<?php echo esc_attr($post_status); ?>"><?php echo esc_html($status_labels[$post_status] ?? $post_status); ?></span></div><p><?php echo esc_html(wp_trim_words(get_the_excerpt(), 20, '…')); ?></p><div class="creator-post-row__meta"><time datetime="<?php echo esc_attr(get_the_date('c')); ?>"><?php echo esc_html(get_the_date()); ?></time><span><?php echo $price > 0 ? esc_html__('付费内容', 'jio') : esc_html__('免费内容', 'jio'); ?></span></div></div>
|
||||
<div class="creator-post-row__body"><div class="creator-post-row__heading"><h4><a href="<?php echo esc_url(get_permalink($post_id)); ?>"><?php echo esc_html(get_the_title($post_id)); ?></a></h4><span class="creator-post-row__status creator-post-row__status--<?php echo esc_attr($post_status); ?>"><?php echo esc_html($status_labels[$post_status] ?? $post_status); ?></span><?php if ($review_note !== '') : ?><span class="creator-post-row__status creator-post-row__status--review"><?php echo esc_html(sprintf(__('改稿:%s', 'jio'), $review_note)); ?></span><?php endif; ?></div><p><?php echo esc_html(wp_trim_words(get_the_excerpt(), 20, '…')); ?></p><div class="creator-post-row__meta"><time datetime="<?php echo esc_attr(get_the_date('c')); ?>"><?php echo esc_html(get_the_date()); ?></time><span><?php echo $price > 0 ? esc_html__('付费内容', 'jio') : esc_html__('免费内容', 'jio'); ?></span></div></div>
|
||||
<div class="creator-post-row__actions"><?php if ($edit_url) : ?><a href="<?php echo esc_url($edit_url); ?>" class="creator-post-row__action"><?php esc_html_e('编辑', 'jio'); ?></a><?php endif; ?><a href="<?php echo esc_url(get_permalink($post_id)); ?>" class="creator-post-row__action"><?php esc_html_e('查看', 'jio'); ?></a><?php if ($can_delete) : ?><button type="button" class="creator-post-row__action creator-post-row__action--danger" data-action="delete-post" data-post-id="<?php echo esc_attr((string) $post_id); ?>" data-nonce="<?php echo esc_attr(wp_create_nonce('jio_submit')); ?>"><?php esc_html_e('删除', 'jio'); ?></button><?php endif; ?></div>
|
||||
</article>
|
||||
<?php endwhile; ?>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Creator Studio 契约测试。
|
||||
*
|
||||
* 这些断言锁定“服务端才是权威”的边界:状态、付费、审核与私有附件不能由前端
|
||||
* 直接写穿;已发布文章的改稿不得覆盖线上内容;私有附件不得暴露公开路径。
|
||||
*/
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const root = path.resolve(__dirname, '..', '..');
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
const exists = (file) => fs.existsSync(path.join(root, file));
|
||||
|
||||
const creatorRest = read('src/Core/CreatorRest.php');
|
||||
const versions = read('src/Core/SubmissionVersion.php');
|
||||
const privateAssets = read('src/Content/PrivateAssets.php');
|
||||
const protectedDownload = read('src/Content/ProtectedDownload.php');
|
||||
const studio = read('src/Core/CreatorStudio.php');
|
||||
const pageSubmit = read('templates/pages/page-submit.php');
|
||||
const saveTrait = read('src/Core/FrontSubmitSave.php');
|
||||
const domainModel = read('src/Core/DomainModel.php');
|
||||
const appSource = read('assets/creator-studio/src/index.tsx');
|
||||
const drawer = read('assets/creator-studio/src/panels/publish-drawer.tsx');
|
||||
const api = read('assets/creator-studio/src/services/api.ts');
|
||||
|
||||
test('creator REST routes always register a permission callback', () => {
|
||||
const routes = creatorRest.match(/register_rest_route\(/g) || [];
|
||||
const permissions = creatorRest.match(/'permission_callback'\s*=>/g) || [];
|
||||
assert.ok(routes.length >= 5, 'creator API should expose bootstrap/save/submit/preview/upload routes');
|
||||
assert.equal(routes.length, permissions.length, 'every creator route needs a permission callback');
|
||||
assert.doesNotMatch(creatorRest, /'permission_callback'\s*=>\s*'__return_true'/);
|
||||
});
|
||||
|
||||
test('studio writes reuse the existing submit service instead of raw post APIs', () => {
|
||||
assert.match(creatorRest, /\$front->persist\(\$data, 'posts_draft'\)/);
|
||||
assert.match(creatorRest, /\$front->persist\(\$data, 'posts_save'\)/);
|
||||
assert.doesNotMatch(creatorRest, /wp_insert_post\(/);
|
||||
assert.doesNotMatch(creatorRest, /wp_update_post\(\$update/);
|
||||
assert.match(creatorRest, /can_use_studio/);
|
||||
assert.match(creatorRest, /user_can_cap\('new_post_audit_no', \$user_id\)/);
|
||||
});
|
||||
|
||||
test('published edits never overwrite the live post before approval', () => {
|
||||
assert.match(versions, /public static function requires_version/);
|
||||
assert.match(versions, /post_status === 'publish'/);
|
||||
assert.match(versions, /self::POST_TYPE/);
|
||||
assert.match(versions, /public function approve/);
|
||||
assert.match(versions, /public function reject/);
|
||||
assert.match(versions, /jio_version_source_changed/);
|
||||
assert.match(versions, /'status'\s*=>\s*409/);
|
||||
// Approval is the only path that writes title/content back to the source post.
|
||||
assert.match(versions, /'ID'\s*=>\s*\$source_id[\s\S]{0,400}?wp_update_post/);
|
||||
assert.match(creatorRest, /SubmissionVersion::requires_version\(\$post_id\)[\s\S]{0,400}?save_working/);
|
||||
});
|
||||
|
||||
test('stale autosave requests are rejected instead of overwriting', () => {
|
||||
assert.match(versions, /'jio_version_conflict'/);
|
||||
assert.match(versions, /current_rev/);
|
||||
assert.match(versions, /base_rev/);
|
||||
assert.match(appSource, /status === 409/);
|
||||
});
|
||||
|
||||
test('paid payload is validated before any post write', () => {
|
||||
const prepareAt = saveTrait.indexOf('prepare_pay_meta');
|
||||
const insertAt = saveTrait.indexOf('wp_insert_post');
|
||||
const updateAt = saveTrait.indexOf('wp_update_post');
|
||||
assert.notEqual(prepareAt, -1, 'persist must validate pay payload');
|
||||
assert.ok(prepareAt < insertAt, 'pay validation must precede insert');
|
||||
assert.ok(prepareAt < updateAt, 'pay validation must precede update');
|
||||
assert.match(saveTrait, /if \(empty\(\$pay_result\['success'\]\)\) \{\s*return \$pay_result;/);
|
||||
});
|
||||
|
||||
test('private attachments never expose a public URL', () => {
|
||||
assert.match(privateAssets, /define\('JIO_PRIVATE_STORAGE_DIR'/);
|
||||
assert.match(privateAssets, /is_available\(\)/);
|
||||
assert.match(privateAssets, /is_verified/);
|
||||
assert.match(privateAssets, /return \$outside \|\| \$explicit;/);
|
||||
assert.doesNotMatch(privateAssets, /wp_upload_dir\(/);
|
||||
assert.doesNotMatch(privateAssets, /wp_get_attachment_url/);
|
||||
assert.match(privateAssets, /asset_key/);
|
||||
assert.match(privateAssets, /@chmod\(\$target, 0600\)/);
|
||||
});
|
||||
|
||||
test('protected downloads require entitlement, tokens, and the private asset id', () => {
|
||||
assert.match(protectedDownload, /jio_can_download/);
|
||||
assert.match(protectedDownload, /Visibility::can_download_resource/);
|
||||
assert.match(protectedDownload, /asset_id/);
|
||||
assert.match(protectedDownload, /jio_token_expired/);
|
||||
assert.match(protectedDownload, /jio_token_exhausted/);
|
||||
assert.match(protectedDownload, /UPDATE \{\$table\} SET used_count = used_count \+ 1/);
|
||||
assert.match(protectedDownload, /GREATEST\(used_count - 1, 0\)/);
|
||||
assert.match(protectedDownload, /X-Content-Type-Options/);
|
||||
assert.doesNotMatch(protectedDownload, /wp_get_attachment_url/);
|
||||
});
|
||||
|
||||
test('model stores protected downloads by asset key only', () => {
|
||||
assert.match(domainModel, /'protected'/);
|
||||
assert.match(domainModel, /'asset_key'/);
|
||||
assert.match(domainModel, /PrivateAssets::sanitize_asset_key/);
|
||||
assert.match(domainModel, /\$is_protected\) \{\s*\$type = 'protected';\s*\$url = '';/);
|
||||
});
|
||||
|
||||
test('studio is mounted only on the submit template with a fallback', () => {
|
||||
assert.match(pageSubmit, /CreatorStudio::get_instance\(\)/);
|
||||
assert.match(pageSubmit, /is_built\(\)/);
|
||||
assert.match(pageSubmit, /get_current_user_id\(\) > 0/);
|
||||
assert.match(pageSubmit, /id="jio-creator-studio"/);
|
||||
// Guests and missing builds fall back to the existing TinyMCE form.
|
||||
assert.match(pageSubmit, /wp_enqueue_editor\(\);/);
|
||||
assert.match(studio, /is_page_template\('templates\/pages\/page-submit.php'\)/);
|
||||
assert.match(studio, /index\.asset\.php/);
|
||||
assert.match(studio, /wp_add_inline_script/);
|
||||
});
|
||||
|
||||
test('studio app keeps autosave, recovery, and the publish drawer wired', () => {
|
||||
assert.match(appSource, /useAutosave/);
|
||||
assert.match(appSource, /useLocalRecovery/);
|
||||
assert.match(appSource, /PublishDrawer/);
|
||||
assert.match(appSource, /BlockEditorProvider/);
|
||||
assert.match(appSource, /registerCoreBlocks/);
|
||||
assert.match(appSource, /installMediaUploadBridge/);
|
||||
assert.match(api, /'X-WP-Nonce'/);
|
||||
assert.match(drawer, /canPay/);
|
||||
assert.match(drawer, /canPayDownload/);
|
||||
assert.match(drawer, /私有附件存储尚未配置/);
|
||||
assert.match(drawer, /onSubmit\(false\)/);
|
||||
assert.match(drawer, /立即发布/);
|
||||
});
|
||||
|
||||
test('creator studio build artifacts and contracts exist', () => {
|
||||
assert.ok(exists('assets/creator-studio/package.json'));
|
||||
assert.ok(exists('assets/creator-studio/package-lock.json'));
|
||||
assert.ok(exists('assets/creator-studio/build/index.asset.php'));
|
||||
assert.ok(exists('assets/creator-studio/build/index.js'));
|
||||
assert.ok(exists('assets/creator-studio/build/index.css'));
|
||||
assert.ok(exists('assets/js/protected-download.js'));
|
||||
assert.ok(exists('src/Core/PreviewToken.php'));
|
||||
assert.ok(exists('src/Admin/Native/SubmissionReview.php'));
|
||||
});
|
||||
@@ -28,7 +28,9 @@ test('front submit owns the typed media REST routes and media trait wiring', ()
|
||||
assert.match(frontSubmit, /use FrontSubmitMedia\s*;/);
|
||||
assert.match(frontSubmit, /add_action\('rest_api_init', \[\$this, 'register_rest_routes'\]\)/);
|
||||
assert.doesNotMatch(frontSubmit, /add_action\('jio_new_(?:add|edit)_posts', \[\$this, 'sync_post_attachments'\]\)/);
|
||||
assert.match(saveTrait, /\$this->sync_post_attachments\(\$post\);\s*if \(!\$pay_result\['success'\]\)/);
|
||||
// Paid fields are validated before the post write; saved posts always sync attachments.
|
||||
assert.match(saveTrait, /\$pay_result = \$this->prepare_pay_meta\([\s\S]{0,120}?if \(empty\(\$pay_result\['success'\]\)\) \{\s*return \$pay_result;/);
|
||||
assert.match(saveTrait, /\$this->sync_post_attachments\(\$post\);[\s\S]{0,200}?do_action\(/);
|
||||
|
||||
assert.match(frontSubmit, /register_rest_route\('jio\/v1', '\/front-submit\/media\/\(\?P<type>image\|video\|file\)'/);
|
||||
assert.match(frontSubmit, /'methods'\s*=>\s*\\WP_REST_Server::READABLE/);
|
||||
|
||||
@@ -376,15 +376,19 @@ namespace Jio\Core {
|
||||
return $cap !== 'new_post_audit_no';
|
||||
}
|
||||
|
||||
private function save_pay_meta(int $post_id, array $data, int $user_id, array $settings): array {
|
||||
if (isset($data['fake_saved_pay_meta']) && is_array($data['fake_saved_pay_meta'])) {
|
||||
$GLOBALS['front_pending_state']['pay_meta'][$post_id] = $data['fake_saved_pay_meta'];
|
||||
\update_post_meta($post_id, DomainModel::PAY_META_KEY, $data['fake_saved_pay_meta']);
|
||||
}
|
||||
private function prepare_pay_meta(int $post_id, array $data, int $user_id, array $settings): array {
|
||||
if (!empty($data['fake_pay_failure'])) {
|
||||
return ['success' => false, 'message' => 'pay validation failed'];
|
||||
}
|
||||
return ['success' => true];
|
||||
if (isset($data['fake_saved_pay_meta']) && is_array($data['fake_saved_pay_meta'])) {
|
||||
return ['success' => true, 'write' => true, 'meta' => $data['fake_saved_pay_meta']];
|
||||
}
|
||||
return ['success' => true, 'write' => false];
|
||||
}
|
||||
|
||||
private function write_pay_meta(int $post_id, array $meta): void {
|
||||
$GLOBALS['front_pending_state']['pay_meta'][$post_id] = $meta;
|
||||
\update_post_meta($post_id, DomainModel::PAY_META_KEY, $meta);
|
||||
}
|
||||
|
||||
private function save_featured_meta(int $post_id, array $data, int $user_id): void {
|
||||
@@ -537,21 +541,23 @@ namespace {
|
||||
fp_assert(get_post_meta(441, '_jio_front_submit_media', true) === '', 'removed media should lose only its submit relation marker');
|
||||
fp_assert(!fp_pending(441) && $GLOBALS['front_pending_state']['deleted'] === [], 'removed media must not be deleted or marked pending again');
|
||||
|
||||
// A payment validation error happens after the body write. The saved body is
|
||||
// protected, but the request's featured values were never persisted and must
|
||||
// therefore not be considered references.
|
||||
// A payment validation error is resolved before anything is written, so the
|
||||
// request leaves no partially saved post and no claimed reference. Featured
|
||||
// values are never considered persisted either.
|
||||
fp_reset();
|
||||
fp_attachment(450, 7, 'image/jpeg');
|
||||
fp_attachment(451, 7, 'image/jpeg');
|
||||
$pay_error = $submit->persist(fp_article_data('<p>Body remains persisted after pay error.</p><img data-edit-file-id="450" src="/saved.jpg">', [
|
||||
$pay_error = $submit->persist(fp_article_data('<p>Body must not persist after pay error.</p><img data-edit-file-id="450" src="/saved.jpg">', [
|
||||
'fake_pay_failure' => true,
|
||||
'fake_featured_meta' => ['featured_video_attachment_id' => 0, 'featured_slide' => '451'],
|
||||
'fake_thumbnail_id' => 451,
|
||||
]));
|
||||
fp_assert(empty($pay_error['success']) && (int) ($pay_error['post_id'] ?? 0) > 0, 'pay validation should return its error after the post was written');
|
||||
fp_assert(!fp_pending(450), 'persisted body reference should be claimed even when payment validation fails');
|
||||
fp_assert(empty($pay_error['success']), 'pay validation should fail before the post is written');
|
||||
fp_assert((int) ($pay_error['post_id'] ?? 0) === 0, 'a failed pay validation must not report a saved post');
|
||||
fp_assert(fp_pending(450), 'body media must stay pending when validation fails before the write');
|
||||
fp_assert(fp_pending(451), 'unsaved featured media from failed request must not be claimed');
|
||||
fp_assert($GLOBALS['front_pending_state']['featured_save_calls'] === 0, 'featured metadata must not be saved after payment failure');
|
||||
fp_assert(($GLOBALS['front_pending_state']['posts'][450]->post_parent ?? 0) === 0, 'validation failure must not bind media to a post');
|
||||
|
||||
// Failed insert/update writes cannot claim anything because no saved post is
|
||||
// synchronized. Test both the create and edit branches.
|
||||
|
||||
@@ -134,12 +134,12 @@ test('preserves readable labels through the admin category filter, list, and det
|
||||
assert.equal(result.detail_report.category, '涉嫌侵权');
|
||||
});
|
||||
|
||||
test('stores report categories as text and uses schema version 3.3.0', () => {
|
||||
test('stores report categories as text and uses the current schema version', () => {
|
||||
const migrator = fs.readFileSync(path.join(root, 'src/Core/DBMigrator.php'), 'utf8');
|
||||
const tableDefinitions = fs.readFileSync(path.join(root, 'src/Core/DBMigratorPart2.php'), 'utf8');
|
||||
const reportTable = tableDefinitions.match(/\$sql_report\s*=\s*"([\s\S]*?)";/);
|
||||
|
||||
assert.match(migrator, /const SCHEMA_VERSION\s*=\s*'3\.3\.0';/);
|
||||
assert.match(migrator, /const SCHEMA_VERSION\s*=\s*'3\.4\.0';/);
|
||||
assert.ok(reportTable, 'report table definition should exist');
|
||||
assert.match(reportTable[1], /\bcategory\s+TEXT\s+NOT NULL\b/);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
/**
|
||||
* 工单表结构契约:
|
||||
* 1) 6 张 jio_ticket* 表全部由 DBMigratorPart2 建出,且被 migrate_ticket_tables() 调用;
|
||||
* 2) SCHEMA_VERSION 已升到 3.3.0(dbDelta 只在版本变化时重跑);
|
||||
* 2) SCHEMA_VERSION 已升到当前版本(dbDelta 只在版本变化时重跑);
|
||||
* 3) jio_order_aftersale 已从 schema 与表名映射中彻底移除;
|
||||
* 4) 状态与部门 seeds 幂等,默认状态被标记为不可改删。
|
||||
*/
|
||||
@@ -27,7 +27,7 @@ const TABLES = [
|
||||
|
||||
test('schema version is bumped so dbDelta re-runs', () => {
|
||||
const migrator = read('src/Core/DBMigrator.php');
|
||||
assert.match(migrator, /const SCHEMA_VERSION = '3\.3\.0';/, 'SCHEMA_VERSION must be 3.3.0');
|
||||
assert.match(migrator, /const SCHEMA_VERSION = '3\.4\.0';/, 'SCHEMA_VERSION must be bumped for new tables/columns');
|
||||
assert.ok(migrator.includes('use DBMigratorTicket;'), 'DBMigratorTicket trait not composed');
|
||||
// 版本注释要说明每次升版做了什么,否则后人看不懂升版原因。
|
||||
assert.match(migrator, /3\.0\.0: 售后(order_aftersale)整体替换为工单系统/);
|
||||
|
||||
@@ -74,6 +74,7 @@ const runtimePreludeScripts = [
|
||||
'assets/js/core/footer-tabbar.js',
|
||||
'assets/js/core/misc.js',
|
||||
'assets/js/core/theme.js',
|
||||
'assets/js/protected-download.js',
|
||||
'assets/js/follow.js',
|
||||
'assets/js/enhancements.js',
|
||||
'assets/js/enhancements2.js',
|
||||
|
||||
Reference in new issue
Block a user