Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
458 lines
14 KiB
TypeScript
458 lines
14 KiB
TypeScript
/**
|
||
* 发布配置抽屉。
|
||
*
|
||
* 分类、标签、付费与资料附件都在这里配置;封面与定时发布在正文下方的
|
||
* 「发布设置」卡片里,避免把封面入口和正文挤在同一张纸上。
|
||
* 手机端以全屏面板呈现。所有字段与权限来自服务端 bootstrap,前端不自行放大权限。
|
||
*
|
||
* @package Jio
|
||
*/
|
||
|
||
import { useEffect, useMemo, useRef, useState } from '@wordpress/element';
|
||
import { __, sprintf } from '@wordpress/i18n';
|
||
|
||
import { apiUpload } from '../services/api';
|
||
|
||
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;
|
||
categories: number[];
|
||
tags: string[];
|
||
custom: Record<string, string[]>;
|
||
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 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 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>
|
||
<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>
|
||
);
|
||
}
|