- 新增迁移中心 Tab - 自动迁移:填写 api.php 地址,可选 Bot 凭据,支持明确允许内网来源;可配置过时页面筛选规则(未更新天数、维护者统计窗口、维护者人数上限、过时标记) - XML 导入:上传 MediaWiki 导出文件建立暂存任务 - 迁移任务列表:状态标签、审核进度条、已审核/导入/排除统计、错误信息 - 任务详情:分页加载候选页面,逐条显示更新时间、维护者数量、可疑原因,可设置目标标题与是否覆盖,执行导入或排除 - 整批发布与回滚,失败或已回滚任务可删除记录 - 补充迁移相关 API 与类型定义 - 中英文各补 55 个文案
This commit is contained in:
1 parent
667c1a0900
commit
febbd84ae7
4 files changed
+986
-4
No files matched your search
@@ -16,6 +16,16 @@ import {
|
|||||||
type SkinSiteWikiSettings,
|
type SkinSiteWikiSettings,
|
||||||
skinSiteWikiSettings,
|
skinSiteWikiSettings,
|
||||||
skinSiteWikiUpdateSettings,
|
skinSiteWikiUpdateSettings,
|
||||||
|
skinSiteWikiMigrationCandidates,
|
||||||
|
skinSiteWikiMigrationDecide,
|
||||||
|
skinSiteWikiMigrationDelete,
|
||||||
|
skinSiteWikiMigrationJobs,
|
||||||
|
skinSiteWikiMigrationPublish,
|
||||||
|
skinSiteWikiMigrationRollback,
|
||||||
|
skinSiteWikiMigrationStartAutomatic,
|
||||||
|
skinSiteWikiMigrationStartXml,
|
||||||
|
type SkinSiteWikiMigrationCandidate,
|
||||||
|
type SkinSiteWikiMigrationJobInfo,
|
||||||
} from '@/helpers/skin-site-api'
|
} from '@/helpers/skin-site-api'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
@@ -26,6 +36,198 @@ const messages = defineMessages({
|
|||||||
tabPage: { id: 'app.skin-site.admin.wiki.tab-page', defaultMessage: 'Page' },
|
tabPage: { id: 'app.skin-site.admin.wiki.tab-page', defaultMessage: 'Page' },
|
||||||
tabAudit: { id: 'app.skin-site.admin.wiki.tab-audit', defaultMessage: 'Audit log' },
|
tabAudit: { id: 'app.skin-site.admin.wiki.tab-audit', defaultMessage: 'Audit log' },
|
||||||
tabSettings: { id: 'app.skin-site.admin.wiki.tab-settings', defaultMessage: 'Settings' },
|
tabSettings: { id: 'app.skin-site.admin.wiki.tab-settings', defaultMessage: 'Settings' },
|
||||||
|
tabMigration: { id: 'app.skin-site.admin.wiki.tab-migration', defaultMessage: 'Migration' },
|
||||||
|
migrationTitle: {
|
||||||
|
id: 'app.skin-site.admin.wiki.migration-title',
|
||||||
|
defaultMessage: 'MediaWiki migration',
|
||||||
|
},
|
||||||
|
migrationHint: {
|
||||||
|
id: 'app.skin-site.admin.wiki.migration-hint',
|
||||||
|
defaultMessage:
|
||||||
|
'Migrated content lands in a hidden staging area and is published in one batch after every page is reviewed.',
|
||||||
|
},
|
||||||
|
autoTitle: {
|
||||||
|
id: 'app.skin-site.admin.wiki.auto-title',
|
||||||
|
defaultMessage: 'Read from MediaWiki API',
|
||||||
|
},
|
||||||
|
apiUrl: { id: 'app.skin-site.admin.wiki.api-url', defaultMessage: 'api.php URL' },
|
||||||
|
botUsername: {
|
||||||
|
id: 'app.skin-site.admin.wiki.bot-username',
|
||||||
|
defaultMessage: 'Bot username (optional)',
|
||||||
|
},
|
||||||
|
botPassword: {
|
||||||
|
id: 'app.skin-site.admin.wiki.bot-password',
|
||||||
|
defaultMessage: 'Bot password (optional)',
|
||||||
|
},
|
||||||
|
credentialsHint: {
|
||||||
|
id: 'app.skin-site.admin.wiki.credentials-hint',
|
||||||
|
defaultMessage: 'Credentials stay in memory for this job only. HTTPS is required when supplied.',
|
||||||
|
},
|
||||||
|
allowPrivate: {
|
||||||
|
id: 'app.skin-site.admin.wiki.allow-private',
|
||||||
|
defaultMessage: 'Allow private network source',
|
||||||
|
},
|
||||||
|
allowPrivateHint: {
|
||||||
|
id: 'app.skin-site.admin.wiki.allow-private-hint',
|
||||||
|
defaultMessage: 'Private sources may reach services inside the server network.',
|
||||||
|
},
|
||||||
|
startAuto: {
|
||||||
|
id: 'app.skin-site.admin.wiki.start-auto',
|
||||||
|
defaultMessage: 'Create automatic migration job',
|
||||||
|
},
|
||||||
|
xmlTitle: {
|
||||||
|
id: 'app.skin-site.admin.wiki.xml-title',
|
||||||
|
defaultMessage: 'Import MediaWiki XML',
|
||||||
|
},
|
||||||
|
xmlFile: { id: 'app.skin-site.admin.wiki.xml-file', defaultMessage: 'Export file' },
|
||||||
|
startXml: {
|
||||||
|
id: 'app.skin-site.admin.wiki.start-xml',
|
||||||
|
defaultMessage: 'Upload and stage',
|
||||||
|
},
|
||||||
|
staleRules: {
|
||||||
|
id: 'app.skin-site.admin.wiki.stale-rules',
|
||||||
|
defaultMessage: 'Stale page detection rules',
|
||||||
|
},
|
||||||
|
staleDays: {
|
||||||
|
id: 'app.skin-site.admin.wiki.stale-days',
|
||||||
|
defaultMessage: 'Days without update',
|
||||||
|
},
|
||||||
|
maintainerWindow: {
|
||||||
|
id: 'app.skin-site.admin.wiki.maintainer-window',
|
||||||
|
defaultMessage: 'Maintainer window (days)',
|
||||||
|
},
|
||||||
|
maxMaintainers: {
|
||||||
|
id: 'app.skin-site.admin.wiki.max-maintainers',
|
||||||
|
defaultMessage: 'Max maintainers',
|
||||||
|
},
|
||||||
|
staleMarkers: {
|
||||||
|
id: 'app.skin-site.admin.wiki.stale-markers',
|
||||||
|
defaultMessage: 'Stale categories, templates or text',
|
||||||
|
},
|
||||||
|
staleMarkersHint: {
|
||||||
|
id: 'app.skin-site.admin.wiki.stale-markers-hint',
|
||||||
|
defaultMessage: 'Comma separated',
|
||||||
|
},
|
||||||
|
jobs: { id: 'app.skin-site.admin.wiki.jobs', defaultMessage: 'Migration jobs' },
|
||||||
|
refreshJobs: {
|
||||||
|
id: 'app.skin-site.admin.wiki.refresh-jobs',
|
||||||
|
defaultMessage: 'Refresh',
|
||||||
|
},
|
||||||
|
jobsEmpty: {
|
||||||
|
id: 'app.skin-site.admin.wiki.jobs-empty',
|
||||||
|
defaultMessage: 'No migration jobs yet.',
|
||||||
|
},
|
||||||
|
reviewedCount: {
|
||||||
|
id: 'app.skin-site.admin.wiki.reviewed-count',
|
||||||
|
defaultMessage: 'Reviewed {reviewed} / {total}',
|
||||||
|
},
|
||||||
|
importedExcluded: {
|
||||||
|
id: 'app.skin-site.admin.wiki.imported-excluded',
|
||||||
|
defaultMessage: 'Imported {imported} · Excluded {excluded}',
|
||||||
|
},
|
||||||
|
viewJob: { id: 'app.skin-site.admin.wiki.view-job', defaultMessage: 'View job' },
|
||||||
|
deleteJob: {
|
||||||
|
id: 'app.skin-site.admin.wiki.delete-job',
|
||||||
|
defaultMessage: 'Delete job record',
|
||||||
|
},
|
||||||
|
confirmDeleteJob: {
|
||||||
|
id: 'app.skin-site.admin.wiki.confirm-delete-job',
|
||||||
|
defaultMessage: 'Delete migration job #{id}? This cannot be undone.',
|
||||||
|
},
|
||||||
|
candidates: {
|
||||||
|
id: 'app.skin-site.admin.wiki.candidates',
|
||||||
|
defaultMessage: 'Candidate pages',
|
||||||
|
},
|
||||||
|
candidatesEmpty: {
|
||||||
|
id: 'app.skin-site.admin.wiki.candidates-empty',
|
||||||
|
defaultMessage: 'Select a job to review its pages.',
|
||||||
|
},
|
||||||
|
loadMore: { id: 'app.skin-site.admin.wiki.load-more', defaultMessage: 'Load more' },
|
||||||
|
lastUpdated: {
|
||||||
|
id: 'app.skin-site.admin.wiki.last-updated',
|
||||||
|
defaultMessage: 'Updated {date}',
|
||||||
|
},
|
||||||
|
maintainerCount: {
|
||||||
|
id: 'app.skin-site.admin.wiki.maintainer-count',
|
||||||
|
defaultMessage: '{count} maintainers',
|
||||||
|
},
|
||||||
|
conflict: {
|
||||||
|
id: 'app.skin-site.admin.wiki.conflict',
|
||||||
|
defaultMessage: 'Title conflict',
|
||||||
|
},
|
||||||
|
targetTitle: {
|
||||||
|
id: 'app.skin-site.admin.wiki.target-title',
|
||||||
|
defaultMessage: 'Target title',
|
||||||
|
},
|
||||||
|
overwrite: {
|
||||||
|
id: 'app.skin-site.admin.wiki.overwrite',
|
||||||
|
defaultMessage: 'Overwrite',
|
||||||
|
},
|
||||||
|
importAction: { id: 'app.skin-site.admin.wiki.import', defaultMessage: 'Import' },
|
||||||
|
excludeAction: { id: 'app.skin-site.admin.wiki.exclude', defaultMessage: 'Exclude' },
|
||||||
|
publish: { id: 'app.skin-site.admin.wiki.publish', defaultMessage: 'Publish batch' },
|
||||||
|
rollback: { id: 'app.skin-site.admin.wiki.rollback', defaultMessage: 'Roll back' },
|
||||||
|
confirmRollback: {
|
||||||
|
id: 'app.skin-site.admin.wiki.confirm-rollback',
|
||||||
|
defaultMessage: 'Roll back this migration batch? Published pages will be reverted.',
|
||||||
|
},
|
||||||
|
stateImporting: {
|
||||||
|
id: 'app.skin-site.admin.wiki.state-importing',
|
||||||
|
defaultMessage: 'Importing',
|
||||||
|
},
|
||||||
|
stateReview: {
|
||||||
|
id: 'app.skin-site.admin.wiki.state-review',
|
||||||
|
defaultMessage: 'Waiting review',
|
||||||
|
},
|
||||||
|
statePublishing: {
|
||||||
|
id: 'app.skin-site.admin.wiki.state-publishing',
|
||||||
|
defaultMessage: 'Publishing',
|
||||||
|
},
|
||||||
|
statePublished: {
|
||||||
|
id: 'app.skin-site.admin.wiki.state-published',
|
||||||
|
defaultMessage: 'Published',
|
||||||
|
},
|
||||||
|
stateFailed: { id: 'app.skin-site.admin.wiki.state-failed', defaultMessage: 'Failed' },
|
||||||
|
stateRolledBack: {
|
||||||
|
id: 'app.skin-site.admin.wiki.state-rolled-back',
|
||||||
|
defaultMessage: 'Rolled back',
|
||||||
|
},
|
||||||
|
decisionPending: {
|
||||||
|
id: 'app.skin-site.admin.wiki.decision-pending',
|
||||||
|
defaultMessage: 'Pending',
|
||||||
|
},
|
||||||
|
decisionImport: {
|
||||||
|
id: 'app.skin-site.admin.wiki.decision-import',
|
||||||
|
defaultMessage: 'Import',
|
||||||
|
},
|
||||||
|
decisionExclude: {
|
||||||
|
id: 'app.skin-site.admin.wiki.decision-exclude',
|
||||||
|
defaultMessage: 'Exclude',
|
||||||
|
},
|
||||||
|
migrationStarted: {
|
||||||
|
id: 'app.skin-site.admin.wiki.migration-started',
|
||||||
|
defaultMessage: 'Migration job created.',
|
||||||
|
},
|
||||||
|
xmlStaged: {
|
||||||
|
id: 'app.skin-site.admin.wiki.xml-staged',
|
||||||
|
defaultMessage: 'XML parsed and staged for review.',
|
||||||
|
},
|
||||||
|
published: {
|
||||||
|
id: 'app.skin-site.admin.wiki.published',
|
||||||
|
defaultMessage: 'Published {imported} pages, overwrote {overwritten}.',
|
||||||
|
},
|
||||||
|
rolledBack: {
|
||||||
|
id: 'app.skin-site.admin.wiki.rolled-back',
|
||||||
|
defaultMessage: 'Migration batch rolled back.',
|
||||||
|
},
|
||||||
|
jobDeleted: {
|
||||||
|
id: 'app.skin-site.admin.wiki.job-deleted',
|
||||||
|
defaultMessage: 'Migration record deleted.',
|
||||||
|
},
|
||||||
|
selectXml: {
|
||||||
|
id: 'app.skin-site.admin.wiki.select-xml',
|
||||||
|
defaultMessage: 'Choose a MediaWiki XML file first.',
|
||||||
|
},
|
||||||
loading: { id: 'app.skin-site.admin.wiki.loading', defaultMessage: 'Loading…' },
|
loading: { id: 'app.skin-site.admin.wiki.loading', defaultMessage: 'Loading…' },
|
||||||
pagePath: { id: 'app.skin-site.admin.wiki.page-path', defaultMessage: 'Page path' },
|
pagePath: { id: 'app.skin-site.admin.wiki.page-path', defaultMessage: 'Page path' },
|
||||||
loadPage: { id: 'app.skin-site.admin.wiki.load-page', defaultMessage: 'Load page' },
|
loadPage: { id: 'app.skin-site.admin.wiki.load-page', defaultMessage: 'Load page' },
|
||||||
@@ -77,7 +279,7 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
type Tab = 'page' | 'audit' | 'settings'
|
type Tab = 'page' | 'audit' | 'settings' | 'migration'
|
||||||
const tab = ref<Tab>('page')
|
const tab = ref<Tab>('page')
|
||||||
|
|
||||||
const pagePath = ref('')
|
const pagePath = ref('')
|
||||||
@@ -244,6 +446,269 @@ async function switchTab(next: Tab) {
|
|||||||
tab.value = next
|
tab.value = next
|
||||||
if (next === 'audit' && !auditItems.value.length) await loadAudit(true)
|
if (next === 'audit' && !auditItems.value.length) await loadAudit(true)
|
||||||
if (next === 'settings' && !settings.value) await loadSettings()
|
if (next === 'settings' && !settings.value) await loadSettings()
|
||||||
|
if (next === 'migration' && !migrationJobs.value.length) await loadMigrationJobs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- migration ----------
|
||||||
|
const migrationJobs = ref<SkinSiteWikiMigrationJobInfo[]>([])
|
||||||
|
const jobsLoading = ref(false)
|
||||||
|
const startingMigration = ref(false)
|
||||||
|
|
||||||
|
const automaticForm = ref({
|
||||||
|
apiUrl: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
allowPrivateAddress: false,
|
||||||
|
rules: {
|
||||||
|
staleDays: 365,
|
||||||
|
maintainerWindowDays: 180,
|
||||||
|
maximumMaintainers: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const staleMarkersText = ref('')
|
||||||
|
const xmlInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const selectedJob = ref<SkinSiteWikiMigrationJobInfo | null>(null)
|
||||||
|
const candidates = ref<SkinSiteWikiMigrationCandidate[]>([])
|
||||||
|
const candidatesLoading = ref(false)
|
||||||
|
const candidateNextCursor = ref<number | null>(null)
|
||||||
|
const candidateTargetTitles = reactive<Record<number, string>>({})
|
||||||
|
const candidateOverwrite = reactive<Record<number, boolean>>({})
|
||||||
|
|
||||||
|
const publishing = ref(false)
|
||||||
|
const rollingBack = ref(false)
|
||||||
|
const confirmingRollbackId = ref<number | null>(null)
|
||||||
|
const deletingJob = ref(false)
|
||||||
|
const confirmingDeleteId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const JOB_STATE_LABELS: Record<string, string> = {
|
||||||
|
IMPORTING: '正在读取',
|
||||||
|
REVIEW: '等待审核',
|
||||||
|
PUBLISHING: '正在发布',
|
||||||
|
PUBLISHED: '已发布',
|
||||||
|
FAILED: '失败',
|
||||||
|
ROLLED_BACK: '已回滚',
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobStateLabel(state: string): string {
|
||||||
|
return JOB_STATE_LABELS[state] ?? state
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobStateClass(state: string): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'IMPORTING':
|
||||||
|
case 'PUBLISHING':
|
||||||
|
return 'bg-brand text-contrast'
|
||||||
|
case 'REVIEW':
|
||||||
|
return 'bg-yellow text-contrast'
|
||||||
|
case 'PUBLISHED':
|
||||||
|
return 'bg-green text-contrast'
|
||||||
|
case 'FAILED':
|
||||||
|
return 'bg-red text-contrast'
|
||||||
|
default:
|
||||||
|
return 'bg-button-bg text-secondary'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decisionLabel(decision: string): string {
|
||||||
|
if (decision === 'IMPORT') return formatMessage(messages.decisionImport)
|
||||||
|
if (decision === 'EXCLUDE') return formatMessage(messages.decisionExclude)
|
||||||
|
return formatMessage(messages.decisionPending)
|
||||||
|
}
|
||||||
|
|
||||||
|
function decisionClass(decision: string): string {
|
||||||
|
if (decision === 'IMPORT') return 'bg-green text-contrast'
|
||||||
|
if (decision === 'EXCLUDE') return 'bg-red text-contrast'
|
||||||
|
return 'bg-yellow text-contrast'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMigrationJobs() {
|
||||||
|
jobsLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await skinSiteWikiMigrationJobs()
|
||||||
|
if (!res.success) throw new Error(res.errorMessage)
|
||||||
|
migrationJobs.value = res.payload ?? []
|
||||||
|
if (selectedJob.value) {
|
||||||
|
selectedJob.value =
|
||||||
|
migrationJobs.value.find((j) => j.id === selectedJob.value!.id) ?? null
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
} finally {
|
||||||
|
jobsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startAutomaticMigration() {
|
||||||
|
if (startingMigration.value) return
|
||||||
|
startingMigration.value = true
|
||||||
|
try {
|
||||||
|
const res = await skinSiteWikiMigrationStartAutomatic({
|
||||||
|
apiUrl: automaticForm.value.apiUrl.trim(),
|
||||||
|
username: automaticForm.value.username.trim() || null,
|
||||||
|
password: automaticForm.value.password || null,
|
||||||
|
allowPrivateAddress: automaticForm.value.allowPrivateAddress,
|
||||||
|
rules: {
|
||||||
|
...automaticForm.value.rules,
|
||||||
|
staleMarkers: staleMarkersText.value
|
||||||
|
.split(/[,,]/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!res.success) throw new Error(res.errorMessage)
|
||||||
|
addNotification({ type: 'success', title: formatMessage(messages.migrationStarted) })
|
||||||
|
await loadMigrationJobs()
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
} finally {
|
||||||
|
startingMigration.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startXmlMigration() {
|
||||||
|
const file = xmlInput.value?.files?.[0]
|
||||||
|
if (!file) {
|
||||||
|
addNotification({ type: 'error', title: formatMessage(messages.selectXml) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (startingMigration.value) return
|
||||||
|
startingMigration.value = true
|
||||||
|
try {
|
||||||
|
const res = await skinSiteWikiMigrationStartXml(file)
|
||||||
|
if (!res.success) throw new Error(res.errorMessage)
|
||||||
|
addNotification({ type: 'success', title: formatMessage(messages.xmlStaged) })
|
||||||
|
if (xmlInput.value) xmlInput.value.value = ''
|
||||||
|
await loadMigrationJobs()
|
||||||
|
await selectJob(res.payload)
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
} finally {
|
||||||
|
startingMigration.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectJob(job: SkinSiteWikiMigrationJobInfo) {
|
||||||
|
confirmingDeleteId.value = null
|
||||||
|
confirmingRollbackId.value = null
|
||||||
|
selectedJob.value = job
|
||||||
|
candidatesLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await skinSiteWikiMigrationCandidates(job.id)
|
||||||
|
if (selectedJob.value?.id !== job.id) return
|
||||||
|
if (!res.success) throw new Error(res.errorMessage)
|
||||||
|
candidates.value = res.payload.items ?? []
|
||||||
|
candidateNextCursor.value = res.payload.nextCursor ?? null
|
||||||
|
applyCandidateDefaults(candidates.value)
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
} finally {
|
||||||
|
if (selectedJob.value?.id === job.id) candidatesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCandidateDefaults(items: SkinSiteWikiMigrationCandidate[]) {
|
||||||
|
for (const c of items) {
|
||||||
|
if (candidateTargetTitles[c.id] === undefined) candidateTargetTitles[c.id] = c.title
|
||||||
|
if (candidateOverwrite[c.id] === undefined) candidateOverwrite[c.id] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMoreCandidates() {
|
||||||
|
if (!selectedJob.value || !candidateNextCursor.value || candidatesLoading.value) return
|
||||||
|
const jobId = selectedJob.value.id
|
||||||
|
candidatesLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await skinSiteWikiMigrationCandidates(jobId, candidateNextCursor.value)
|
||||||
|
if (selectedJob.value?.id !== jobId) return
|
||||||
|
if (!res.success) throw new Error(res.errorMessage)
|
||||||
|
candidates.value.push(...(res.payload.items ?? []))
|
||||||
|
candidateNextCursor.value = res.payload.nextCursor ?? null
|
||||||
|
applyCandidateDefaults(res.payload.items ?? [])
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
} finally {
|
||||||
|
if (selectedJob.value?.id === jobId) candidatesLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decideCandidate(
|
||||||
|
candidate: SkinSiteWikiMigrationCandidate,
|
||||||
|
decision: 'IMPORT' | 'EXCLUDE',
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const res = await skinSiteWikiMigrationDecide(candidate.id, {
|
||||||
|
decision,
|
||||||
|
targetTitle:
|
||||||
|
decision === 'IMPORT' ? candidateTargetTitles[candidate.id] || candidate.title : null,
|
||||||
|
overwrite: decision === 'IMPORT' ? Boolean(candidateOverwrite[candidate.id]) : false,
|
||||||
|
})
|
||||||
|
if (!res.success) throw new Error(res.errorMessage)
|
||||||
|
candidate.decision = decision
|
||||||
|
await loadMigrationJobs()
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishMigration() {
|
||||||
|
if (!selectedJob.value || publishing.value) return
|
||||||
|
publishing.value = true
|
||||||
|
try {
|
||||||
|
const res = await skinSiteWikiMigrationPublish(selectedJob.value.id)
|
||||||
|
if (!res.success) throw new Error(res.errorMessage)
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: formatMessage(messages.published, {
|
||||||
|
imported: res.payload.imported,
|
||||||
|
overwritten: res.payload.overwritten,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
await loadMigrationJobs()
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
} finally {
|
||||||
|
publishing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rollbackMigration() {
|
||||||
|
if (!selectedJob.value || rollingBack.value) return
|
||||||
|
rollingBack.value = true
|
||||||
|
try {
|
||||||
|
const res = await skinSiteWikiMigrationRollback(selectedJob.value.id)
|
||||||
|
if (!res.success) throw new Error(res.errorMessage)
|
||||||
|
confirmingRollbackId.value = null
|
||||||
|
addNotification({ type: 'success', title: formatMessage(messages.rolledBack) })
|
||||||
|
await loadMigrationJobs()
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
} finally {
|
||||||
|
rollingBack.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteJob(job: SkinSiteWikiMigrationJobInfo) {
|
||||||
|
if (deletingJob.value || confirmingDeleteId.value !== job.id) return
|
||||||
|
if (job.state !== 'FAILED' && job.state !== 'ROLLED_BACK') return
|
||||||
|
deletingJob.value = true
|
||||||
|
try {
|
||||||
|
const res = await skinSiteWikiMigrationDelete(job.id)
|
||||||
|
if (!res.success) throw new Error(res.errorMessage)
|
||||||
|
migrationJobs.value = migrationJobs.value.filter((j) => j.id !== job.id)
|
||||||
|
if (selectedJob.value?.id === job.id) {
|
||||||
|
selectedJob.value = null
|
||||||
|
candidates.value = []
|
||||||
|
candidateNextCursor.value = null
|
||||||
|
}
|
||||||
|
confirmingDeleteId.value = null
|
||||||
|
addNotification({ type: 'success', title: formatMessage(messages.jobDeleted) })
|
||||||
|
await loadMigrationJobs()
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
} finally {
|
||||||
|
deletingJob.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => void loadSettings())
|
onMounted(() => void loadSettings())
|
||||||
@@ -276,6 +741,14 @@ onMounted(() => void loadSettings())
|
|||||||
>
|
>
|
||||||
{{ formatMessage(messages.tabSettings) }}
|
{{ formatMessage(messages.tabSettings) }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full px-3 py-1 text-xs font-semibold"
|
||||||
|
:class="tab === 'migration' ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
|
||||||
|
@click="switchTab('migration')"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.tabMigration) }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="tab === 'page'">
|
<template v-if="tab === 'page'">
|
||||||
@@ -439,7 +912,7 @@ onMounted(() => void loadSettings())
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else-if="tab === 'settings'">
|
||||||
<div v-if="settingsLoading" class="flex items-center gap-2 text-sm text-secondary">
|
<div v-if="settingsLoading" class="flex items-center gap-2 text-sm text-secondary">
|
||||||
<SpinnerIcon class="size-4 animate-spin" />
|
<SpinnerIcon class="size-4 animate-spin" />
|
||||||
{{ formatMessage(messages.loading) }}
|
{{ formatMessage(messages.loading) }}
|
||||||
@@ -508,5 +981,278 @@ onMounted(() => void loadSettings())
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="rounded-xl border border-solid border-surface-5 bg-bg-raised p-4">
|
||||||
|
<h3 class="m-0 text-base font-bold text-contrast">
|
||||||
|
{{ formatMessage(messages.migrationTitle) }}
|
||||||
|
</h3>
|
||||||
|
<p class="m-0 mt-1 text-xs text-secondary">
|
||||||
|
{{ formatMessage(messages.migrationHint) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 p-4">
|
||||||
|
<h4 class="m-0 text-sm font-bold text-contrast">
|
||||||
|
{{ formatMessage(messages.autoTitle) }}
|
||||||
|
</h4>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs font-semibold text-secondary">
|
||||||
|
{{ formatMessage(messages.apiUrl) }}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
v-model.trim="automaticForm.apiUrl"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com/w/api.php"
|
||||||
|
class="rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs font-semibold text-secondary">
|
||||||
|
{{ formatMessage(messages.botUsername) }}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
v-model.trim="automaticForm.username"
|
||||||
|
type="text"
|
||||||
|
class="rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs font-semibold text-secondary">
|
||||||
|
{{ formatMessage(messages.botPassword) }}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
v-model="automaticForm.password"
|
||||||
|
type="password"
|
||||||
|
class="rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="m-0 text-[11px] text-secondary">
|
||||||
|
{{ formatMessage(messages.credentialsHint) }}
|
||||||
|
</p>
|
||||||
|
<label class="flex cursor-pointer items-center gap-2 text-xs text-contrast">
|
||||||
|
<input v-model="automaticForm.allowPrivateAddress" type="checkbox" class="size-3.5" />
|
||||||
|
{{ formatMessage(messages.allowPrivate) }}
|
||||||
|
</label>
|
||||||
|
<p v-if="automaticForm.allowPrivateAddress" class="m-0 text-[11px] text-yellow">
|
||||||
|
{{ formatMessage(messages.allowPrivateHint) }}
|
||||||
|
</p>
|
||||||
|
<details class="rounded-lg border border-solid border-surface-5 p-3">
|
||||||
|
<summary class="cursor-pointer text-xs font-semibold text-contrast">
|
||||||
|
{{ formatMessage(messages.staleRules) }}
|
||||||
|
</summary>
|
||||||
|
<div class="mt-3 grid gap-2 sm:grid-cols-3">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-[11px] text-secondary">{{ formatMessage(messages.staleDays) }}</span>
|
||||||
|
<input v-model.number="automaticForm.rules.staleDays" type="number" min="1" max="36500" class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast" />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-[11px] text-secondary">{{ formatMessage(messages.maintainerWindow) }}</span>
|
||||||
|
<input v-model.number="automaticForm.rules.maintainerWindowDays" type="number" min="1" max="36500" class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast" />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-[11px] text-secondary">{{ formatMessage(messages.maxMaintainers) }}</span>
|
||||||
|
<input v-model.number="automaticForm.rules.maximumMaintainers" type="number" min="0" max="100" class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast" />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 sm:col-span-3">
|
||||||
|
<span class="text-[11px] text-secondary">{{ formatMessage(messages.staleMarkers) }}</span>
|
||||||
|
<input v-model="staleMarkersText" type="text" :placeholder="formatMessage(messages.staleMarkersHint)" class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<button type="button" class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50" :disabled="startingMigration" @click="startAutomaticMigration">
|
||||||
|
{{ formatMessage(messages.startAuto) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 p-4">
|
||||||
|
<h4 class="m-0 text-sm font-bold text-contrast">
|
||||||
|
{{ formatMessage(messages.xmlTitle) }}
|
||||||
|
</h4>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs font-semibold text-secondary">
|
||||||
|
{{ formatMessage(messages.xmlFile) }}
|
||||||
|
</span>
|
||||||
|
<input ref="xmlInput" type="file" accept=".xml,text/xml,application/xml" class="text-xs text-contrast" />
|
||||||
|
</label>
|
||||||
|
<button type="button" class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50" :disabled="startingMigration" @click="startXmlMigration">
|
||||||
|
{{ formatMessage(messages.startXml) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 p-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h4 class="m-0 text-sm font-bold text-contrast">
|
||||||
|
{{ formatMessage(messages.jobs) }}
|
||||||
|
</h4>
|
||||||
|
<button type="button" class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50" :disabled="jobsLoading" @click="loadMigrationJobs">
|
||||||
|
{{ formatMessage(messages.refreshJobs) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="jobsLoading" class="flex min-h-24 items-center justify-center gap-2 text-sm text-secondary">
|
||||||
|
<SpinnerIcon class="size-4 animate-spin" />
|
||||||
|
{{ formatMessage(messages.loading) }}
|
||||||
|
</div>
|
||||||
|
<p v-else-if="!migrationJobs.length" class="m-0 rounded-lg bg-bg-raised p-3 text-xs text-secondary">
|
||||||
|
{{ formatMessage(messages.jobsEmpty) }}
|
||||||
|
</p>
|
||||||
|
<div v-else class="flex flex-col gap-2">
|
||||||
|
<article
|
||||||
|
v-for="job in migrationJobs"
|
||||||
|
:key="job.id"
|
||||||
|
class="rounded-lg border border-solid p-3"
|
||||||
|
:class="selectedJob?.id === job.id ? 'border-brand bg-brand/5' : 'border-surface-5'"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span class="text-sm font-semibold text-contrast">#{{ job.id }} · {{ job.source }}</span>
|
||||||
|
<span class="rounded-full px-2 py-0.5 text-[10px] font-semibold" :class="jobStateClass(job.state)">
|
||||||
|
{{ jobStateLabel(job.state) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-button-bg">
|
||||||
|
<div class="h-full bg-brand transition-all" :style="{ width: (job.total ? (job.reviewed / job.total) * 100 : 0) + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex flex-wrap justify-between gap-2 text-[11px] text-secondary">
|
||||||
|
<span>{{ formatMessage(messages.reviewedCount, { reviewed: job.reviewed, total: job.total }) }}</span>
|
||||||
|
<span>{{ formatMessage(messages.importedExcluded, { imported: job.imported, excluded: job.excluded }) }}</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="job.error" class="m-0 mt-2 text-xs text-red">{{ job.error }}</p>
|
||||||
|
<div class="mt-2 flex flex-wrap justify-end gap-2">
|
||||||
|
<button type="button" class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110" @click="selectJob(job)">
|
||||||
|
{{ formatMessage(messages.viewJob) }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="job.state === 'FAILED' || job.state === 'ROLLED_BACK'"
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg bg-red/20 px-3 py-1 text-xs font-semibold text-red hover:brightness-110 disabled:opacity-50"
|
||||||
|
:disabled="deletingJob"
|
||||||
|
@click="confirmingDeleteId = job.id"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.deleteJob) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="confirmingDeleteId === job.id" class="mt-2 rounded-lg bg-red/10 p-2">
|
||||||
|
<p class="m-0 text-xs text-red">
|
||||||
|
{{ formatMessage(messages.confirmDeleteJob, { id: job.id }) }}
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 flex justify-end gap-2">
|
||||||
|
<button type="button" class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-secondary" @click="confirmingDeleteId = null">
|
||||||
|
{{ formatMessage(messages.cancel) }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="rounded-lg bg-red px-3 py-1 text-xs font-semibold text-contrast disabled:opacity-50" :disabled="deletingJob" @click="deleteJob(job)">
|
||||||
|
{{ formatMessage(messages.deleteJob) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedJob" class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 p-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h4 class="m-0 text-sm font-bold text-contrast">
|
||||||
|
{{ formatMessage(messages.candidates) }}
|
||||||
|
</h4>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-if="selectedJob.state === 'REVIEW'"
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
|
||||||
|
:disabled="publishing"
|
||||||
|
@click="publishMigration"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.publish) }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="selectedJob.state === 'PUBLISHED'"
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg bg-yellow/20 px-3 py-1 text-xs font-semibold text-yellow hover:brightness-110 disabled:opacity-50"
|
||||||
|
:disabled="rollingBack"
|
||||||
|
@click="confirmingRollbackId = selectedJob.id"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.rollback) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="confirmingRollbackId === selectedJob.id" class="rounded-lg bg-yellow/10 p-2">
|
||||||
|
<p class="m-0 text-xs text-yellow">{{ formatMessage(messages.confirmRollback) }}</p>
|
||||||
|
<div class="mt-2 flex justify-end gap-2">
|
||||||
|
<button type="button" class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-secondary" @click="confirmingRollbackId = null">
|
||||||
|
{{ formatMessage(messages.cancel) }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="rounded-lg bg-yellow px-3 py-1 text-xs font-semibold text-contrast disabled:opacity-50" :disabled="rollingBack" @click="rollbackMigration">
|
||||||
|
{{ formatMessage(messages.rollback) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="candidatesLoading && !candidates.length" class="flex min-h-24 items-center justify-center gap-2 text-sm text-secondary">
|
||||||
|
<SpinnerIcon class="size-4 animate-spin" />
|
||||||
|
{{ formatMessage(messages.loading) }}
|
||||||
|
</div>
|
||||||
|
<p v-else-if="!candidates.length" class="m-0 rounded-lg bg-bg-raised p-3 text-xs text-secondary">
|
||||||
|
{{ formatMessage(messages.candidatesEmpty) }}
|
||||||
|
</p>
|
||||||
|
<div v-else class="flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
v-for="candidate in candidates"
|
||||||
|
:key="candidate.id"
|
||||||
|
class="flex flex-col gap-2 rounded-lg border border-solid border-surface-5 p-3"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">{{ candidate.title }}</span>
|
||||||
|
<span class="rounded-full px-2 py-0.5 text-[10px] font-semibold" :class="decisionClass(candidate.decision)">
|
||||||
|
{{ decisionLabel(candidate.decision) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="candidate.conflict" class="rounded-full bg-yellow/20 px-2 py-0.5 text-[10px] font-semibold text-yellow">
|
||||||
|
{{ formatMessage(messages.conflict) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3 text-[11px] text-secondary">
|
||||||
|
<span>{{ formatMessage(messages.lastUpdated, { date: new Date(candidate.lastUpdatedAt).toLocaleDateString() }) }}</span>
|
||||||
|
<span>{{ formatMessage(messages.maintainerCount, { count: candidate.maintainerCount }) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="candidate.suspiciousReasons.length" class="flex flex-wrap gap-1">
|
||||||
|
<span v-for="reason in candidate.suspiciousReasons" :key="reason" class="rounded bg-bg px-1.5 py-0.5 text-[10px] text-secondary">
|
||||||
|
{{ reason }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="candidateTargetTitles[candidate.id]"
|
||||||
|
type="text"
|
||||||
|
:placeholder="formatMessage(messages.targetTitle)"
|
||||||
|
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg-raised px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
|
||||||
|
/>
|
||||||
|
<label class="flex cursor-pointer items-center gap-1.5 text-[11px] text-contrast">
|
||||||
|
<input v-model="candidateOverwrite[candidate.id]" type="checkbox" class="size-3" />
|
||||||
|
{{ formatMessage(messages.overwrite) }}
|
||||||
|
</label>
|
||||||
|
<button type="button" class="rounded-lg bg-green/20 px-3 py-1 text-xs font-semibold text-green hover:brightness-110" @click="decideCandidate(candidate, 'IMPORT')">
|
||||||
|
{{ formatMessage(messages.importAction) }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="rounded-lg bg-red/20 px-3 py-1 text-xs font-semibold text-red hover:brightness-110" @click="decideCandidate(candidate, 'EXCLUDE')">
|
||||||
|
{{ formatMessage(messages.excludeAction) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="candidateNextCursor"
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg bg-button-bg px-4 py-2 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
|
||||||
|
:disabled="candidatesLoading"
|
||||||
|
@click="loadMoreCandidates"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.loadMore) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -2367,3 +2367,129 @@ export function skinSiteChatVoiceCapacityUpdate(
|
|||||||
{ method: 'PUT', body: data },
|
{ method: 'PUT', body: data },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Wiki migration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type SkinSiteWikiMigrationState =
|
||||||
|
| 'IMPORTING'
|
||||||
|
| 'REVIEW'
|
||||||
|
| 'PUBLISHING'
|
||||||
|
| 'PUBLISHED'
|
||||||
|
| 'FAILED'
|
||||||
|
| 'ROLLED_BACK'
|
||||||
|
|
||||||
|
export type SkinSiteWikiMigrationDecision = 'PENDING' | 'IMPORT' | 'EXCLUDE'
|
||||||
|
|
||||||
|
export interface SkinSiteWikiMigrationJobInfo {
|
||||||
|
id: number
|
||||||
|
source: string
|
||||||
|
state: SkinSiteWikiMigrationState
|
||||||
|
total: number
|
||||||
|
reviewed: number
|
||||||
|
imported: number
|
||||||
|
excluded: number
|
||||||
|
error?: string | null
|
||||||
|
missingAttachmentCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkinSiteWikiMigrationCandidate {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
lastUpdatedAt: number
|
||||||
|
maintainerCount: number
|
||||||
|
suspiciousReasons: string[]
|
||||||
|
decision: SkinSiteWikiMigrationDecision
|
||||||
|
conflict: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkinSiteWikiMigrationPage {
|
||||||
|
items: SkinSiteWikiMigrationCandidate[]
|
||||||
|
nextCursor?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkinSiteWikiMigrationPublishResult {
|
||||||
|
imported: number
|
||||||
|
overwritten: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkinSiteWikiAutomaticMigrationInput {
|
||||||
|
apiUrl: string
|
||||||
|
username?: string | null
|
||||||
|
password?: string | null
|
||||||
|
allowPrivateAddress: boolean
|
||||||
|
rules: {
|
||||||
|
staleDays: number
|
||||||
|
maintainerWindowDays: number
|
||||||
|
maximumMaintainers: number
|
||||||
|
staleMarkers: string[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteWikiMigrationJobs(): Promise<
|
||||||
|
SkinSiteResponse<SkinSiteWikiMigrationJobInfo[]>
|
||||||
|
> {
|
||||||
|
return skinSiteRequest<SkinSiteWikiMigrationJobInfo[]>('wiki/admin/migration')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteWikiMigrationStartAutomatic(
|
||||||
|
data: SkinSiteWikiAutomaticMigrationInput,
|
||||||
|
): Promise<SkinSiteResponse<SkinSiteWikiMigrationJobInfo>> {
|
||||||
|
return skinSiteRequest<SkinSiteWikiMigrationJobInfo>('wiki/admin/migration/automatic', {
|
||||||
|
method: 'POST',
|
||||||
|
body: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteWikiMigrationStartXml(
|
||||||
|
file: File,
|
||||||
|
): Promise<SkinSiteResponse<SkinSiteWikiMigrationJobInfo>> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('xml', file, file.name)
|
||||||
|
return skinSiteRequest<SkinSiteWikiMigrationJobInfo>('wiki/admin/migration/xml', {
|
||||||
|
method: 'POST',
|
||||||
|
form,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteWikiMigrationCandidates(
|
||||||
|
jobId: number,
|
||||||
|
after?: number,
|
||||||
|
): Promise<SkinSiteResponse<SkinSiteWikiMigrationPage>> {
|
||||||
|
const suffix = after ? '?after=' + after : ''
|
||||||
|
return skinSiteRequest<SkinSiteWikiMigrationPage>(
|
||||||
|
'wiki/admin/migration/' + jobId + '/candidates' + suffix,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteWikiMigrationDecide(
|
||||||
|
candidateId: number,
|
||||||
|
data: { decision: SkinSiteWikiMigrationDecision; targetTitle?: string | null; overwrite?: boolean },
|
||||||
|
): Promise<SkinSiteResponse<void>> {
|
||||||
|
return skinSiteRequest<void>('wiki/admin/migration/candidates/' + candidateId, {
|
||||||
|
method: 'POST',
|
||||||
|
body: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteWikiMigrationPublish(
|
||||||
|
jobId: number,
|
||||||
|
): Promise<SkinSiteResponse<SkinSiteWikiMigrationPublishResult>> {
|
||||||
|
return skinSiteRequest<SkinSiteWikiMigrationPublishResult>(
|
||||||
|
'wiki/admin/migration/' + jobId + '/publish',
|
||||||
|
{ method: 'POST' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteWikiMigrationRollback(
|
||||||
|
jobId: number,
|
||||||
|
): Promise<SkinSiteResponse<void>> {
|
||||||
|
return skinSiteRequest<void>('wiki/admin/migration/' + jobId + '/rollback', {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteWikiMigrationDelete(jobId: number): Promise<SkinSiteResponse<void>> {
|
||||||
|
return skinSiteRequest<void>('wiki/admin/migration/' + jobId, { method: 'DELETE' })
|
||||||
|
}
|
||||||
@@ -12369,5 +12369,60 @@
|
|||||||
"app.skin-site.chat.admin.voice-saved": {"message": "Voice configuration saved."},
|
"app.skin-site.chat.admin.voice-saved": {"message": "Voice configuration saved."},
|
||||||
"app.skin-site.chat.admin.voice-logs-auto-refresh": {"message": "Auto refresh"},
|
"app.skin-site.chat.admin.voice-logs-auto-refresh": {"message": "Auto refresh"},
|
||||||
"app.skin-site.chat.admin.voice-logs-refresh": {"message": "Refresh"},
|
"app.skin-site.chat.admin.voice-logs-refresh": {"message": "Refresh"},
|
||||||
"app.skin-site.chat.admin.voice-logs-empty": {"message": "No logs yet."}
|
"app.skin-site.chat.admin.voice-logs-empty": {"message": "No logs yet."},
|
||||||
|
"app.skin-site.admin.wiki.tab-migration": {"message": "Migration"},
|
||||||
|
"app.skin-site.admin.wiki.migration-title": {"message": "MediaWiki migration"},
|
||||||
|
"app.skin-site.admin.wiki.migration-hint": {"message": "Migrated content lands in a hidden staging area and is published in one batch after every page is reviewed."},
|
||||||
|
"app.skin-site.admin.wiki.auto-title": {"message": "Read from MediaWiki API"},
|
||||||
|
"app.skin-site.admin.wiki.api-url": {"message": "api.php URL"},
|
||||||
|
"app.skin-site.admin.wiki.bot-username": {"message": "Bot username (optional)"},
|
||||||
|
"app.skin-site.admin.wiki.bot-password": {"message": "Bot password (optional)"},
|
||||||
|
"app.skin-site.admin.wiki.credentials-hint": {"message": "Credentials stay in memory for this job only. HTTPS is required when supplied."},
|
||||||
|
"app.skin-site.admin.wiki.allow-private": {"message": "Allow private network source"},
|
||||||
|
"app.skin-site.admin.wiki.allow-private-hint": {"message": "Private sources may reach services inside the server network."},
|
||||||
|
"app.skin-site.admin.wiki.start-auto": {"message": "Create automatic migration job"},
|
||||||
|
"app.skin-site.admin.wiki.xml-title": {"message": "Import MediaWiki XML"},
|
||||||
|
"app.skin-site.admin.wiki.xml-file": {"message": "Export file"},
|
||||||
|
"app.skin-site.admin.wiki.start-xml": {"message": "Upload and stage"},
|
||||||
|
"app.skin-site.admin.wiki.stale-rules": {"message": "Stale page detection rules"},
|
||||||
|
"app.skin-site.admin.wiki.stale-days": {"message": "Days without update"},
|
||||||
|
"app.skin-site.admin.wiki.maintainer-window": {"message": "Maintainer window (days)"},
|
||||||
|
"app.skin-site.admin.wiki.max-maintainers": {"message": "Max maintainers"},
|
||||||
|
"app.skin-site.admin.wiki.stale-markers": {"message": "Stale categories, templates or text"},
|
||||||
|
"app.skin-site.admin.wiki.stale-markers-hint": {"message": "Comma separated"},
|
||||||
|
"app.skin-site.admin.wiki.jobs": {"message": "Migration jobs"},
|
||||||
|
"app.skin-site.admin.wiki.refresh-jobs": {"message": "Refresh"},
|
||||||
|
"app.skin-site.admin.wiki.jobs-empty": {"message": "No migration jobs yet."},
|
||||||
|
"app.skin-site.admin.wiki.reviewed-count": {"message": "Reviewed {reviewed} / {total}"},
|
||||||
|
"app.skin-site.admin.wiki.imported-excluded": {"message": "Imported {imported} · Excluded {excluded}"},
|
||||||
|
"app.skin-site.admin.wiki.view-job": {"message": "View job"},
|
||||||
|
"app.skin-site.admin.wiki.delete-job": {"message": "Delete job record"},
|
||||||
|
"app.skin-site.admin.wiki.confirm-delete-job": {"message": "Delete migration job #{id}? This cannot be undone."},
|
||||||
|
"app.skin-site.admin.wiki.candidates": {"message": "Candidate pages"},
|
||||||
|
"app.skin-site.admin.wiki.candidates-empty": {"message": "Select a job to review its pages."},
|
||||||
|
"app.skin-site.admin.wiki.last-updated": {"message": "Updated {date}"},
|
||||||
|
"app.skin-site.admin.wiki.maintainer-count": {"message": "{count} maintainers"},
|
||||||
|
"app.skin-site.admin.wiki.conflict": {"message": "Title conflict"},
|
||||||
|
"app.skin-site.admin.wiki.target-title": {"message": "Target title"},
|
||||||
|
"app.skin-site.admin.wiki.overwrite": {"message": "Overwrite"},
|
||||||
|
"app.skin-site.admin.wiki.import": {"message": "Import"},
|
||||||
|
"app.skin-site.admin.wiki.exclude": {"message": "Exclude"},
|
||||||
|
"app.skin-site.admin.wiki.publish": {"message": "Publish batch"},
|
||||||
|
"app.skin-site.admin.wiki.rollback": {"message": "Roll back"},
|
||||||
|
"app.skin-site.admin.wiki.confirm-rollback": {"message": "Roll back this migration batch? Published pages will be reverted."},
|
||||||
|
"app.skin-site.admin.wiki.state-importing": {"message": "Importing"},
|
||||||
|
"app.skin-site.admin.wiki.state-review": {"message": "Waiting review"},
|
||||||
|
"app.skin-site.admin.wiki.state-publishing": {"message": "Publishing"},
|
||||||
|
"app.skin-site.admin.wiki.state-published": {"message": "Published"},
|
||||||
|
"app.skin-site.admin.wiki.state-failed": {"message": "Failed"},
|
||||||
|
"app.skin-site.admin.wiki.state-rolled-back": {"message": "Rolled back"},
|
||||||
|
"app.skin-site.admin.wiki.decision-pending": {"message": "Pending"},
|
||||||
|
"app.skin-site.admin.wiki.decision-import": {"message": "Import"},
|
||||||
|
"app.skin-site.admin.wiki.decision-exclude": {"message": "Exclude"},
|
||||||
|
"app.skin-site.admin.wiki.migration-started": {"message": "Migration job created."},
|
||||||
|
"app.skin-site.admin.wiki.xml-staged": {"message": "XML parsed and staged for review."},
|
||||||
|
"app.skin-site.admin.wiki.published": {"message": "Published {imported} pages, overwrote {overwritten}."},
|
||||||
|
"app.skin-site.admin.wiki.rolled-back": {"message": "Migration batch rolled back."},
|
||||||
|
"app.skin-site.admin.wiki.job-deleted": {"message": "Migration record deleted."},
|
||||||
|
"app.skin-site.admin.wiki.select-xml": {"message": "Choose a MediaWiki XML file first."}
|
||||||
}
|
}
|
||||||
@@ -12200,5 +12200,60 @@
|
|||||||
"app.skin-site.chat.admin.voice-saved": {"message": "语音库配置已保存。"},
|
"app.skin-site.chat.admin.voice-saved": {"message": "语音库配置已保存。"},
|
||||||
"app.skin-site.chat.admin.voice-logs-auto-refresh": {"message": "自动刷新"},
|
"app.skin-site.chat.admin.voice-logs-auto-refresh": {"message": "自动刷新"},
|
||||||
"app.skin-site.chat.admin.voice-logs-refresh": {"message": "刷新"},
|
"app.skin-site.chat.admin.voice-logs-refresh": {"message": "刷新"},
|
||||||
"app.skin-site.chat.admin.voice-logs-empty": {"message": "暂无日志。"}
|
"app.skin-site.chat.admin.voice-logs-empty": {"message": "暂无日志。"},
|
||||||
|
"app.skin-site.admin.wiki.tab-migration": {"message": "迁移中心"},
|
||||||
|
"app.skin-site.admin.wiki.migration-title": {"message": "MediaWiki 迁移"},
|
||||||
|
"app.skin-site.admin.wiki.migration-hint": {"message": "迁移内容会先进入不可见暂存区,所有页面完成导入或排除审核后才能整批发布。"},
|
||||||
|
"app.skin-site.admin.wiki.auto-title": {"message": "从 MediaWiki API 自动读取"},
|
||||||
|
"app.skin-site.admin.wiki.api-url": {"message": "api.php 地址"},
|
||||||
|
"app.skin-site.admin.wiki.bot-username": {"message": "Bot 用户名(选填)"},
|
||||||
|
"app.skin-site.admin.wiki.bot-password": {"message": "Bot 密码(选填)"},
|
||||||
|
"app.skin-site.admin.wiki.credentials-hint": {"message": "凭据只存在于本次后台任务的内存中,携带凭据时必须使用 HTTPS。"},
|
||||||
|
"app.skin-site.admin.wiki.allow-private": {"message": "明确允许访问内网来源"},
|
||||||
|
"app.skin-site.admin.wiki.allow-private-hint": {"message": "内网来源可能访问皮肤站所在网络中的服务,请只对可信地址启用。"},
|
||||||
|
"app.skin-site.admin.wiki.start-auto": {"message": "创建自动迁移任务"},
|
||||||
|
"app.skin-site.admin.wiki.xml-title": {"message": "导入 MediaWiki XML"},
|
||||||
|
"app.skin-site.admin.wiki.xml-file": {"message": "导出文件"},
|
||||||
|
"app.skin-site.admin.wiki.start-xml": {"message": "上传并建立暂存任务"},
|
||||||
|
"app.skin-site.admin.wiki.stale-rules": {"message": "疑似过时页面筛选规则"},
|
||||||
|
"app.skin-site.admin.wiki.stale-days": {"message": "未更新天数"},
|
||||||
|
"app.skin-site.admin.wiki.maintainer-window": {"message": "维护者统计天数"},
|
||||||
|
"app.skin-site.admin.wiki.max-maintainers": {"message": "维护者人数不超过"},
|
||||||
|
"app.skin-site.admin.wiki.stale-markers": {"message": "过时分类、模板或文字标记"},
|
||||||
|
"app.skin-site.admin.wiki.stale-markers-hint": {"message": "使用逗号分隔"},
|
||||||
|
"app.skin-site.admin.wiki.jobs": {"message": "迁移任务"},
|
||||||
|
"app.skin-site.admin.wiki.refresh-jobs": {"message": "刷新"},
|
||||||
|
"app.skin-site.admin.wiki.jobs-empty": {"message": "还没有迁移任务。"},
|
||||||
|
"app.skin-site.admin.wiki.reviewed-count": {"message": "已审核 {reviewed} / {total}"},
|
||||||
|
"app.skin-site.admin.wiki.imported-excluded": {"message": "导入 {imported} · 排除 {excluded}"},
|
||||||
|
"app.skin-site.admin.wiki.view-job": {"message": "查看任务"},
|
||||||
|
"app.skin-site.admin.wiki.delete-job": {"message": "删除迁移记录"},
|
||||||
|
"app.skin-site.admin.wiki.confirm-delete-job": {"message": "确认删除迁移任务 #{id}?此操作不可恢复。"},
|
||||||
|
"app.skin-site.admin.wiki.candidates": {"message": "候选页面"},
|
||||||
|
"app.skin-site.admin.wiki.candidates-empty": {"message": "选择任务后查看其页面。"},
|
||||||
|
"app.skin-site.admin.wiki.last-updated": {"message": "更新于 {date}"},
|
||||||
|
"app.skin-site.admin.wiki.maintainer-count": {"message": "{count} 位维护者"},
|
||||||
|
"app.skin-site.admin.wiki.conflict": {"message": "标题冲突"},
|
||||||
|
"app.skin-site.admin.wiki.target-title": {"message": "目标标题"},
|
||||||
|
"app.skin-site.admin.wiki.overwrite": {"message": "覆盖"},
|
||||||
|
"app.skin-site.admin.wiki.import": {"message": "导入"},
|
||||||
|
"app.skin-site.admin.wiki.exclude": {"message": "排除"},
|
||||||
|
"app.skin-site.admin.wiki.publish": {"message": "整批发布"},
|
||||||
|
"app.skin-site.admin.wiki.rollback": {"message": "回滚"},
|
||||||
|
"app.skin-site.admin.wiki.confirm-rollback": {"message": "确认回滚本批迁移?已发布的页面将被还原。"},
|
||||||
|
"app.skin-site.admin.wiki.state-importing": {"message": "正在读取"},
|
||||||
|
"app.skin-site.admin.wiki.state-review": {"message": "等待审核"},
|
||||||
|
"app.skin-site.admin.wiki.state-publishing": {"message": "正在发布"},
|
||||||
|
"app.skin-site.admin.wiki.state-published": {"message": "已发布"},
|
||||||
|
"app.skin-site.admin.wiki.state-failed": {"message": "失败"},
|
||||||
|
"app.skin-site.admin.wiki.state-rolled-back": {"message": "已回滚"},
|
||||||
|
"app.skin-site.admin.wiki.decision-pending": {"message": "待审核"},
|
||||||
|
"app.skin-site.admin.wiki.decision-import": {"message": "导入"},
|
||||||
|
"app.skin-site.admin.wiki.decision-exclude": {"message": "排除"},
|
||||||
|
"app.skin-site.admin.wiki.migration-started": {"message": "迁移任务已创建。"},
|
||||||
|
"app.skin-site.admin.wiki.xml-staged": {"message": "XML 已解析并进入逐页审核阶段。"},
|
||||||
|
"app.skin-site.admin.wiki.published": {"message": "已发布 {imported} 个页面,覆盖 {overwritten} 个。"},
|
||||||
|
"app.skin-site.admin.wiki.rolled-back": {"message": "迁移批次已安全回滚。"},
|
||||||
|
"app.skin-site.admin.wiki.job-deleted": {"message": "迁移记录已删除。"},
|
||||||
|
"app.skin-site.admin.wiki.select-xml": {"message": "请先选择 MediaWiki XML 文件。"}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user