import { App, Avatar, Button, Card, Col, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Switch, Table, Tag, Typography, Upload } from "antd"; 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"; import AppPagination from "@/components/shared/AppPagination"; 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; function normalizeAppInfo(value: ExternalAppVO["appInfo"]): NonNullable { if (!value || typeof value !== "object") { return {}; } const info = value as Record; 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>(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(); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); const [editing, setEditing] = useState(null); const [records, setRecords] = useState([]); 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) => { await deleteExternalApp(record.id); 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); } }; 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 = [ { title: "应用", key: "app", width: 260, render: (_, record) => ( } /> {record.appName} {record.description || "暂无描述"} ), }, { title: "类型", dataIndex: "appType", key: "appType", width: 120, render: (value: ExternalAppVO["appType"]) => value === "native" ? }>原生应用 : }>Web 应用, }, { title: "入口信息", key: "entry", width: 260, render: (_, record) => { const info = normalizeAppInfo(record.appInfo); return ( 版本:{info.versionName || "-"} {record.appType === "native" ? `包名:${info.packageName || "-"}` : `地址:${info.webUrl || "-"}`} ); }, }, { title: "状态", key: "status", width: 120, render: (_, record) => 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 ( } />
应用总数
{stats.total}
原生应用
{stats.native}
Web 应用
{stats.web}
已启用
{stats.enabled}
} allowClear style={{ width: 320 }} value={searchValue} onChange={(event) => setSearchValue(event.target.value)} /> setStatusFilter(value as typeof statusFilter)} options={STATUS_OPTIONS as unknown as { label: string; value: string }[]} />
{ setPage(nextPage); setPageSize(nextSize); }} /> setDrawerOpen(false)} width={700} destroyOnHidden forceRender footer={
}>
prev.appType !== curr.appType}> {({ getFieldValue }) => getFieldValue("appType") === "native" ? ( <> { void handleUploadApk(file as File); return Upload.LIST_IGNORE; }}> } placeholder="https://..." /> ) : ( <> } placeholder="https://..." /> )}