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

440 lines
18 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, Col, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Switch, 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 PageContainer from "@/components/shared/PageContainer";
import DataListPanel from "@/components/shared/DataListPanel";
import ListTable from "@/components/shared/ListTable/ListTable";
import AppPagination from "@/components/shared/AppPagination";
import SectionCard from "@/components/shared/SectionCard";
import SummaryStatCards from "@/components/shared/SummaryStatCards";
import { createExternalApp, deleteExternalApp, listExternalApps, type ExternalAppDTO, type ExternalAppVO, updateExternalApp, uploadExternalAppApk, uploadExternalAppIcon } from "@/api/business/externalApp";
import "./ExternalAppManagement.css";
const { Text } = Typography;
const { TextArea } = Input;
const ICON_UPLOAD_ACCEPT = ".png,.jpg,.jpeg,image/png,image/jpeg";
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 watchedIconUrl = Form.useWatch("iconUrl", form);
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 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]);
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 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;
};
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} aria-label="打开应用入口" title="打开应用入口" />
<Button type="text" icon={<EditOutlined />} onClick={() => openEdit(record)} aria-label="编辑外部应用" title="编辑外部应用" />
<Popconfirm title="确认删除该应用吗?" onConfirm={() => void handleDelete(record)}>
<Button type="text" danger icon={<DeleteOutlined />} aria-label="删除外部应用" title="删除外部应用" />
</Popconfirm>
</Space>
);
},
},
];
return (
<PageContainer title={null} className="external-app-page">
<SectionCard
title="外部应用管理"
description="统一维护首页九宫格与抽屉入口中的原生应用、Web 服务和应用图标资源。"
contentClassName="external-app-page__content"
>
<SummaryStatCards items={statCards} ariaLabel="外部应用统计" />
<DataListPanel
leftActions={
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
}
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}
scroll={{ x: "max(100%, 1000px)", y: "100%" }}
pagination={false}
/>
</DataListPanel>
</SectionCard>
<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} 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>
</Row>
<Form.Item name="iconUrl" hidden>
<Input />
</Form.Item>
<Form.Item label="应用图标" className="external-app-icon-form-item">
<Upload
accept={ICON_UPLOAD_ACCEPT}
showUploadList={false}
beforeUpload={(file) => beforeUploadIcon(file as File)}
>
<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>
<span className="external-app-icon-picker__hint">PNG / JPG</span>
</button>
</Upload>
</Form.Item>
<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} 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>
</Row>
<Form.Item name={["appInfo", "apkUrl"]} label="APK 地址" rules={[{ required: true, message: "请上传或填写 APK 地址" }]}><Input prefix={<LinkOutlined />} placeholder="https://..." /></Form.Item>
</>
) : (
<>
<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>
</Row>
</>
)}
</Form.Item>
<Form.Item name="description" label="应用描述"><TextArea rows={3} placeholder="请输入应用用途说明" /></Form.Item>
<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>
</Row>
<Form.Item name="statusEnabled" label="启用状态" valuePropName="checked"><Switch /></Form.Item>
</Form>
</Drawer>
</PageContainer>
);
}