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

This commit is contained in:
linkong
2026-06-30 18:54:34 +08:00
parent 5bdb55f3f1
commit d30f7d08c5
17 changed files with 387 additions and 90 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.74.0",
"version": "0.74.1",
"private": true,
"packageManager": "bun@1",
"dependencies": {

View File

@@ -27,7 +27,7 @@ import {
Trash2,
X,
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type DragEvent as ReactDragEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
@@ -132,6 +132,8 @@ type DatasourceMetricBaseline = {
count: number
}
type BrandAssetTargetKey = 'logo_src' | 'title_src'
const DEFAULT_DATASOURCE_FILTERS: DatasourceFilters = {
product: '',
module: '',
@@ -871,6 +873,14 @@ type FieldConfig = {
placeholder?: string
disabled?: boolean
help?: string
renderInput?: (props: {
disabled?: boolean
displayValue: unknown
fieldKey: string
onChange: (value: unknown) => void
placeholder?: string
value: unknown
}) => ReactNode
inputAction?: {
ariaLabel?: string
disabled?: boolean
@@ -2301,6 +2311,9 @@ function FieldGrid({
const className = field.wide ? 'an-field an-field--wide' : 'an-field'
const searchTarget = searchGroupKey ? `${searchGroupKey}:field:${field.key}` : `field:${field.key}`
const searchText = [label, field.key, text(displayValue, '')].filter(Boolean).join(' ')
const commitFieldValue = (nextValue: unknown) => {
onDraftChange(setNestedDraftField(draft, record, field.key, nextValue))
}
if (field.type === 'boolean') {
return (
<label key={field.key} className="an-checkbox-row an-field--wide" data-admin-search-target={searchTarget} data-admin-search-field={field.key} data-admin-search-text={searchText}>
@@ -2308,7 +2321,7 @@ function FieldGrid({
type="checkbox"
checked={Boolean(value)}
disabled={field.disabled}
onChange={(event) => onDraftChange(setNestedDraftField(draft, record, field.key, event.target.checked))}
onChange={(event) => commitFieldValue(event.target.checked)}
/>
{label}
{help ? <small>{help}</small> : null}
@@ -2323,7 +2336,7 @@ function FieldGrid({
className="an-input"
value={text(value, '')}
disabled={field.disabled}
onChange={(event) => onDraftChange(setNestedDraftField(draft, record, field.key, event.target.value))}
onChange={(event) => commitFieldValue(event.target.value)}
>
{(field.options || []).map((option) => (
<option key={option.value} value={option.value}>
@@ -2346,9 +2359,9 @@ function FieldGrid({
onChange={(event) => {
const nextValue = event.target.value
try {
onDraftChange(setNestedDraftField(draft, record, field.key, JSON.parse(nextValue)))
commitFieldValue(JSON.parse(nextValue))
} catch {
onDraftChange(setNestedDraftField(draft, record, field.key, nextValue))
commitFieldValue(nextValue)
}
}}
spellCheck={false}
@@ -2401,6 +2414,22 @@ function FieldGrid({
</label>
)
}
if (field.renderInput) {
return (
<div key={field.key} className={className} data-admin-search-target={searchTarget} data-admin-search-field={field.key} data-admin-search-text={searchText}>
<span>{label}</span>
{field.renderInput({
disabled: field.disabled,
displayValue,
fieldKey: field.key,
onChange: commitFieldValue,
placeholder,
value,
})}
{help ? <small>{help}</small> : null}
</div>
)
}
const inputAction = field.inputAction
? {
...field.inputAction,
@@ -2421,12 +2450,7 @@ function FieldGrid({
value={text(displayValue, '')}
placeholder={placeholder}
disabled={field.disabled}
onChange={(event) => onDraftChange(setNestedDraftField(
draft,
record,
field.key,
field.type === 'number' ? Number(event.target.value) : event.target.value,
))}
onChange={(event) => commitFieldValue(field.type === 'number' ? Number(event.target.value) : event.target.value)}
/>
</ConnectionTestInput>
) : (
@@ -2436,12 +2460,7 @@ function FieldGrid({
value={text(displayValue, '')}
placeholder={placeholder}
disabled={field.disabled}
onChange={(event) => onDraftChange(setNestedDraftField(
draft,
record,
field.key,
field.type === 'number' ? Number(event.target.value) : event.target.value,
))}
onChange={(event) => commitFieldValue(field.type === 'number' ? Number(event.target.value) : event.target.value)}
/>
)}
{help ? <small>{help}</small> : null}
@@ -2545,6 +2564,25 @@ function GroupList({
const PLAYGROUND_SESSION_KEY = 'default'
const BRAND_ASSET_ACCEPT = '.png,.jpg,.jpeg,.webp,.svg'
const BRAND_ASSET_SUFFIXES = ['png', 'jpg', 'jpeg', 'webp', 'svg']
const BRAND_ASSET_TARGET_META: Record<BrandAssetTargetKey, {
copyLabel: string
dropLabel: string
uploadLabel: string
uploadTitle: string
}> = {
logo_src: {
copyLabel: '复制为 Logo',
dropLabel: '将图片拖到这里',
uploadLabel: '上传',
uploadTitle: '上传 Logo',
},
title_src: {
copyLabel: '复制为标题图',
dropLabel: '将图片拖到这里',
uploadLabel: '上传',
uploadTitle: '上传标题图片',
},
}
const PLAYGROUND_PRESETS = [
{
key: 'bgp-brief',
@@ -2572,6 +2610,121 @@ const PLAYGROUND_PRESETS = [
},
]
function isBrandAssetTargetKey(value: string): value is BrandAssetTargetKey {
return value === 'logo_src' || value === 'title_src'
}
function isFileDrag(event: ReactDragEvent<HTMLElement>) {
return Array.from(event.dataTransfer.types).includes('Files')
}
function BrandAssetInput({
disabled,
onChange,
onFile,
placeholder,
target,
uploading,
value,
}: {
disabled?: boolean
onChange: (value: string) => void
onFile: (file: File) => void
placeholder?: string
target: BrandAssetTargetKey
uploading: boolean
value: string
}) {
const { locale } = useLocale()
const [dragging, setDragging] = useState(false)
const dragCounter = useRef(0)
const fileInputRef = useRef<HTMLInputElement>(null)
const meta = BRAND_ASSET_TARGET_META[target]
const suffixHelp = BRAND_ASSET_SUFFIXES.join(' / ')
const resetDrag = () => {
dragCounter.current = 0
setDragging(false)
}
const handleDragEnter = (event: ReactDragEvent<HTMLDivElement>) => {
if (!isFileDrag(event)) return
event.preventDefault()
dragCounter.current += 1
setDragging(true)
}
const handleDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
if (!isFileDrag(event)) return
event.preventDefault()
}
const handleDragLeave = (event: ReactDragEvent<HTMLDivElement>) => {
if (!isFileDrag(event)) return
event.preventDefault()
dragCounter.current = Math.max(0, dragCounter.current - 1)
if (dragCounter.current === 0) setDragging(false)
}
const handleDrop = (event: ReactDragEvent<HTMLDivElement>) => {
event.preventDefault()
resetDrag()
const file = event.dataTransfer.files?.[0]
if (file) onFile(file)
}
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
event.target.value = ''
if (file) onFile(file)
}
return (
<div
className={dragging ? 'an-brand-asset-input is-dragging' : 'an-brand-asset-input'}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
className="an-brand-asset-input__control"
disabled={disabled || uploading}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
type="text"
value={value}
/>
<input
ref={fileInputRef}
accept={BRAND_ASSET_ACCEPT}
className="an-brand-asset-input__file"
disabled={disabled || uploading}
onChange={handleFileChange}
type="file"
/>
<Button
className="an-brand-asset-input__upload"
disabled={disabled || uploading}
onClick={() => fileInputRef.current?.click()}
size="sm"
tactile={{ height: 28, radius: 5, shadowSize: 0.55 }}
title={`${localizeAdminText(meta.uploadTitle, locale)} · ${suffixHelp}`}
type="button"
variant="primary"
>
{uploading ? localizeAdminText('上传中', locale) : localizeAdminText(meta.uploadLabel, locale)}
</Button>
{dragging ? (
<div className="an-brand-asset-input__drop-overlay" aria-hidden="true">
<span>{localizeAdminText(meta.dropLabel, locale)}</span>
<strong>{localizeAdminText(meta.copyLabel, locale)}</strong>
</div>
) : null}
</div>
)
}
function PlaygroundLite() {
const { toast } = useToast()
const { locale } = useLocale()
@@ -2848,7 +3001,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
const [revealedSecrets, setRevealedSecrets] = useState<Record<string, AnyRecord>>({})
const [visibleSecretFields, setVisibleSecretFields] = useState<Record<string, boolean>>({})
const [smtpTestEmail, setSmtpTestEmail] = useState('')
const [brandUploadFile, setBrandUploadFile] = useState<File | null>(null)
const [brandAssetUploadingTarget, setBrandAssetUploadingTarget] = useState<BrandAssetTargetKey | null>(null)
const [newsImportFile, setNewsImportFile] = useState<File | null>(null)
const newsImportInputRef = useRef<HTMLInputElement>(null)
const [resolveTarget, setResolveTarget] = useState<TableRecord | null>(null)
@@ -4002,51 +4155,46 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
setResolveTarget(null)
}
const uploadBrandAsset = async () => {
if (!brandUploadFile) {
toast({ title: '请选择上传文件', tone: 'error' })
return
}
const suffix = brandUploadFile.name.split('.').pop()?.toLowerCase() || ''
if (!BRAND_ASSET_SUFFIXES.includes(suffix)) {
toast({ title: '文件类型不支持', description: `仅支持 ${BRAND_ASSET_SUFFIXES.join(', ')}`, tone: 'error' })
return
}
const formData = new FormData()
formData.append('file', brandUploadFile)
setActionLoading(true)
try {
const response = await axios.post(apiPath('/earth/brand/assets'), formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
replaceSelectedWithPayload('brand-upload', '品牌资产上传', [{
...response.data,
__title: '品牌资产上传结果',
__module: '品牌资产',
__status: '已上传',
__metric: brandUploadFile.name,
}])
setBrandUploadFile(null)
toast({ title: '品牌资产已上传', tone: 'success' })
await load()
} catch (error) {
toast({ title: '品牌资产上传失败', description: error instanceof Error ? error.message : '接口请求失败', tone: 'error' })
} finally {
setActionLoading(false)
}
}
const selectBrandUploadFile = (file: File | null | undefined) => {
const uploadBrandAssetFile = async (file: File | null | undefined, target: BrandAssetTargetKey) => {
if (!file) {
setBrandUploadFile(null)
return
toast({ title: '请拖入图片文件', tone: 'error' })
return null
}
const suffix = file.name.split('.').pop()?.toLowerCase() || ''
if (!BRAND_ASSET_SUFFIXES.includes(suffix)) {
toast({ title: '文件类型不支持', description: `仅支持 ${BRAND_ASSET_SUFFIXES.join(', ')}`, tone: 'error' })
return
return null
}
const formData = new FormData()
formData.append('file', file)
setActionLoading(true)
setBrandAssetUploadingTarget(target)
try {
const response = await axios.post(apiPath('/earth/brand/assets'), formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
const assetUrl = text(response.data?.url, '')
if (!assetUrl) throw new Error('上传接口未返回资产 URL')
replaceSelectedWithPayload('brand-upload', '品牌资产上传', [{
...response.data,
__title: '品牌资产上传结果',
__module: target === 'logo_src' ? 'Logo' : '标题图片',
__status: '已上传',
__metric: file.name,
}])
toast({
title: target === 'logo_src' ? 'Logo 图片已上传' : '标题图片已上传',
description: assetUrl,
tone: 'success',
})
return assetUrl
} catch (error) {
toast({ title: '品牌资产上传失败', description: actionErrorMessage(error), tone: 'error' })
return null
} finally {
setActionLoading(false)
setBrandAssetUploadingTarget(null)
}
setBrandUploadFile(file)
}
const createManualNewsGroup = async () => {
@@ -5413,6 +5561,32 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
{ key: 'location_label', label: '位置标签' },
]
const fields = newsSourceFields.length ? newsSourceFields : newsItemFields.length ? newsItemFields : [...scalarFields, ...objectFields]
const uploadBrandAssetToDraft = async (file: File, target: BrandAssetTargetKey) => {
if (!activeGroup) return
const assetUrl = await uploadBrandAssetFile(file, target)
if (!assetUrl) return
setHierarchyDraft((current) => setNestedDraftField(current, activeGroup.record, target, assetUrl))
}
const editableFields = config === configs.earthContent && activeSection.key === 'brand'
? fields.map((field): FieldConfig => {
if (!isBrandAssetTargetKey(field.key)) return field
const target = field.key
return {
...field,
renderInput: ({ disabled, displayValue, onChange, placeholder }) => (
<BrandAssetInput
disabled={disabled}
onChange={(nextValue) => onChange(nextValue)}
onFile={(file) => void uploadBrandAssetToDraft(file, target)}
placeholder={placeholder}
target={target}
uploading={brandAssetUploadingTarget === target}
value={text(displayValue, '')}
/>
),
}
})
: fields
const renderNewsFeedEditor = () => {
if (!activeGroup) return null
const currentSource = draftRecord(hierarchyDraft, activeGroup.record)
@@ -5951,20 +6125,6 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
<div className="an-toolbar">
{config === configs.earthContent && activeSection.key === 'brand' ? (
<>
<label
className="an-file-action an-file-action--drop"
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault()
selectBrandUploadFile(event.dataTransfer.files?.[0])
}}
title={`支持 ${BRAND_ASSET_SUFFIXES.join(', ')}`}
>
<input type="file" accept={BRAND_ASSET_ACCEPT} onChange={(event) => selectBrandUploadFile(event.target.files?.[0])} />
<span><ImageUp size={15} />{brandUploadFile ? brandUploadFile.name : '选择/拖入资产'}</span>
<small>{BRAND_ASSET_SUFFIXES.join(' / ')}</small>
</label>
<Button variant="primary" onClick={() => void uploadBrandAsset()} loading={actionLoading} disabled={!brandUploadFile}><ImageUp size={15} /></Button>
<Button size="icon" variant="subtle" title="重置品牌配置" aria-label="重置品牌配置" onClick={() => setConfirmAction({
title: '重置品牌配置',
description: '确认恢复默认智能星球品牌配置?当前自定义配置会被清空。',
@@ -6309,7 +6469,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
</section>
</>
) : (
<FieldGrid record={activeGroup.record} draft={hierarchyDraft} onDraftChange={setHierarchyDraft} fields={fields} searchGroupKey={activeGroup.key} />
<FieldGrid record={activeGroup.record} draft={hierarchyDraft} onDraftChange={setHierarchyDraft} fields={editableFields} searchGroupKey={activeGroup.key} />
)}
{config === configs.collection && activeSection.key === 'collection_history' ? null : (
<details className="an-advanced-editor">
@@ -6463,11 +6623,6 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
if (config === configs.earthContent && endpointKey === 'brand') {
actions.push(
<label key="brand-upload" className="an-file-action">
<input type="file" onChange={(event) => setBrandUploadFile(event.target.files?.[0] ?? null)} />
<span><ImageUp size={15} />{brandUploadFile ? brandUploadFile.name : '选择资产'}</span>
</label>,
<Button key="brand-upload-run" variant="primary" onClick={() => void uploadBrandAsset()} loading={actionLoading} disabled={!brandUploadFile}><ImageUp size={15} /></Button>,
<Button key="delete-brand" variant="danger" onClick={() => setConfirmAction({
title: '删除品牌配置',
description: '确认删除智能星球品牌配置?',

View File

@@ -1697,6 +1697,111 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
border-style: dashed;
}
.an-brand-asset-input {
position: relative;
min-width: 0;
height: 34px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
overflow: hidden;
border: 1px solid var(--an-border);
border-radius: 6px;
background: var(--an-surface);
color: var(--an-text);
transition: border-color 0.16s ease, box-shadow 0.16s ease, background-color 0.16s ease;
}
.an-brand-asset-input:focus-within {
border-color: color-mix(in srgb, var(--an-accent) 24%, var(--an-border-strong));
}
.an-brand-asset-input.is-dragging {
border-color: var(--an-border-strong);
background: color-mix(in srgb, var(--an-soft) 46%, var(--an-surface));
box-shadow: 0 2px 5px rgba(15, 23, 42, 0.1), 0 8px 18px rgba(15, 23, 42, 0.06);
}
.an-brand-asset-input__control {
width: 100%;
min-width: 0;
height: 100%;
border: 0;
outline: 0;
background: transparent;
color: inherit;
padding: 0 10px;
font: inherit;
line-height: 32px;
}
.an-brand-asset-input__control:disabled {
color: var(--an-muted);
}
.an-brand-asset-input__file {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.an-brand-asset-input__upload {
position: relative;
z-index: 1;
min-width: 72px;
height: var(--tui-control-height, 28px);
margin-right: 3px;
padding-inline: 10px;
white-space: nowrap;
}
.an-brand-asset-input__drop-overlay {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 12px;
border: 1px dashed var(--an-border-strong);
border-radius: 5px;
background: color-mix(in srgb, var(--an-soft) 82%, var(--an-surface));
color: var(--an-text);
pointer-events: none;
}
.an-brand-asset-input__drop-overlay > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 800;
}
.an-brand-asset-input__drop-overlay > strong {
min-width: max-content;
height: 28px;
padding: 0 10px;
border: 1px solid var(--an-border);
border-radius: 6px;
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--an-surface);
color: var(--an-text);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.16);
font-size: 13px;
font-weight: 800;
}
.admin-theme-root[data-theme='dark'] .an-brand-asset-input__drop-overlay > strong {
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.34);
}
.an-mapping-form {
display: grid;
gap: 12px;

View File

@@ -556,6 +556,15 @@ export const legacyUiTextEnUS: Record<string, string> = {
'描述': 'Description',
'每日摘要': 'Daily digest',
'Logo 地址': 'Logo URL',
'上传 Logo': 'Upload logo',
'上传标题图片': 'Upload title image',
'上传中': 'Uploading',
'将图片拖到这里': 'Drop file here',
'复制为 Logo': 'Copy as logo',
'复制为标题图': 'Copy as title image',
'Logo 图片已上传': 'Logo image uploaded',
'标题图片已上传': 'Title image uploaded',
'请拖入图片文件': 'Drop an image file',
'API 基础地址': 'API base URL',
'Firecrawl 抓取路径': 'Firecrawl scrape path',
'Firecrawl 搜索路径': 'Firecrawl search path',