2026-06-30 05:37:33 +00:00
import { App , Avatar , Button , Col , Drawer , Form , Input , InputNumber , Popconfirm , Row , Select , Space , Switch , 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" ;
2026-05-09 02:17:46 +00:00
import PageContainer from "@/components/shared/PageContainer" ;
2026-06-30 05:37:33 +00:00
import DataListPanel from "@/components/shared/DataListPanel" ;
import ListTable from "@/components/shared/ListTable/ListTable" ;
2026-04-16 01:52:29 +00:00
import AppPagination from "@/components/shared/AppPagination" ;
2026-06-30 05:37:33 +00:00
import SectionCard from "@/components/shared/SectionCard" ;
2026-07-02 09:29:39 +00:00
import SummaryStatCards from "@/components/shared/SummaryStatCards" ;
2026-04-13 12:21:08 +00:00
import { createExternalApp , deleteExternalApp , listExternalApps , type ExternalAppDTO , type ExternalAppVO , updateExternalApp , uploadExternalAppApk , uploadExternalAppIcon } from "@/api/business/externalApp" ;
2026-06-30 05:37:33 +00:00
import "./ExternalAppManagement.css" ;
2026-04-13 12:21:08 +00:00
const { Text } = Typography ;
const { TextArea } = Input ;
2026-07-02 09:29:39 +00:00
const ICON_UPLOAD_ACCEPT = ".png,.jpg,.jpeg,image/png,image/jpeg" ;
2026-04-13 12:21:08 +00:00
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 > ( ) ;
2026-07-01 14:00:19 +00:00
const watchedIconUrl = Form . useWatch ( "iconUrl" , form ) ;
2026-04-13 12:21:08 +00:00
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 ] ) ;
2026-07-02 09:29:39 +00:00
const statCards = useMemo ( ( ) = > [
{ key : "total" , label : "应用总数" , value : stats.total , icon : < AppstoreOutlined / > , color : "#1890ff" } ,
{ key : "native" , label : "原生应用" , value : stats.native , icon : < RobotOutlined / > , color : "#faad14" } ,
{ key : "web" , label : "Web 应用" , value : stats.web , icon : < GlobalOutlined / > , color : "#52c41a" } ,
{ key : "enabled" , label : "已启用" , value : stats.enabled , icon : < PictureOutlined / > , color : "#13c2c2" } ,
] , [ stats ] ) ;
2026-04-13 12:21:08 +00:00
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 ) ;
}
} ;
2026-07-02 09:29:39 +00:00
const beforeUploadIcon = ( file : File ) = > {
const isAllowedIcon = file . type
? file . type === "image/png" || file . type === "image/jpeg"
: /\.(png|jpe?g)$/i . test ( file . name ) ;
if ( ! isAllowedIcon ) {
message . warning ( "应用图标仅支持上传 PNG / JPG 格式" ) ;
return Upload . LIST_IGNORE ;
}
void handleUploadIcon ( file ) ;
return Upload . LIST_IGNORE ;
} ;
2026-04-13 12:21:08 +00:00
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 } >
2026-07-06 03:08:36 +00:00
< Button type = "text" icon = { < LinkOutlined / > } href = { link } target = "_blank" disabled = { ! link } aria - label = "打开应用入口" title = "打开应用入口" / >
< Button type = "text" icon = { < EditOutlined / > } onClick = { ( ) = > openEdit ( record ) } aria - label = "编辑外部应用" title = "编辑外部应用" / >
2026-04-13 12:21:08 +00:00
< Popconfirm title = "确认删除该应用吗?" onConfirm = { ( ) = > void handleDelete ( record ) } >
2026-07-06 03:08:36 +00:00
< Button type = "text" danger icon = { < DeleteOutlined / > } aria - label = "删除外部应用" title = "删除外部应用" / >
2026-04-13 12:21:08 +00:00
< / Popconfirm >
< / Space >
) ;
} ,
} ,
] ;
return (
2026-06-30 05:37:33 +00:00
< PageContainer title = { null } className = "external-app-page" >
< SectionCard
title = "外部应用管理"
description = "统一维护首页九宫格与抽屉入口中的原生应用、Web 服务和应用图标资源。"
2026-07-02 09:29:39 +00:00
contentClassName = "external-app-page__content"
2026-06-30 05:37:33 +00:00
>
2026-07-02 09:29:39 +00:00
< SummaryStatCards items = { statCards } ariaLabel = "外部应用统计" / >
2026-06-30 05:37:33 +00:00
< DataListPanel
leftActions = {
2026-07-02 09:29:39 +00:00
< Button type = "primary" icon = { < PlusOutlined / > } onClick = { openCreate } >
新 增 应 用
< / Button >
2026-06-30 05:37:33 +00:00
}
rightActions = {
< Space wrap >
< Input placeholder = "搜索名称、描述、包名或入口地址" prefix = { < SearchOutlined / > } allowClear style = { { width : 300 } } value = { searchValue } onChange = { ( event ) = > setSearchValue ( event . target . value ) } / >
< Select value = { appTypeFilter } style = { { width : 130 } } onChange = { ( value ) = > setAppTypeFilter ( value ) } options = { [ { label : "全部类型" , value : "all" } , { label : "原生应用" , value : "native" } , { label : "Web 应用" , value : "web" } ] } / >
< Select value = { statusFilter } style = { { width : 130 } } onChange = { ( value ) = > setStatusFilter ( value as typeof statusFilter ) } options = { STATUS_OPTIONS as unknown as { label : string ; value : string } [ ] } / >
< Button icon = { < ReloadOutlined / > } onClick = { ( ) = > void loadData ( ) } loading = { loading } >
刷 新
< / Button >
< / Space >
}
footer = {
< AppPagination
current = { page }
pageSize = { pageSize }
total = { filteredRecords . length }
onChange = { ( nextPage , nextSize ) = > {
setPage ( nextPage ) ;
setPageSize ( nextSize ) ;
} }
/ >
}
>
< ListTable < ExternalAppVO >
rowKey = "id"
columns = { columns }
dataSource = { pagedRecords }
loading = { loading }
2026-07-07 01:13:19 +00:00
scroll = { { x : "max(100%, 1000px)" , y : "100%" } }
2026-06-30 05:37:33 +00:00
pagination = { false }
/ >
< / DataListPanel >
< / SectionCard >
2026-04-13 12:21:08 +00:00
< 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" >
2026-07-06 03:08:36 +00:00
< Row gutter = { 16 } className = "app-responsive-form-row" >
< Col xs = { 24 } md = { 16 } > < Form.Item name = "appName" label = "应用名称" rules = { [ { required : true , message : "请输入应用名称" } ] } > < Input placeholder = "请输入应用名称" / > < / Form.Item > < / Col >
< Col xs = { 24 } md = { 8 } > < Form.Item name = "appType" label = "应用类型" rules = { [ { required : true , message : "请选择应用类型" } ] } > < Select options = { [ { label : "原生应用" , value : "native" } , { label : "Web 应用" , value : "web" } ] } / > < / Form.Item > < / Col >
2026-04-13 12:21:08 +00:00
< / Row >
2026-07-01 14:00:19 +00:00
< Form.Item name = "iconUrl" hidden >
< Input / >
< / Form.Item >
< Form.Item label = "应用图标" className = "external-app-icon-form-item" >
< Upload
2026-07-02 09:29:39 +00:00
accept = { ICON_UPLOAD_ACCEPT }
2026-07-01 14:00:19 +00:00
showUploadList = { false }
2026-07-02 09:29:39 +00:00
beforeUpload = { ( file ) = > beforeUploadIcon ( file as File ) }
2026-07-01 14:00:19 +00:00
>
< button
type = "button"
className = { watchedIconUrl ? "external-app-icon-picker has-image" : "external-app-icon-picker" }
disabled = { uploadingIcon }
aria - label = "上传应用图标"
>
< span className = "external-app-icon-picker__preview" >
{ watchedIconUrl ? (
< Avatar shape = "square" size = { 72 } src = { watchedIconUrl } icon = { < PictureOutlined / > } / >
) : (
< PictureOutlined / >
) }
< / span >
< span className = "external-app-icon-picker__text" >
{ uploadingIcon ? "上传中..." : watchedIconUrl ? "更换图标" : "点击上传图标" }
< / span >
2026-07-02 09:29:39 +00:00
< span className = "external-app-icon-picker__hint" > PNG / JPG < / span >
2026-07-01 14:00:19 +00:00
< / button >
2026-04-13 12:21:08 +00:00
< / Upload >
2026-07-01 14:00:19 +00:00
< / Form.Item >
2026-04-13 12:21:08 +00:00
< 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 >
2026-07-06 03:08:36 +00:00
< Row gutter = { 16 } className = "app-responsive-form-row" >
< Col xs = { 24 } md = { 12 } > < Form.Item name = { [ "appInfo" , "versionName" ] } label = "版本号" > < Input placeholder = "如 1.0.0" / > < / Form.Item > < / Col >
< Col xs = { 24 } md = { 12 } > < Form.Item name = { [ "appInfo" , "packageName" ] } label = "包名" > < Input placeholder = "com.example.app" / > < / Form.Item > < / Col >
2026-04-13 12:21:08 +00:00
< / Row >
< Form.Item name = { [ "appInfo" , "apkUrl" ] } label = "APK 地址" rules = { [ { required : true , message : "请上传或填写 APK 地址" } ] } > < Input prefix = { < LinkOutlined / > } placeholder = "https://..." / > < / Form.Item >
< / >
) : (
< >
2026-07-06 03:08:36 +00:00
< Row gutter = { 16 } className = "app-responsive-form-row" >
< Col xs = { 24 } md = { 12 } > < Form.Item name = { [ "appInfo" , "versionName" ] } label = "版本号" > < Input placeholder = "可选" / > < / Form.Item > < / Col >
< Col xs = { 24 } md = { 12 } > < Form.Item name = { [ "appInfo" , "webUrl" ] } label = "Web 地址" rules = { [ { required : true , message : "请输入 Web 地址" } ] } > < Input prefix = { < GlobalOutlined / > } placeholder = "https://..." / > < / Form.Item > < / Col >
2026-04-13 12:21:08 +00:00
< / Row >
< / >
) }
< / Form.Item >
< Form.Item name = "description" label = "应用描述" > < TextArea rows = { 3 } placeholder = "请输入应用用途说明" / > < / Form.Item >
2026-07-06 03:08:36 +00:00
< Row gutter = { 16 } className = "app-responsive-form-row" >
< Col xs = { 24 } md = { 12 } > < Form.Item name = "sortOrder" label = "排序权重" > < InputNumber min = { 0 } style = { { width : "100%" } } / > < / Form.Item > < / Col >
< Col xs = { 24 } md = { 12 } > < Form.Item name = "remark" label = "备注" > < Input placeholder = "选填" / > < / Form.Item > < / Col >
2026-04-13 12:21:08 +00:00
< / Row >
< Form.Item name = "statusEnabled" label = "启用状态" valuePropName = "checked" > < Switch / > < / Form.Item >
< / Form >
< / Drawer >
2026-05-09 02:17:46 +00:00
< / PageContainer >
2026-04-13 12:21:08 +00:00
) ;
}