2026-04-15 09:47:31 +00:00
import { App , Avatar , Button , Card , Col , Drawer , Form , Input , InputNumber , Popconfirm , Row , Select , Space , Switch , Table , Tag , Typography , Upload } from "antd" ;
2026-04-13 12:21:08 +00:00
import type { ColumnsType } from "antd/es/table" ;
import { AppstoreOutlined , DeleteOutlined , EditOutlined , GlobalOutlined , LinkOutlined , PictureOutlined , PlusOutlined , ReloadOutlined , RobotOutlined , SaveOutlined , SearchOutlined , UploadOutlined } from "@ant-design/icons" ;
import { useCallback , useEffect , useMemo , useState } from "react" ;
import PageHeader from "@/components/shared/PageHeader" ;
2026-04-16 01:52:29 +00:00
import AppPagination from "@/components/shared/AppPagination" ;
2026-04-13 12:21:08 +00:00
import { createExternalApp , deleteExternalApp , listExternalApps , type ExternalAppDTO , type ExternalAppVO , updateExternalApp , uploadExternalAppApk , uploadExternalAppIcon } from "@/api/business/externalApp" ;
const { Text } = Typography ;
const { TextArea } = Input ;
type ExternalAppFormValues = {
appName : string ;
appType : "native" | "web" ;
appInfo ? : {
versionName? : string ;
webUrl? : string ;
packageName? : string ;
apkUrl? : string ;
} ;
iconUrl? : string ;
description? : string ;
sortOrder? : number ;
statusEnabled : boolean ;
remark? : string ;
} ;
const STATUS_OPTIONS = [
{ label : "全部状态" , value : "all" } ,
{ label : "已启用" , value : "enabled" } ,
{ label : "已停用" , value : "disabled" } ,
] as const ;
2026-04-15 09:47:31 +00:00
function normalizeAppInfo ( value : ExternalAppVO [ "appInfo" ] ) : NonNullable < ExternalAppFormValues [ " appInfo " ] > {
2026-04-13 12:21:08 +00:00
if ( ! value || typeof value !== "object" ) {
return { } ;
}
const info = value as Record < string , unknown > ;
return {
versionName : typeof info . versionName === "string" ? info.versionName : undefined ,
webUrl : typeof info . webUrl === "string" ? info.webUrl : undefined ,
packageName : typeof info . packageName === "string" ? info.packageName : undefined ,
apkUrl : typeof info . apkUrl === "string" ? info.apkUrl : undefined ,
} ;
}
function compactObject < T extends Record < string , unknown > > ( value : T ) : T {
return Object . fromEntries ( Object . entries ( value ) . filter ( ( [ , field ] ) = > field !== undefined && field !== null && field !== "" ) ) as T ;
}
export default function ExternalAppManagement() {
const { message } = App . useApp ( ) ;
const [ form ] = Form . useForm < ExternalAppFormValues > ( ) ;
const [ loading , setLoading ] = useState ( false ) ;
const [ saving , setSaving ] = useState ( false ) ;
const [ drawerOpen , setDrawerOpen ] = useState ( false ) ;
const [ editing , setEditing ] = useState < ExternalAppVO | null > ( null ) ;
const [ records , setRecords ] = useState < ExternalAppVO [ ] > ( [ ] ) ;
const [ searchValue , setSearchValue ] = useState ( "" ) ;
const [ statusFilter , setStatusFilter ] = useState < ( typeof STATUS_OPTIONS ) [ number ] [ "value" ] > ( "all" ) ;
const [ appTypeFilter , setAppTypeFilter ] = useState < "all" | "native" | "web" > ( "all" ) ;
const [ page , setPage ] = useState ( 1 ) ;
const [ pageSize , setPageSize ] = useState ( 10 ) ;
const [ uploadingApk , setUploadingApk ] = useState ( false ) ;
const [ uploadingIcon , setUploadingIcon ] = useState ( false ) ;
const loadData = useCallback ( async ( ) = > {
setLoading ( true ) ;
try {
const result = await listExternalApps ( ) ;
setRecords ( result || [ ] ) ;
} finally {
setLoading ( false ) ;
}
} , [ ] ) ;
useEffect ( ( ) = > {
void loadData ( ) ;
} , [ loadData ] ) ;
const filteredRecords = useMemo ( ( ) = > {
const keyword = searchValue . trim ( ) . toLowerCase ( ) ;
return records . filter ( ( item ) = > {
if ( appTypeFilter !== "all" && item . appType !== appTypeFilter ) {
return false ;
}
if ( statusFilter === "enabled" && item . status !== 1 ) {
return false ;
}
if ( statusFilter === "disabled" && item . status === 1 ) {
return false ;
}
if ( ! keyword ) {
return true ;
}
const info = normalizeAppInfo ( item . appInfo ) ;
return [ item . appName , item . description , info . versionName , info . webUrl , info . packageName , info . apkUrl , item . creatorUsername ] . some ( ( field ) = >
String ( field || "" ) . toLowerCase ( ) . includes ( keyword )
) ;
} ) ;
} , [ appTypeFilter , records , searchValue , statusFilter ] ) ;
const pagedRecords = useMemo ( ( ) = > {
const start = ( page - 1 ) * pageSize ;
return filteredRecords . slice ( start , start + pageSize ) ;
} , [ filteredRecords , page , pageSize ] ) ;
useEffect ( ( ) = > {
setPage ( 1 ) ;
} , [ searchValue , statusFilter , appTypeFilter ] ) ;
const stats = useMemo ( ( ) = > ( {
total : records.length ,
native : records.filter ( ( item ) = > item . appType === "native" ) . length ,
web : records.filter ( ( item ) = > item . appType === "web" ) . length ,
enabled : records.filter ( ( item ) = > item . status === 1 ) . length ,
} ) , [ records ] ) ;
const openCreate = ( ) = > {
setEditing ( null ) ;
form . resetFields ( ) ;
form . setFieldsValue ( {
appType : "native" ,
statusEnabled : true ,
sortOrder : 0 ,
appInfo : { } ,
} ) ;
setDrawerOpen ( true ) ;
} ;
const openEdit = ( record : ExternalAppVO ) = > {
setEditing ( record ) ;
form . setFieldsValue ( {
appName : record.appName ,
appType : record.appType ,
appInfo : normalizeAppInfo ( record . appInfo ) ,
iconUrl : record.iconUrl ,
description : record.description ,
sortOrder : record.sortOrder ,
statusEnabled : record.status === 1 ,
remark : record.remark ,
} ) ;
setDrawerOpen ( true ) ;
} ;
const handleDelete = async ( record : ExternalAppVO ) = > {
2026-04-14 03:17:25 +00:00
await deleteExternalApp ( record . id ) ;
2026-04-13 12:21:08 +00:00
message . success ( "删除成功" ) ;
await loadData ( ) ;
} ;
const handleSubmit = async ( ) = > {
const values = await form . validateFields ( ) ;
const payload : ExternalAppDTO = {
appName : values.appName.trim ( ) ,
appType : values.appType ,
appInfo : compactObject ( values . appInfo || { } ) ,
iconUrl : values.iconUrl?.trim ( ) ,
description : values.description?.trim ( ) ,
sortOrder : values.sortOrder ,
status : values.statusEnabled ? 1 : 0 ,
remark : values.remark?.trim ( ) ,
} ;
setSaving ( true ) ;
try {
if ( editing ) {
await updateExternalApp ( editing . id , payload ) ;
message . success ( "外部应用更新成功" ) ;
} else {
await createExternalApp ( payload ) ;
message . success ( "外部应用创建成功" ) ;
}
setDrawerOpen ( false ) ;
await loadData ( ) ;
} finally {
setSaving ( false ) ;
}
} ;
2026-04-14 03:17:25 +00:00
2026-04-13 12:21:08 +00:00
const handleUploadApk = async ( file : File ) = > {
setUploadingApk ( true ) ;
try {
const result = await uploadExternalAppApk ( file ) ;
const currentInfo = form . getFieldValue ( "appInfo" ) || { } ;
form . setFieldsValue ( {
appInfo : {
. . . currentInfo ,
apkUrl : result.apkUrl || currentInfo . apkUrl ,
packageName : result.packageName || currentInfo . packageName ,
versionName : result.versionName || currentInfo . versionName ,
} ,
} ) ;
if ( result . appName && ! form . getFieldValue ( "appName" ) ) {
form . setFieldValue ( "appName" , result . appName ) ;
}
message . success ( "APK 上传成功,已自动回填可解析的 APK 元数据" ) ;
} finally {
setUploadingApk ( false ) ;
}
} ;
const handleUploadIcon = async ( file : File ) = > {
setUploadingIcon ( true ) ;
try {
const result = await uploadExternalAppIcon ( file ) ;
if ( result . iconUrl ) {
form . setFieldValue ( "iconUrl" , result . iconUrl ) ;
}
message . success ( "图标上传成功" ) ;
} finally {
setUploadingIcon ( false ) ;
}
} ;
const handleToggleStatus = async ( record : ExternalAppVO , checked : boolean ) = > {
await updateExternalApp ( record . id , { status : checked ? 1 : 0 } ) ;
message . success ( checked ? "已启用应用" : "已停用应用" ) ;
await loadData ( ) ;
} ;
const columns : ColumnsType < ExternalAppVO > = [
{
title : "应用" ,
key : "app" ,
width : 260 ,
render : ( _ , record ) = > (
< Space >
< Avatar shape = "square" size = { 44 } src = { record . iconUrl } icon = { < AppstoreOutlined / > } / >
< Space direction = "vertical" size = { 0 } >
< Text strong > { record . appName } < / Text >
< Text type = "secondary" > { record . description || "暂无描述" } < / Text >
< / Space >
< / Space >
) ,
} ,
{
title : "类型" ,
dataIndex : "appType" ,
key : "appType" ,
width : 120 ,
render : ( value : ExternalAppVO [ "appType" ] ) = > value === "native" ? < Tag color = "green" icon = { < RobotOutlined / > } > 原 生 应 用 < / Tag > : < Tag color = "blue" icon = { < GlobalOutlined / > } > Web 应 用 < / Tag > ,
} ,
{
title : "入口信息" ,
key : "entry" ,
width : 260 ,
render : ( _ , record ) = > {
const info = normalizeAppInfo ( record . appInfo ) ;
return (
< Space direction = "vertical" size = { 0 } >
< Text type = "secondary" > 版 本 : { info . versionName || "-" } < / Text >
< Text type = "secondary" > { record . appType === "native" ? ` 包名: ${ info . packageName || "-" } ` : ` 地址: ${ info . webUrl || "-" } ` } < / Text >
< / Space >
) ;
} ,
} ,
{
title : "状态" ,
key : "status" ,
width : 120 ,
render : ( _ , record ) = > < Switch size = "small" checked = { record . status === 1 } onChange = { ( checked ) = > void handleToggleStatus ( record , checked ) } / > ,
} ,
{
title : "排序权重" ,
dataIndex : "sortOrder" ,
key : "sortOrder" ,
width : 90 ,
align : "center" ,
} ,
{
title : "操作" ,
key : "action" ,
width : 150 ,
fixed : "right" ,
render : ( _ , record ) = > {
const info = normalizeAppInfo ( record . appInfo ) ;
const link = record . appType === "native" ? info.apkUrl : info.webUrl ;
return (
< Space size = { 4 } >
< Button type = "text" icon = { < LinkOutlined / > } href = { link } target = "_blank" disabled = { ! link } / >
< Button type = "text" icon = { < EditOutlined / > } onClick = { ( ) = > openEdit ( record ) } / >
< Popconfirm title = "确认删除该应用吗?" onConfirm = { ( ) = > void handleDelete ( record ) } >
< Button type = "text" danger icon = { < DeleteOutlined / > } / >
< / Popconfirm >
< / Space >
) ;
} ,
} ,
] ;
return (
< div style = { { height : "100%" , display : "flex" , flexDirection : "column" , overflow : "hidden" } } >
< PageHeader
title = "外部应用管理"
subtitle = "统一维护首页九宫格与抽屉入口中的原生应用、Web 服务和应用图标资源。"
extra = {
< Space >
< Button icon = { < ReloadOutlined / > } onClick = { ( ) = > void loadData ( ) } loading = { loading } > 刷 新 < / Button >
< Button type = "primary" icon = { < PlusOutlined / > } onClick = { openCreate } > 新 增 应 用 < / Button >
< / Space >
}
/ >
< Row gutter = { 16 } className = "mb-4" >
< Col span = { 6 } > < Card size = "small" > < Space > < AppstoreOutlined style = { { color : "#1677ff" } } / > < div > < div > 应 用 总 数 < / div > < Text strong > { stats . total } < / Text > < / div > < / Space > < / Card > < / Col >
< Col span = { 6 } > < Card size = "small" > < Space > < RobotOutlined style = { { color : "#52c41a" } } / > < div > < div > 原 生 应 用 < / div > < Text strong > { stats . native } < / Text > < / div > < / Space > < / Card > < / Col >
< Col span = { 6 } > < Card size = "small" > < Space > < GlobalOutlined style = { { color : "#722ed1" } } / > < div > < div > Web 应 用 < / div > < Text strong > { stats . web } < / Text > < / div > < / Space > < / Card > < / Col >
< Col span = { 6 } > < Card size = "small" > < Space > < PictureOutlined style = { { color : "#fa8c16" } } / > < div > < div > 已 启 用 < / div > < Text strong > { stats . enabled } < / Text > < / div > < / Space > < / Card > < / Col >
< / Row >
< Card className = "app-page__filter-card mb-4" styles = { { body : { padding : 16 } } } >
< Space wrap style = { { width : "100%" , justifyContent : "space-between" } } >
< Space wrap >
< Input placeholder = "搜索名称、描述、包名或入口地址" prefix = { < SearchOutlined / > } allowClear style = { { width : 320 } } value = { searchValue } onChange = { ( event ) = > setSearchValue ( event . target . value ) } / >
< Select value = { appTypeFilter } style = { { width : 140 } } onChange = { ( value ) = > setAppTypeFilter ( value ) } options = { [ { label : "全部类型" , value : "all" } , { label : "原生应用" , value : "native" } , { label : "Web 应用" , value : "web" } ] } / >
< Select value = { statusFilter } style = { { width : 140 } } onChange = { ( value ) = > setStatusFilter ( value as typeof statusFilter ) } options = { STATUS_OPTIONS as unknown as { label : string ; value : string } [ ] } / >
< / Space >
< / Space >
< / Card >
< Card className = "app-page__content-card flex-1 flex flex-col overflow-hidden" styles = { { body : { padding : 0 , flex : 1 , display : "flex" , flexDirection : "column" , overflow : "hidden" } } } >
2026-04-16 01:52:29 +00:00
< div className = "app-page__table-wrap" style = { { padding : "0 24px" , overflow : "auto" } } >
< Table rowKey = "id" columns = { columns } dataSource = { pagedRecords } loading = { loading } scroll = { { x : "max-content" } } pagination = { false } / >
2026-04-15 09:47:31 +00:00
< / div >
2026-04-16 01:52:29 +00:00
< AppPagination
current = { page }
pageSize = { pageSize }
total = { filteredRecords . length }
onChange = { ( nextPage , nextSize ) = > { setPage ( nextPage ) ; setPageSize ( nextSize ) ; } }
/ >
2026-04-13 12:21:08 +00:00
< / Card >
< Drawer title = { editing ? "编辑外部应用" : "新增外部应用" } open = { drawerOpen } onClose = { ( ) = > setDrawerOpen ( false ) } width = { 700 } destroyOnHidden forceRender footer = { < div className = "app-page__drawer-footer" > < Button onClick = { ( ) = > setDrawerOpen ( false ) } > 取 消 < / Button > < Button type = "primary" icon = { < SaveOutlined / > } loading = { saving } onClick = { ( ) = > void handleSubmit ( ) } > 保 存 < / Button > < / div > } >
< Form form = { form } layout = "vertical" >
< Row gutter = { 16 } >
< Col span = { 16 } > < Form.Item name = "appName" label = "应用名称" rules = { [ { required : true , message : "请输入应用名称" } ] } > < Input placeholder = "请输入应用名称" / > < / Form.Item > < / Col >
< Col span = { 8 } > < Form.Item name = "appType" label = "应用类型" rules = { [ { required : true , message : "请选择应用类型" } ] } > < Select options = { [ { label : "原生应用" , value : "native" } , { label : "Web 应用" , value : "web" } ] } / > < / Form.Item > < / Col >
< / Row >
< Space align = "start" className = "mb-4" >
< Upload showUploadList = { false } beforeUpload = { ( file ) = > { void handleUploadIcon ( file as File ) ; return Upload . LIST_IGNORE ; } } >
< Avatar shape = "square" size = { 72 } src = { form . getFieldValue ( "iconUrl" ) } icon = { < PictureOutlined / > } style = { { cursor : "pointer" , border : "1px dashed var(--app-border-color)" , background : "var(--app-bg-card)" } } / >
< / Upload >
< div style = { { flex : 1 } } >
< Form.Item name = "iconUrl" label = "图标地址" > < Input placeholder = "上传图标后自动回填,或手动填写" / > < / Form.Item >
< / div >
< / Space >
< Form.Item noStyle shouldUpdate = { ( prev , curr ) = > prev . appType !== curr . appType } >
{ ( { getFieldValue } ) = > getFieldValue ( "appType" ) === "native" ? (
< >
< Form.Item label = "上传 APK" >
< Upload showUploadList = { false } beforeUpload = { ( file ) = > { void handleUploadApk ( file as File ) ; return Upload . LIST_IGNORE ; } } >
< Button icon = { < UploadOutlined / > } loading = { uploadingApk } > 上 传 并 回 填 APK 信 息 < / Button >
< / Upload >
< / Form.Item >
< Row gutter = { 16 } >
< Col span = { 12 } > < Form.Item name = { [ "appInfo" , "versionName" ] } label = "版本号" > < Input placeholder = "如 1.0.0" / > < / Form.Item > < / Col >
< Col span = { 12 } > < Form.Item name = { [ "appInfo" , "packageName" ] } label = "包名" > < Input placeholder = "com.example.app" / > < / Form.Item > < / Col >
< / Row >
< Form.Item name = { [ "appInfo" , "apkUrl" ] } label = "APK 地址" rules = { [ { required : true , message : "请上传或填写 APK 地址" } ] } > < Input prefix = { < LinkOutlined / > } placeholder = "https://..." / > < / Form.Item >
< / >
) : (
< >
< Row gutter = { 16 } >
< Col span = { 12 } > < Form.Item name = { [ "appInfo" , "versionName" ] } label = "版本号" > < Input placeholder = "可选" / > < / Form.Item > < / Col >
< Col span = { 12 } > < Form.Item name = { [ "appInfo" , "webUrl" ] } label = "Web 地址" rules = { [ { required : true , message : "请输入 Web 地址" } ] } > < Input prefix = { < GlobalOutlined / > } placeholder = "https://..." / > < / Form.Item > < / Col >
< / Row >
< / >
) }
< / Form.Item >
< Form.Item name = "description" label = "应用描述" > < TextArea rows = { 3 } placeholder = "请输入应用用途说明" / > < / Form.Item >
< Row gutter = { 16 } >
< Col span = { 12 } > < Form.Item name = "sortOrder" label = "排序权重" > < InputNumber min = { 0 } style = { { width : "100%" } } / > < / Form.Item > < / Col >
< Col span = { 12 } > < Form.Item name = "remark" label = "备注" > < Input placeholder = "选填" / > < / Form.Item > < / Col >
< / Row >
< Form.Item name = "statusEnabled" label = "启用状态" valuePropName = "checked" > < Switch / > < / Form.Item >
< / Form >
< / Drawer >
< / div >
) ;
}