imeeting/frontend/src/pages/business/ExternalAppManagement.tsx

385 lines
16 KiB
TypeScript
Raw Normal View History

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<ExternalAppFormValues["appInfo"]> {
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) => {
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<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" } }}>
<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} />
</div>
<AppPagination
current={page}
pageSize={pageSize}
total={filteredRecords.length}
onChange={(nextPage, nextSize) => { setPage(nextPage); setPageSize(nextSize); }}
/>
</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>
);
}