385 lines
16 KiB
TypeScript
385 lines
16 KiB
TypeScript
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>
|
||
);
|
||
}
|