|
@@ -0,0 +1,304 @@
|
|
|
|
|
+import { useCallback, useEffect, useState } from "react";
|
|
|
|
|
+import { useTranslation } from "react-i18next";
|
|
|
|
|
+import {
|
|
|
|
|
+ Typography,
|
|
|
|
|
+ Button,
|
|
|
|
|
+ Table,
|
|
|
|
|
+ Popconfirm,
|
|
|
|
|
+ message,
|
|
|
|
|
+ Card,
|
|
|
|
|
+ Modal,
|
|
|
|
|
+ Form,
|
|
|
|
|
+ Input,
|
|
|
|
|
+ Space,
|
|
|
|
|
+} from "antd";
|
|
|
|
|
+import type { TableProps } from "antd";
|
|
|
|
|
+import {
|
|
|
|
|
+ PlusOutlined,
|
|
|
|
|
+ EditOutlined,
|
|
|
|
|
+ DeleteOutlined,
|
|
|
|
|
+ ReloadOutlined,
|
|
|
|
|
+ SearchOutlined,
|
|
|
|
|
+} from "@ant-design/icons";
|
|
|
|
|
+import type { ConfigForm, ConfigItem } from "@/api/config";
|
|
|
|
|
+import {
|
|
|
|
|
+ addConfig,
|
|
|
|
|
+ deleteConfig,
|
|
|
|
|
+ getConfig,
|
|
|
|
|
+ getConfigPage,
|
|
|
|
|
+ updateConfig,
|
|
|
|
|
+} from "@/api/config";
|
|
|
|
|
+
|
|
|
|
|
+const { Title, Text } = Typography;
|
|
|
|
|
+const { TextArea } = Input;
|
|
|
|
|
+
|
|
|
|
|
+export function ConfigPage() {
|
|
|
|
|
+ const { t } = useTranslation();
|
|
|
|
|
+ const [data, setData] = useState<ConfigItem[]>([]);
|
|
|
|
|
+ const [loading, setLoading] = useState(false);
|
|
|
|
|
+ const [submitting, setSubmitting] = useState(false);
|
|
|
|
|
+ const [modalOpen, setModalOpen] = useState(false);
|
|
|
|
|
+ const [editingKey, setEditingKey] = useState<string | null>(null);
|
|
|
|
|
+ const [total, setTotal] = useState(0);
|
|
|
|
|
+ const [keyword, setKeyword] = useState("");
|
|
|
|
|
+ const [query, setQuery] = useState({ page: 1, size: 10, keyword: undefined as string | undefined });
|
|
|
|
|
+ const [form] = Form.useForm<ConfigForm>();
|
|
|
|
|
+
|
|
|
|
|
+ const fetchData = useCallback(async () => {
|
|
|
|
|
+ setLoading(true);
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await getConfigPage(query);
|
|
|
|
|
+ setData(res.records);
|
|
|
|
|
+ setTotal(res.total);
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // handled by request util
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setLoading(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [query]);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ fetchData();
|
|
|
|
|
+ }, [fetchData]);
|
|
|
|
|
+
|
|
|
|
|
+ function handleSearch() {
|
|
|
|
|
+ setQuery((prev) => ({
|
|
|
|
|
+ ...prev,
|
|
|
|
|
+ page: 1,
|
|
|
|
|
+ keyword: keyword.trim() || undefined,
|
|
|
|
|
+ }));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function handleReset() {
|
|
|
|
|
+ setKeyword("");
|
|
|
|
|
+ setQuery({ page: 1, size: 10, keyword: undefined });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function openAdd() {
|
|
|
|
|
+ setEditingKey(null);
|
|
|
|
|
+ form.resetFields();
|
|
|
|
|
+ setModalOpen(true);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function openEdit(record: ConfigItem) {
|
|
|
|
|
+ setEditingKey(record.configKey);
|
|
|
|
|
+ setModalOpen(true);
|
|
|
|
|
+ try {
|
|
|
|
|
+ const detail = await getConfig(record.configKey);
|
|
|
|
|
+ form.setFieldsValue({
|
|
|
|
|
+ configKey: detail.configKey,
|
|
|
|
|
+ configValue: detail.configValue,
|
|
|
|
|
+ remark: detail.remark,
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ setModalOpen(false);
|
|
|
|
|
+ setEditingKey(null);
|
|
|
|
|
+ message.error(t("config.fetchFailed"));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function handleSubmit() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const values = await form.validateFields();
|
|
|
|
|
+ setSubmitting(true);
|
|
|
|
|
+ if (editingKey) {
|
|
|
|
|
+ await updateConfig(values);
|
|
|
|
|
+ message.success(t("config.updateSuccess"));
|
|
|
|
|
+ } else {
|
|
|
|
|
+ await addConfig(values);
|
|
|
|
|
+ message.success(t("config.addSuccess"));
|
|
|
|
|
+ }
|
|
|
|
|
+ setModalOpen(false);
|
|
|
|
|
+ setEditingKey(null);
|
|
|
|
|
+ form.resetFields();
|
|
|
|
|
+ fetchData();
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // validation or request error
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setSubmitting(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function handleDelete(configKey: string) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await deleteConfig(configKey);
|
|
|
|
|
+ message.success(t("config.deleteSuccess"));
|
|
|
|
|
+ fetchData();
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // handled by request util
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const columns: TableProps<ConfigItem>["columns"] = [
|
|
|
|
|
+ {
|
|
|
|
|
+ title: t("config.colKey"),
|
|
|
|
|
+ dataIndex: "configKey",
|
|
|
|
|
+ width: 220,
|
|
|
|
|
+ fixed: "left",
|
|
|
|
|
+ render: (val: string) => <span className="font-mono text-xs">{val}</span>,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: t("config.colValue"),
|
|
|
|
|
+ dataIndex: "configValue",
|
|
|
|
|
+ width: 320,
|
|
|
|
|
+ render: (val?: string) => (
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="text-sm"
|
|
|
|
|
+ style={{
|
|
|
|
|
+ lineHeight: "20px",
|
|
|
|
|
+ whiteSpace: "pre-wrap",
|
|
|
|
|
+ wordBreak: "break-word",
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {val || "—"}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: t("config.colRemark"),
|
|
|
|
|
+ dataIndex: "remark",
|
|
|
|
|
+ width: 240,
|
|
|
|
|
+ render: (val?: string) => <span>{val || "—"}</span>,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: t("config.colCreateTime"),
|
|
|
|
|
+ dataIndex: "createTime",
|
|
|
|
|
+ width: 170,
|
|
|
|
|
+ render: (val?: string) => val || "—",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: t("config.colUpdateTime"),
|
|
|
|
|
+ dataIndex: "updateTime",
|
|
|
|
|
+ width: 170,
|
|
|
|
|
+ render: (val?: string) => val || "—",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: t("config.colActions"),
|
|
|
|
|
+ key: "actions",
|
|
|
|
|
+ width: 140,
|
|
|
|
|
+ fixed: "right",
|
|
|
|
|
+ align: "center",
|
|
|
|
|
+ render: (_, record) => (
|
|
|
|
|
+ <Space size={0}>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ type="link"
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ icon={<EditOutlined />}
|
|
|
|
|
+ onClick={() => openEdit(record)}
|
|
|
|
|
+ >
|
|
|
|
|
+ {t("common.edit")}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Popconfirm
|
|
|
|
|
+ title={t("config.deleteConfirm")}
|
|
|
|
|
+ onConfirm={() => handleDelete(record.configKey)}
|
|
|
|
|
+ okText={t("common.confirm")}
|
|
|
|
|
+ cancelText={t("common.cancel")}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
|
|
|
|
+ {t("common.delete")}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Popconfirm>
|
|
|
|
|
+ </Space>
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div className="flex items-center justify-between mb-4">
|
|
|
|
|
+ <Title level={4} style={{ margin: 0 }}>
|
|
|
|
|
+ {t("config.title")}
|
|
|
|
|
+ </Title>
|
|
|
|
|
+ <Space>
|
|
|
|
|
+ <Button icon={<ReloadOutlined />} onClick={fetchData}>
|
|
|
|
|
+ {t("config.refresh")}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
|
|
|
|
|
+ {t("config.addBtn")}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Space>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <Card style={{ marginBottom: 16 }}>
|
|
|
|
|
+ <div className="flex flex-wrap items-center gap-3">
|
|
|
|
|
+ <Input
|
|
|
|
|
+ placeholder={t("config.searchPlaceholder")}
|
|
|
|
|
+ prefix={<SearchOutlined />}
|
|
|
|
|
+ value={keyword}
|
|
|
|
|
+ onChange={(e) => setKeyword(e.target.value)}
|
|
|
|
|
+ onPressEnter={handleSearch}
|
|
|
|
|
+ style={{ width: 320 }}
|
|
|
|
|
+ allowClear
|
|
|
|
|
+ />
|
|
|
|
|
+ <Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
|
|
|
|
|
+ {t("common.search")}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button icon={<ReloadOutlined />} onClick={handleReset}>
|
|
|
|
|
+ {t("config.reset")}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <Text type="secondary" style={{ display: "block", marginTop: 12, fontSize: 12 }}>
|
|
|
|
|
+ {t("config.searchTip")}
|
|
|
|
|
+ </Text>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+
|
|
|
|
|
+ <Card>
|
|
|
|
|
+ <Table<ConfigItem>
|
|
|
|
|
+ rowKey="configKey"
|
|
|
|
|
+ columns={columns}
|
|
|
|
|
+ dataSource={data}
|
|
|
|
|
+ loading={loading}
|
|
|
|
|
+ scroll={{ x: 1200 }}
|
|
|
|
|
+ size="middle"
|
|
|
|
|
+ pagination={{
|
|
|
|
|
+ current: query.page,
|
|
|
|
|
+ pageSize: query.size,
|
|
|
|
|
+ total,
|
|
|
|
|
+ showSizeChanger: true,
|
|
|
|
|
+ showQuickJumper: true,
|
|
|
|
|
+ showTotal: (count) => t("common.paginationTotal", { total: count }),
|
|
|
|
|
+ onChange: (page, size) => setQuery((prev) => ({ ...prev, page, size })),
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Card>
|
|
|
|
|
+
|
|
|
|
|
+ <Modal
|
|
|
|
|
+ open={modalOpen}
|
|
|
|
|
+ title={editingKey ? t("config.editTitle") : t("config.addTitle")}
|
|
|
|
|
+ onCancel={() => {
|
|
|
|
|
+ setModalOpen(false);
|
|
|
|
|
+ setEditingKey(null);
|
|
|
|
|
+ form.resetFields();
|
|
|
|
|
+ }}
|
|
|
|
|
+ onOk={handleSubmit}
|
|
|
|
|
+ confirmLoading={submitting}
|
|
|
|
|
+ destroyOnClose
|
|
|
|
|
+ >
|
|
|
|
|
+ <Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="configKey"
|
|
|
|
|
+ label={t("config.fieldKey")}
|
|
|
|
|
+ rules={[{ required: true, message: t("config.required") }]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Input
|
|
|
|
|
+ placeholder={t("config.keyPlaceholder")}
|
|
|
|
|
+ disabled={!!editingKey}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item name="configValue" label={t("config.fieldValue")}>
|
|
|
|
|
+ <TextArea
|
|
|
|
|
+ rows={4}
|
|
|
|
|
+ placeholder={t("config.valuePlaceholder")}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item name="remark" label={t("config.fieldRemark")}>
|
|
|
|
|
+ <TextArea
|
|
|
|
|
+ rows={3}
|
|
|
|
|
+ placeholder={t("config.remarkPlaceholder")}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </Form>
|
|
|
|
|
+ </Modal>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|