Files
wordpress-theme-aksrt/assets/creator-studio/src/panels/publish-drawer.tsx
T
2026-11-06 21:33:40 +08:00

458 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 发布配置抽屉。
*
* 分类、标签、付费与资料附件都在这里配置;封面与定时发布在正文下方的
* 「发布设置」卡片里,避免把封面入口和正文挤在同一张纸上。
* 手机端以全屏面板呈现。所有字段与权限来自服务端 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>
);
}