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

385 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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>
);
}