release: bump version to 0.71.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
release / images (push) Has been cancelled
ci / delivery (push) Has been cancelled

This commit is contained in:
linkong
2026-06-11 16:47:24 +08:00
parent 8c204717cd
commit 899e3bce43
56 changed files with 4618 additions and 260 deletions

View File

@@ -833,6 +833,7 @@ const fieldLabels: Record<string, string> = {
region: '地区',
language: '语言',
source_type: '播放类型',
sourceType: '来源类型',
embed_url: '嵌入地址',
stream_url: '播放流地址',
homepage_url: '主页地址',
@@ -870,6 +871,18 @@ const fieldLabels: Record<string, string> = {
status: '状态',
state: '状态',
source: '来源',
feed_name: 'Feed 名称',
published_at: '发布时间',
category: '新闻类型',
tags_text: '标签',
summary: '摘要',
content: '正文',
latitude: '纬度',
longitude: '经度',
location_label: '位置标签',
enrichment_status: '处理状态',
translated: '已翻译',
verified: '已定位',
display_name: '显示名称',
module: '层级',
product: '产品',
@@ -1590,7 +1603,7 @@ function newsSourceGroup(source: AnyRecord, healthPayload: AnyRecord = {}): Hier
const health: AnyRecord = isObjectRecord(maybeHealth) ? maybeHealth : {}
const editor: AnyRecord = newsSourceToEditor(source, health)
return {
key: `news-source:${text(editor.id, text(editor.name, crypto.randomUUID()))}`,
key: `news-source:${text(editor.id, text(editor.name, 'unnamed'))}`,
label: pick(editor, ['name', 'id'], '新闻源'),
description: [text(editor.source_type, '').toUpperCase(), text(editor.region, ''), text(editor.default_category, ''), text(editor.__healthLabel, '')].filter(Boolean).join(' · '),
status: newsSourceStatusLabel(editor),
@@ -1658,6 +1671,97 @@ function newsSourceValidationError(source: AnyRecord, existingSources: AnyRecord
return ''
}
function newsItemSourceType(item: AnyRecord) {
return text(item.source_type || item.feed_type, 'rss').toLowerCase()
}
function newsItemToEditor(item: AnyRecord): AnyRecord {
const tags = Array.isArray(item.item_tags)
? item.item_tags.map((tag) => String(tag)).filter(Boolean)
: Array.isArray(item.tags)
? item.tags.map((tag) => String(tag)).filter(Boolean)
: []
const sourceType = newsItemSourceType(item)
const editable = Boolean(item.editable || sourceType === 'manual' || text(item.id, '').startsWith('manual:'))
return {
...item,
title: text(item.title || item.display_title, ''),
summary: text(item.summary || item.display_summary, ''),
content: text(item.manual_content || item.content, ''),
source: text(item.source, editable ? '手动添加' : ''),
url: text(item.url, ''),
region: text(item.region, 'global'),
published_at: text(item.published_at, ''),
category: text(item.category, 'other'),
tags_text: tags.join(', '),
latitude: item.latitude ?? '',
longitude: item.longitude ?? '',
location_label: text(item.location_label, ''),
source_type: sourceType,
editable,
__module: sourceType === 'manual' ? '手动新闻' : '新闻条目',
__status: text(item.status || item.enrichment_status, editable ? 'pending' : ''),
__title: pick(item, ['title', 'display_title', 'id'], '新闻条目'),
}
}
function newsItemFromEditor(record: AnyRecord) {
const next = cleanRecord(record)
const latitudeText = text(next.latitude, '').trim()
const longitudeText = text(next.longitude, '').trim()
const location = latitudeText && longitudeText ? {
label: text(next.location_label, ''),
latitude: Number(latitudeText),
longitude: Number(longitudeText),
} : undefined
return {
title: text(next.title, '').trim(),
summary: text(next.summary, '').trim(),
content: text(next.content, '').trim(),
url: text(next.url, '').trim(),
source: text(next.source, '').trim(),
region: text(next.region, 'global'),
published_at: text(next.published_at, '').trim() || undefined,
category: text(next.category, 'other'),
tags: text(next.tags_text, '').split(/[,\n]/).map((item) => item.trim()).filter(Boolean),
location,
}
}
function newsItemValidationError(payload: AnyRecord) {
if (!text(payload.title, '').trim()) return '新闻标题不能为空。'
if (payload.location) {
const location = payload.location as AnyRecord
const latitude = Number(location.latitude)
const longitude = Number(location.longitude)
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return '坐标必须是数字。'
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) return '坐标超出范围。'
}
return ''
}
function newsContentGroup(group: AnyRecord): HierarchyGroup {
const groupType = text(group.group_type, 'rss')
const sourceType = text(group.source_type, groupType)
return {
key: `news-group:${text(group.id, text(group.name, 'unnamed'))}`,
label: pick(group, ['name', 'feed_name', 'source', 'id'], '新闻组'),
description: [
groupType === 'manual' ? '手动新闻组' : 'RSS 来源',
sourceType.toUpperCase(),
text(group.region, ''),
].filter(Boolean).join(' · '),
status: group.editable === false ? '只读' : '可编辑',
count: Number(group.count || arrayAt(group, 'items').length || 0),
record: {
...group,
__module: groupType === 'manual' ? '手动新闻组' : 'RSS 来源',
__status: group.editable === false ? '只读' : '可编辑',
__title: pick(group, ['name', 'feed_name', 'source', 'id'], '新闻组'),
},
}
}
function updateNewsFeedDraft(
draft: string,
fallback: AnyRecord,
@@ -2573,6 +2677,10 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
const [hierarchyDraft, setHierarchyDraft] = useState('')
const [tvDraftGroup, setTvDraftGroup] = useState<HierarchyGroup | null>(null)
const [newsDraftGroup, setNewsDraftGroup] = useState<HierarchyGroup | null>(null)
const [newsItemEditorDraft, setNewsItemEditorDraft] = useState('')
const [newsItemEditorId, setNewsItemEditorId] = useState('')
const [newsImportDialogOpen, setNewsImportDialogOpen] = useState(false)
const [newsImportTargetGroupId, setNewsImportTargetGroupId] = useState('')
const [newsFilters, setNewsFilters] = useState({ status: 'all', sourceType: 'all', region: 'all', tag: 'all' })
const [collectionDraftGroup, setCollectionDraftGroup] = useState<HierarchyGroup | null>(null)
const [snapshotSelectionBySource, setSnapshotSelectionBySource] = useState<Record<string, string>>({})
@@ -2597,6 +2705,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
const [visibleSecretFields, setVisibleSecretFields] = useState<Record<string, boolean>>({})
const [smtpTestEmail, setSmtpTestEmail] = useState('')
const [brandUploadFile, setBrandUploadFile] = useState<File | null>(null)
const [newsImportFile, setNewsImportFile] = useState<File | null>(null)
const newsImportInputRef = useRef<HTMLInputElement>(null)
const [resolveTarget, setResolveTarget] = useState<TableRecord | null>(null)
const [resolutionText, setResolutionText] = useState('已处理')
const [credentialGuide, setCredentialGuide] = useState<AnyRecord | null>(null)
@@ -3140,6 +3250,10 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
setActiveGroupKey('')
setHierarchyDraft('')
setTvDraftGroup(null)
setNewsItemEditorDraft('')
setNewsItemEditorId('')
setNewsImportDialogOpen(false)
setNewsImportFile(null)
setSelected(null)
setSelectedHistory([])
setMobileResourceDetailOpen(false)
@@ -3791,6 +3905,71 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
setBrandUploadFile(file)
}
const createManualNewsGroup = async () => {
setActionLoading(true)
try {
const response = await axios.post(apiPath('/earth/news-groups'), { name: '新建新闻组' })
const group = isObjectRecord(response.data?.group) ? response.data.group : {}
const groupId = text(group.id, '')
toast({ title: '新闻组已创建', tone: 'success' })
await load()
if (groupId) {
setActiveGroupKey(`news-group:${groupId}`)
setHierarchyDraft(formatRaw(group))
setMobileHierarchyDetailOpen(true)
}
} catch (error) {
toast({ title: '创建新闻组失败', description: actionErrorMessage(error), tone: 'error' })
} finally {
setActionLoading(false)
}
}
const openManualNewsImportDialog = (groupId: string) => {
setNewsImportTargetGroupId(groupId)
setNewsImportFile(null)
setNewsImportDialogOpen(true)
}
const importManualNewsJson = async () => {
if (!newsImportFile) {
toast({ title: '请选择 JSON 文件', tone: 'error' })
return
}
if (!newsImportTargetGroupId) {
toast({ title: '请选择新闻组', description: 'JSON 导入需要在手动新闻组详情页中执行。', tone: 'error' })
return
}
if (!newsImportFile.name.toLowerCase().endsWith('.json')) {
toast({ title: '文件类型不支持', description: '首版只支持 JSON 数组文件。', tone: 'error' })
return
}
const formData = new FormData()
formData.append('file', newsImportFile)
formData.append('group_id', newsImportTargetGroupId)
setActionLoading(true)
try {
const response = await axios.post(apiPath('/earth/news-items/import'), formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
const result = response.data as AnyRecord
setNewsImportFile(null)
setNewsImportDialogOpen(false)
toast({
title: '新闻导入完成',
description: `新增 ${text(result.created, '0')} 条,更新 ${text(result.updated, '0')} 条,失败 ${text(result.failed, '0')} 条。`,
tone: Number(result.failed || 0) > 0 ? 'error' : 'success',
})
const activeKey = activeGroupKey
await load()
if (activeKey) setActiveGroupKey(activeKey)
} catch (error) {
toast({ title: '导入新闻失败', description: actionErrorMessage(error), tone: 'error' })
} finally {
setActionLoading(false)
}
}
const saveAdvancedJson = async () => {
if (!selected) return
let payload: unknown
@@ -4893,6 +5072,9 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
const newsPayload = config === configs.earthContent && activeSection.key === 'news_sources'
? newsSourcesPayload(activeState.raw)
: {}
const newsSettingsPayload = config === configs.earthContent
? newsSourcesPayload(states.find((state) => state.section.key === 'news_sources')?.raw)
: {}
if (config === configs.earthContent && activeSection.key === 'news_sources') {
const matchesNewsFilter = (group: HierarchyGroup) => {
const source = group.record
@@ -4913,6 +5095,9 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
groups = sortGroupsByStatus(arrayAt(newsPayload, 'sources').filter(isObjectRecord).map((source) => newsSourceGroup(source, newsHealthPayload))).filter(matchesNewsFilter)
if (newsDraftGroup) groups.push(newsDraftGroup)
}
if (config === configs.earthContent && activeSection.key === 'news_items') {
groups = activeState.rows.map((row) => newsContentGroup(row))
}
if (config === configs.collection && activeSection.key === 'collection_history') {
groups = activeState.rows.map((row) => ({
key: row.__rowId,
@@ -5027,7 +5212,32 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
{ key: 'cooldown_minutes', label: '熔断冷却(分钟)', type: 'number' },
{ key: 'circuit_breaker', label: '熔断开关', type: 'boolean' },
] : []
const fields = newsSourceFields.length ? newsSourceFields : [...scalarFields, ...objectFields]
const newsItemReadonly = config === configs.earthContent
&& activeSection.key === 'news_items'
&& activeGroup
&& !activeGroup.record.__isDraft
&& activeGroup.record.editable === false
const newsItemFields: FieldConfig[] = config === configs.earthContent && activeSection.key === 'news_items' ? [
{ key: 'name', label: '组名 / 来源名', disabled: newsItemReadonly },
{ key: 'group_type', label: '组类型', disabled: true },
{ key: 'source_type', label: '来源类型', disabled: true },
{ key: 'count', label: '新闻数量', type: 'number', disabled: true },
] : []
const manualNewsItemFields: FieldConfig[] = [
{ key: 'title', label: '标题' },
{ key: 'summary', label: '摘要', type: 'textarea', wide: true },
{ key: 'content', label: '正文', type: 'textarea', wide: true },
{ key: 'source', label: '内容来源' },
{ key: 'url', label: '原文链接', wide: true },
{ key: 'region', label: '缺省区域', type: 'select', options: NEWS_REGION_OPTIONS.filter((option) => !['china', 'us'].includes(option.value)) },
{ key: 'published_at', label: '发布时间' },
{ key: 'category', label: '新闻类型', type: 'select', options: newsCategoryOptions(newsSettingsPayload) },
{ key: 'tags_text', label: '标签', wide: true },
{ key: 'latitude', label: '纬度', type: 'number' },
{ key: 'longitude', label: '经度', type: 'number' },
{ key: 'location_label', label: '位置标签' },
]
const fields = newsSourceFields.length ? newsSourceFields : newsItemFields.length ? newsItemFields : [...scalarFields, ...objectFields]
const renderNewsFeedEditor = () => {
if (!activeGroup) return null
const currentSource = draftRecord(hierarchyDraft, activeGroup.record)
@@ -5106,6 +5316,146 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
)
}
const startNewsItemEditor = (item?: AnyRecord) => {
const groupId = text(activeGroup?.record.id, '')
const baseline = item
? newsItemToEditor(item)
: newsItemToEditor({
id: '__new__',
title: '',
summary: '',
content: '',
source: '手动添加',
source_type: 'manual',
region: 'global',
category: 'other',
published_at: new Date().toISOString(),
editable: true,
group_id: groupId,
})
setNewsItemEditorId(text(baseline.id, '__new__'))
setNewsItemEditorDraft(formatRaw(baseline))
}
const saveNewsItemEditor = async () => {
if (!activeGroup) return
const groupId = text(activeGroup.record.id, '')
if (!groupId || text(activeGroup.record.group_type, '') !== 'manual') return
const current = draftRecord(newsItemEditorDraft, {})
const payload = { ...newsItemFromEditor(current), group_id: groupId }
const validationError = newsItemValidationError(payload)
if (validationError) {
toast({ title: '新闻内容不完整', description: validationError, tone: 'error' })
return
}
const itemId = text(current.id, newsItemEditorId)
const isNew = !itemId || itemId === '__new__'
await requestAction(
isNew ? '新增新闻内容' : '保存新闻内容',
isNew ? 'post' : 'put',
isNew ? '/earth/news-items' : `/earth/news-items/${encodeURIComponent(itemId)}`,
payload,
)
setNewsItemEditorDraft('')
setNewsItemEditorId('')
}
const renderNewsContentGroupEditor = () => {
if (!activeGroup) return null
const group = record
const groupId = text(activeGroup.record.id, '')
const isManual = text(group.group_type, '') === 'manual'
const items = arrayAt(group, 'items')
const currentEditorRecord = newsItemEditorDraft ? draftRecord(newsItemEditorDraft, {}) : {}
return (
<>
<section className="an-field-cluster">
<div className="an-field-cluster__heading">
<h3>{isManual ? '手动新闻组' : 'RSS 来源'}</h3>
<p>{isManual ? '组内可按条添加,也可以上传 JSON 数组批量导入。' : 'RSS 来源只读,新闻由抓取与增强链路维护。'}</p>
</div>
<FieldGrid
record={activeGroup.record}
draft={hierarchyDraft}
onDraftChange={setHierarchyDraft}
fields={fields}
searchGroupKey={activeGroup.key}
/>
{isManual ? (
<TactileControlGroup className="an-hierarchy-list__footer-actions">
<Button size="icon" variant="subtle" icon="plus" title="单条添加" aria-label="单条添加" onClick={() => startNewsItemEditor()} />
<Button size="icon" variant="subtle" title="上传 JSON" aria-label="上传 JSON" onClick={() => openManualNewsImportDialog(groupId)}><ImageUp size={15} /></Button>
</TactileControlGroup>
) : null}
</section>
<section className="an-field-cluster">
<div className="an-field-cluster__heading">
<h3></h3>
<p>{items.length} /</p>
</div>
<div className="an-news-feed-list">
{items.length ? items.map((item, itemIndex) => {
const itemId = text(item.id, '')
return (
<div className="an-news-feed-card" key={itemId || `${text(item.title, 'news-item')}:${itemIndex}`}>
<div className="an-news-feed-card__header">
<strong>{pick(item, ['title', 'display_title', 'id'], '新闻条目')}</strong>
<div className="an-news-feed-card__actions">
<StatusText tone={item.verified ? 'success' : 'warning'}>{item.verified ? '已定位' : '待定位'}</StatusText>
{isManual ? (
<>
<Button size="icon" variant="subtle" title="编辑新闻" aria-label="编辑新闻" onClick={() => startNewsItemEditor(item)}><Redo2 size={14} /></Button>
<Button size="icon" variant="subtle" title="重新处理" aria-label="重新处理" onClick={() => void requestAction('重新处理新闻', 'post', `/earth/news-items/${encodeURIComponent(itemId)}/reprocess`, undefined, { refresh: true, successDescription: '新闻已重新进入清洗、翻译和定位队列。' })}><RefreshCw size={14} /></Button>
<Button size="icon" variant="danger" title="删除新闻" aria-label="删除新闻" onClick={() => setConfirmAction({
title: '删除新闻',
description: `确认删除 ${pick(item, ['title', 'id'], '新闻条目')}`,
danger: true,
confirmLabel: '删除',
run: async () => {
await requestAction('删除新闻', 'delete', `/earth/news-items/${encodeURIComponent(itemId)}`)
setNewsItemEditorDraft('')
setNewsItemEditorId('')
},
})}><Trash2 size={14} /></Button>
</>
) : null}
</div>
</div>
<p>{text(item.summary || item.display_summary, '暂无摘要')}</p>
<small>{[text(item.source, ''), text(item.region, ''), text(item.category, ''), text(item.published_at, '')].filter(Boolean).join(' · ')}</small>
</div>
)
}) : <EmptyState title="暂无新闻" description={isManual ? '可以单条添加或上传 JSON 数组导入。' : '该 RSS 来源暂无入库新闻。'} />}
</div>
</section>
{newsItemEditorDraft ? (
<section className="an-field-cluster">
<div className="an-field-cluster__heading">
<h3>{newsItemEditorId === '__new__' ? '新增新闻' : '编辑新闻'}</h3>
<p></p>
</div>
<FieldGrid
record={currentEditorRecord}
draft={newsItemEditorDraft}
onDraftChange={setNewsItemEditorDraft}
fields={manualNewsItemFields}
searchGroupKey={`${activeGroup.key}:news-editor`}
/>
<TactileControlGroup className="an-hierarchy-list__footer-actions">
<Button variant="subtle" onClick={() => {
setNewsItemEditorDraft('')
setNewsItemEditorId('')
}}><X size={15} /></Button>
<Button variant="primary" onClick={() => void saveNewsItemEditor()} loading={actionLoading}><Save size={15} /></Button>
</TactileControlGroup>
</section>
) : null}
</>
)
}
const saveCurrent = async () => {
if (!activeGroup) return
const payload = draftRecord(hierarchyDraft, activeGroup.record)
@@ -5184,6 +5534,17 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
}
return
}
if (activeSection.key === 'news_items') {
if (activeGroup.record.editable === false || text(activeGroup.record.group_type, '') !== 'manual') {
toast({ title: 'RSS 来源只读', description: 'RSS 来源组不能在这里重命名或编辑。', tone: 'error' })
return
}
const groupId = text(activeGroup.record.id, '')
await requestAction('保存新闻组', 'put', `/earth/news-groups/${encodeURIComponent(groupId)}`, {
name: text(payload.name, '').trim(),
})
return
}
if (activeSection.key === 'tv') {
const tv = tvSettingsFromRaw(activeState.raw)
const sources = Array.isArray(tv.sources) ? tv.sources.filter(isObjectRecord) : []
@@ -5368,6 +5729,10 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
selectHierarchyGroup(group)
}} />
</TactileControlGroup>
) : config === configs.earthContent && activeSection.key === 'news_items' ? (
<TactileControlGroup className="an-hierarchy-list__footer-actions">
<Button size="icon" variant="subtle" icon="plus" title="新增新闻组" aria-label="新增新闻组" onClick={() => void createManualNewsGroup()} loading={actionLoading} />
</TactileControlGroup>
) : null
const hierarchyHeader = config === configs.earthContent && activeSection.key === 'news_sources' ? (
@@ -5487,6 +5852,16 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
) : null}
</>
) : null}
{config === configs.earthContent && activeSection.key === 'news_items' && activeGroup ? (
<>
<Button size="icon" variant="subtle" title="恢复当前表单" aria-label="恢复当前表单" onClick={() => {
setHierarchyDraft(formatRaw(activeGroup.record))
setNewsItemEditorDraft('')
setNewsItemEditorId('')
toast({ title: '已恢复当前项', description: '表单已恢复到加载时状态。', tone: 'success' })
}}><Redo2 size={15} /></Button>
</>
) : null}
{config === configs.earthContent && activeSection.key === 'tv' && activeGroup ? (
<>
<Button size="icon" variant="subtle" title={text(record.id, '') === tvDefaultSourceId(activeState.raw) ? '当前已是默认频道' : '设为默认频道'} aria-label={text(record.id, '') === tvDefaultSourceId(activeState.raw) ? '当前已是默认频道' : '设为默认频道'} onClick={() => {
@@ -5724,6 +6099,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
/>
</section>
</>
) : config === configs.earthContent && activeSection.key === 'news_items' ? (
renderNewsContentGroupEditor()
) : config === configs.earthContent && activeSection.key === 'tv' ? (
<>
<div className="an-tv-edit-layout">
@@ -6618,6 +6995,41 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
</label>
</Dialog>
<Dialog
open={newsImportDialogOpen}
onOpenChange={(open) => {
setNewsImportDialogOpen(open)
if (!open) setNewsImportFile(null)
}}
title="导入 JSON 新闻"
description="上传 JSON 数组后,所有条目都会归入当前手动新闻组。"
width={520}
footer={(
<>
<Button variant="subtle" onClick={() => {
setNewsImportDialogOpen(false)
setNewsImportFile(null)
}} disabled={actionLoading}></Button>
<Button variant="primary" onClick={() => void importManualNewsJson()} loading={actionLoading} disabled={!newsImportFile}><ImageUp size={15} /></Button>
</>
)}
>
<input
ref={newsImportInputRef}
type="file"
accept="application/json,.json"
hidden
onChange={(event) => setNewsImportFile(event.target.files?.[0] ?? null)}
/>
<section className="an-field-cluster an-field-cluster--compact">
<div className="an-field-cluster__heading">
<h3>{newsImportFile ? newsImportFile.name : '选择 JSON 文件'}</h3>
<p> JSON </p>
</div>
<Button variant="subtle" onClick={() => newsImportInputRef.current?.click()}><ImageUp size={15} /></Button>
</section>
</Dialog>
<ConfirmDialog
open={Boolean(confirmAction)}
onOpenChange={(open) => {
@@ -6784,6 +7196,20 @@ const configs = {
}]
: [],
},
{
key: 'news_items',
label: '新闻内容',
url: '/earth/news-groups',
map: (payload) => arrayAt(payload, 'groups').map((group) => ({
...group,
__title: pick(group, ['name', 'feed_name', 'source', 'id'], '新闻组'),
__module: text(group.group_type, '') === 'manual' ? '手动新闻组' : 'RSS 来源',
__status: group.editable === false ? '只读' : '可编辑',
__metric: `${text(group.count, '0')}`,
__endpointKey: 'newsGroups',
__endpointLabel: '新闻内容',
})),
},
{ key: 'basemap', label: '底图资源', map: emptyRows },
{ key: 'layer_resources', label: '图层资源', map: emptyRows },
{ key: 'models_3d', label: '3D 模型', map: emptyRows },