增加文章同步功能

This commit is contained in:
fzk
2026-10-08 11:34:47 +08:00
parent 086e4f23f9
commit 8ae12eff1b
85 changed files with 16166 additions and 18 deletions
+217
View File
@@ -0,0 +1,217 @@
import { DeleteOutlined, PlusOutlined, ApiOutlined } from "@ant-design/icons"
import {
Button,
Form,
Input,
message,
Modal,
Popconfirm,
Select,
Space,
Table,
Tag
} from "antd"
import { useEffect, useState } from "react"
import {
CMS_TYPES,
checkCmsAuth,
getCmsAccounts,
removeCmsAccount,
saveCmsAccount,
type CmsAccount
} from "~sync/extension/engine"
export interface CmsManagerProps {
/** 账号列表变化时通知父组件 */
onChange?: (accounts: CmsAccount[]) => void
}
interface CmsFormValues {
type: CmsAccount["type"]
name: string
url: string
username: string
password: string
}
/**
* 自建站(WordPress / Typecho / MetaWeblog)账号管理
*/
export default function CmsManager({ onChange }: CmsManagerProps) {
const [accounts, setAccounts] = useState<CmsAccount[]>([])
const [status, setStatus] = useState<
Record<string, { checking: boolean; ok?: boolean; error?: string }>
>({})
const [open, setOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [form] = Form.useForm<CmsFormValues>()
async function refresh() {
const list = await getCmsAccounts()
setAccounts(list)
onChange?.(list)
}
useEffect(() => {
refresh()
}, [])
async function handleAdd() {
try {
const values = await form.validateFields()
setSaving(true)
const account = await saveCmsAccount(
{
type: values.type,
name: values.name || `${values.type} 站点`,
url: values.url.trim(),
username: values.username
},
values.password
)
message.success("账号已保存")
setOpen(false)
form.resetFields()
await refresh()
await check(account)
} catch (error) {
if ((error as { errorFields?: unknown }).errorFields) return
message.error((error as Error).message)
} finally {
setSaving(false)
}
}
async function check(account: CmsAccount) {
setStatus((prev) => ({ ...prev, [account.id]: { checking: true } }))
const result = await checkCmsAuth(account)
setStatus((prev) => ({
...prev,
[account.id]: { checking: false, ok: result.isAuthenticated, error: result.error }
}))
}
async function handleRemove(id: string) {
await removeCmsAccount(id)
message.success("账号已删除")
await refresh()
}
return (
<div>
<Space style={{ marginBottom: 12 }}>
<Button
size="small"
type="primary"
icon={<PlusOutlined />}
onClick={() => setOpen(true)}>
添加自建站
</Button>
<span style={{ fontSize: 12, color: "#999" }}>
支持 WordPress、Typecho 及兼容 MetaWeblog 的博客,同步为草稿
</span>
</Space>
{accounts.length > 0 && (
<Table<CmsAccount>
size="small"
rowKey="id"
pagination={false}
dataSource={accounts}
columns={[
{ title: "名称", dataIndex: "name", width: 160 },
{
title: "类型",
dataIndex: "type",
width: 130,
render: (type: string) =>
CMS_TYPES.find((t) => t.value === type)?.label || type
},
{ title: "站点", dataIndex: "url", ellipsis: true },
{ title: "用户名", dataIndex: "username", width: 120 },
{
title: "状态",
width: 120,
render: (_, account) => {
const item = status[account.id]
if (!item || item.checking) return <Tag>未检测</Tag>
return item.ok ? (
<Tag color="success">连接正常</Tag>
) : (
<Tag color="error" title={item.error}>
连接失败
</Tag>
)
}
},
{
title: "操作",
width: 140,
render: (_, account) => (
<Space>
<Button
size="small"
type="link"
icon={<ApiOutlined />}
loading={status[account.id]?.checking}
onClick={() => check(account)}>
测试
</Button>
<Popconfirm
title="删除该账号?"
onConfirm={() => handleRemove(account.id)}>
<Button size="small" type="link" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
)
}
]}
/>
)}
<Modal
title="添加自建站账号"
open={open}
onCancel={() => setOpen(false)}
onOk={handleAdd}
confirmLoading={saving}
okText="保存并测试">
<Form form={form} layout="vertical" initialValues={{ type: "wordpress" }}>
<Form.Item
name="type"
label="类型"
rules={[{ required: true, message: "请选择类型" }]}>
<Select options={CMS_TYPES} />
</Form.Item>
<Form.Item name="name" label="名称">
<Input placeholder="如:我的博客" />
</Form.Item>
<Form.Item
name="url"
label="站点地址"
rules={[{ required: true, message: "请输入站点地址" }]}
extra="如 https://blog.example.com,无需带结尾斜杠">
<Input placeholder="https://blog.example.com" />
</Form.Item>
<Form.Item
name="username"
label="用户名"
rules={[{ required: true, message: "请输入用户名" }]}>
<Input placeholder="WordPress / Typecho 登录用户名" />
</Form.Item>
<Form.Item
name="password"
label="密码 / 应用密码"
rules={[{ required: true, message: "请输入密码" }]}
extra="WordPress 建议使用「应用密码」,账号密码仅保存在本地浏览器中">
<Input.Password placeholder="密码" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
+172
View File
@@ -0,0 +1,172 @@
import { ReloadOutlined } from "@ant-design/icons"
import { Avatar, Button, Checkbox, Empty, Space, Tag, Tooltip, Typography } from "antd"
import type { PlatformAuthItem } from "~sync/extension/engine"
const { Text } = Typography
export interface PlatformPickerProps {
platforms: PlatformAuthItem[]
selected: string[]
loading: boolean
onChange: (selected: string[]) => void
onRefresh: () => void
}
/**
* 平台选择面板(按分类分组,展示登录状态)
*/
export default function PlatformPicker({
platforms,
selected,
loading,
onChange,
onRefresh
}: PlatformPickerProps) {
const selectedSet = new Set(selected)
function toggle(id: string, checked: boolean) {
const next = new Set(selectedSet)
if (checked) {
next.add(id)
} else {
next.delete(id)
}
onChange(Array.from(next))
}
function selectAll(authenticatedOnly: boolean) {
const next = platforms
.filter((p) => (authenticatedOnly ? p.isAuthenticated : true))
.map((p) => p.id)
onChange(next)
}
// 按分类分组
const groups = new Map<string, PlatformAuthItem[]>()
for (const platform of platforms) {
const category = platform.category || "其他"
if (!groups.has(category)) groups.set(category, [])
groups.get(category)!.push(platform)
}
const authenticatedCount = platforms.filter((p) => p.isAuthenticated).length
return (
<div>
<Space wrap style={{ marginBottom: 12 }}>
<Button size="small" onClick={() => selectAll(true)}>
选择已登录({authenticatedCount})
</Button>
<Button size="small" onClick={() => selectAll(false)}>
全选({platforms.length})
</Button>
<Button size="small" onClick={() => onChange([])}>
清空
</Button>
<Button
size="small"
icon={<ReloadOutlined />}
loading={loading}
onClick={onRefresh}>
刷新登录状态
</Button>
<Text type="secondary" style={{ fontSize: 12 }}>
已选 {selected.length} 个平台
</Text>
</Space>
{platforms.length === 0 ? (
<Empty description="正在加载平台列表..." />
) : (
Array.from(groups.entries()).map(([category, items]) => (
<div key={category} style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8 }}>
<Tag color="green">{category}</Tag>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(210px, 1fr))",
gap: 8
}}>
{items.map((platform) => (
<PlatformCard
key={platform.id}
platform={platform}
checked={selectedSet.has(platform.id)}
onToggle={(checked) => toggle(platform.id, checked)}
/>
))}
</div>
</div>
))
)}
</div>
)
}
function PlatformCard({
platform,
checked,
onToggle
}: {
platform: PlatformAuthItem
checked: boolean
onToggle: (checked: boolean) => void
}) {
const statusTag = platform.isAuthenticated ? (
<Tag color="success" style={{ maxWidth: 110, overflow: "hidden" }}>
{platform.username || "已登录"}
</Tag>
) : (
<Tag color="default">未登录</Tag>
)
return (
<div
onClick={() => onToggle(!checked)}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 10px",
border: checked ? "1px solid #52c41a" : "1px solid #f0f0f0",
borderRadius: 8,
cursor: "pointer",
background: checked ? "#f6ffed" : "#fff"
}}>
{/* 阻止冒泡,避免点击勾选框时与外层卡片点击重复触发 */}
<span onClick={(e) => e.stopPropagation()}>
<Checkbox checked={checked} onChange={(e) => onToggle(e.target.checked)} />
</span>
<Avatar
size={22}
src={platform.icon}
style={{ backgroundColor: "#f0f0f0", fontSize: 12 }}>
{platform.name?.[0]}
</Avatar>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<Text ellipsis style={{ fontSize: 13 }}>
{platform.name}
</Text>
{platform.experimental && (
<Tooltip title="半自动同步:打开官方编辑器并自动填充内容,需人工确认发布">
<Tag color="orange" style={{ marginInlineEnd: 0, fontSize: 10 }}>
实验
</Tag>
</Tooltip>
)}
</div>
<div style={{ marginTop: 2 }}>
{platform.error && !platform.isAuthenticated ? (
<Tooltip title={platform.error}>{statusTag}</Tooltip>
) : (
statusTag
)}
</div>
</div>
</div>
)
}
+113
View File
@@ -0,0 +1,113 @@
import { CheckCircleTwoTone, CloseCircleTwoTone, LinkOutlined } from "@ant-design/icons"
import { Progress, Spin, Tag, Tooltip, Typography } from "antd"
import type { SyncDetailProgress } from "~sync/extension/engine"
import type { SyncResult } from "~sync/types"
const { Text } = Typography
export interface SyncProgressProps {
/** 正在同步的平台数(用于计算整体进度) */
total: number
progress: Record<string, SyncDetailProgress>
results: SyncResult[]
platformNames: Record<string, string>
}
/**
* 同步进度与结果
*/
export default function SyncProgress({
total,
progress,
results,
platformNames
}: SyncProgressProps) {
const finished = results.length
const percent = total > 0 ? Math.round((finished / total) * 100) : 0
const platformIds = Object.keys(progress)
return (
<div>
<Progress percent={percent} status={percent === 100 ? "success" : "active"} />
<div style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 6 }}>
{platformIds.map((platformId) => {
const item = progress[platformId]
const name = platformNames[platformId] || item.platformName || platformId
return (
<div
key={platformId}
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
padding: "4px 0",
borderBottom: "1px solid #fafafa"
}}>
<StageIcon stage={item.stage} />
<span style={{ width: 130 }}>{name}</span>
<StageText stage={item.stage} imageProgress={item.imageProgress} />
{item.result?.message && (
<Tooltip title={item.result.message}>
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>
{item.result.message.slice(0, 40)}
</Text>
</Tooltip>
)}
{item.stage === "completed" && item.result?.postUrl && (
<a
href={item.result.postUrl}
target="_blank"
rel="noreferrer"
style={{ fontSize: 12 }}>
<LinkOutlined /> 打开
</a>
)}
</div>
)
})}
</div>
</div>
)
}
function StageIcon({ stage }: { stage: SyncDetailProgress["stage"] }) {
if (stage === "completed") {
return <CheckCircleTwoTone twoToneColor="#52c41a" />
}
if (stage === "failed") {
return <CloseCircleTwoTone twoToneColor="#ff4d4f" />
}
return <Spin size="small" />
}
function StageText({
stage,
imageProgress
}: {
stage: SyncDetailProgress["stage"]
imageProgress?: { current: number; total: number }
}) {
switch (stage) {
case "starting":
return <Tag>准备中</Tag>
case "uploading_images":
return (
<Tag color="blue">
上传图片 {imageProgress ? `${imageProgress.current}/${imageProgress.total}` : ""}
</Tag>
)
case "saving":
return <Tag color="processing">保存中</Tag>
case "completed":
return <Tag color="success">已完成</Tag>
case "failed":
return <Tag color="error">失败</Tag>
default:
return null
}
}