mirror of
https://github.com/027xiguapi/code-box.git
synced 2026-10-19 05:21:10 +00:00
增加文章同步功能
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user