提交 da1425d0 authored 作者: 王鹏飞's avatar 王鹏飞

refactor(admin): migrate members level module

上级 81a95844
# Members Level Migration Report
## Status
- Migrated the active `/member/userlevel` route to `src/modules/members/level`.
- Kept the public URL unchanged and added a private nested route boundary.
- Replaced the legacy table/filter/pagination shell with the shared `AppList` contract.
- Kept the legacy page and request module because deprecated route metadata still references the page.
## Preserved behavior
- List request: `POST /members/grade/getList` with form-encoded `page`, `page_size`, optional `name`, `start_time`, and `end_time`.
- Detail/add/edit/delete requests:
- `POST /members/grade/getInfoById`
- `POST /members/grade/add`
- `POST /members/grade/edit`
- `POST /members/grade/del`
- Validity requests retain `type: 2`:
- `POST /system/config/getSystem`
- `POST /system/config/systemEdit`
- Server success/error messages and the fallback error text `数据获取失败!` are retained.
- Filters retain level name and local-day creation-time boundaries. Clearing a populated filter reloads immediately.
- Columns retain ID, level name, qualifying amount, creation time, update time, and operations.
- Add/edit validation retains the 30-character name limit and non-negative integer amount pattern.
- Delete confirmation retains `等级将永久删除,是否继续?`.
- Mutations refresh the current page without applying the draft filters, matching the legacy `init()` behavior.
- The source page has no sorting or status-changing operation to migrate.
## Permissions
- `/member/userlevel/getInfoById`: edit action
- `/member/userlevel/del`: delete action
- `/member/userlevel/add`: add action
- `/member/userlevel/getSystem`: load/show validity setting
- `/member/userlevel/systemEdit`: submit/cancel controls in the validity drawer
## Structure
- `api.ts`: form-encoded API calls and server notifications.
- `query.ts`: list filter-to-query conversion.
- `useMemberLevelList.ts`: controlled draft/applied filters, pagination, loading, retry, and stale-request protection.
- `routes.tsx`: private member-level route boundary.
- `views/MemberLevelListView.tsx`: list composition and action coordination.
- `components/MemberLevelFormDrawer.tsx`: add/edit form.
- `components/DeleteMemberLevelModal.tsx`: delete confirmation.
- `components/ValidityDrawer.tsx`: user-level validity setting.
## Verification
- `pnpm exec oxlint src/modules/members/level src/modules/members/routes.tsx --deny-warnings`: passed.
- `pnpm exec oxfmt --check src/modules/members/level src/modules/members/routes.tsx`: passed.
- `pnpm test`: passed, 11 tests.
- `pnpm build`: passed; only the existing formula-editor and large-chunk warnings were emitted.
- `pnpm exec tsc --noEmit`: no diagnostics under `src/modules/members/level` or `src/modules/members/routes.tsx`; the workspace still has unrelated existing diagnostics.
## Concerns
- Per task direction, no API/query/Hook feature tests were added.
- `src/routes/routes.jsx` still imports `@/pages/member/userlevel/index`, and `src/config/component.js` still names `member/userlevel/index`. Both are outside this task's allowed scope. Therefore the legacy `src/pages/member/userlevel/index.jsx` and its sibling `request.js` were intentionally retained instead of deleting a still-referenced page.
import { message } from "antd";
// @ts-expect-error The legacy workspace does not install the qs declaration package.
import qs from "qs";
import request from "@/utils/request.js";
import type { MemberLevelQuery } from "./query";
export interface MemberLevelRow {
id: number;
name?: string;
consumption?: number | string;
create_time?: string;
update_time?: string;
}
export interface MemberLevelListResult {
rows: MemberLevelRow[];
total: number;
}
export interface MemberLevelFormValues {
name: string;
consumption: string;
}
export interface MemberLevelSystemConfig {
set_value?: string | number;
}
type ApiParams = Record<string, unknown>;
async function getPayload<T>(url: string, params: ApiParams): Promise<T | false> {
try {
const response = await request.post(url, qs.stringify(params));
const body = response.data as { code?: number; data?: T; message?: string };
const code = body?.code ?? 0;
const data = (body?.data ?? []) as T;
const text = body?.message ?? "数据获取失败!";
if (code === 200) return data;
message.error(text);
return false;
} catch {
return false;
}
}
async function mutate(url: string, params: ApiParams) {
try {
const response = await request.post(url, qs.stringify(params));
const body = response.data as { code?: number; message?: string };
const code = body?.code ?? 0;
const text = body?.message ?? "数据获取失败!";
if (code === 200) {
message.success(text);
return true;
}
message.error(text);
return false;
} catch {
return false;
}
}
export async function getMemberLevelList(
params: MemberLevelQuery,
): Promise<MemberLevelListResult | false> {
const data = await getPayload<{ list?: MemberLevelRow[]; total?: number }>(
"/members/grade/getList",
{ ...params },
);
if (data === false) return false;
return { rows: data.list ?? [], total: data.total ?? 0 };
}
export function getMemberLevelInfo(id: number) {
return getPayload<MemberLevelRow>("/members/grade/getInfoById", { id });
}
export function addMemberLevel(values: MemberLevelFormValues) {
return mutate("/members/grade/add", { ...values });
}
export function editMemberLevel(id: number, values: MemberLevelFormValues) {
return mutate("/members/grade/edit", { ...values, id });
}
export function deleteMemberLevel(id: number) {
return mutate("/members/grade/del", { id });
}
export function getMemberLevelSystem() {
return getPayload<MemberLevelSystemConfig>("/system/config/getSystem", { type: 2 });
}
export function editMemberLevelSystem(setValue: string | number | undefined) {
return mutate("/system/config/systemEdit", { type: 2, set_value: setValue });
}
import { Modal, Row, Space } from "antd";
interface DeleteMemberLevelModalProps {
open: boolean;
onCancel: () => void;
onConfirm: () => void | Promise<void>;
}
export default function DeleteMemberLevelModal({
open,
onCancel,
onConfirm,
}: DeleteMemberLevelModalProps) {
return (
<Modal
mask={false}
centered
open={open}
onOk={onConfirm}
onCancel={onCancel}
footer={(_, { OkBtn, CancelBtn }) => (
<Row justify="center" className="delModal" style={{ marginBottom: "30px" }}>
<Space size={20}>
<CancelBtn />
<OkBtn />
</Space>
</Row>
)}
okText="确认"
>
<p style={{ textAlign: "center", padding: "30px 0 18px", fontSize: 16 }}>
等级将永久删除,是否继续?
</p>
</Modal>
);
}
import { Button, Drawer, Form, Input, Space } from "antd";
import { useEffect } from "react";
import type { MemberLevelFormValues } from "../api";
interface MemberLevelFormDrawerProps {
open: boolean;
initialValues: Partial<MemberLevelFormValues> | null;
onClose: () => void;
onSubmit: (values: MemberLevelFormValues) => void | Promise<void>;
}
export default function MemberLevelFormDrawer({
open,
initialValues,
onClose,
onSubmit,
}: MemberLevelFormDrawerProps) {
const [form] = Form.useForm<MemberLevelFormValues>();
useEffect(() => {
if (!open) {
form.resetFields();
return;
}
form.setFieldsValue({ name: "", consumption: "", ...initialValues });
}, [form, initialValues, open]);
return (
<Drawer onClose={onClose} open={open} footer={null} mask={false} keyboard={false}>
<Form
form={form}
onFinish={onSubmit}
labelCol={{ span: 8 }}
wrapperCol={{ span: 12 }}
style={{ marginBottom: "-30px" }}
>
<Form.Item
label="等级名称"
name="name"
rules={[
{ required: true, message: "请输入等级名称" },
{ max: 30, message: "最大30字符" },
]}
>
<Input autoComplete="off" placeholder="请输入等级名称" />
</Form.Item>
<Form.Item
label="达标金额"
name="consumption"
rules={[
{ required: true, message: "请输入达标金额" },
{ pattern: /^[0-9]\d*$/, message: "必须是正整数" },
]}
>
<Input autoComplete="off" placeholder="请输入达标金额" />
</Form.Item>
<Form.Item wrapperCol={{ offset: 9, span: 16 }}>
<Space size={20}>
<Button onClick={onClose}>取消</Button>
<Button type="primary" htmlType="submit">
提交
</Button>
</Space>
</Form.Item>
</Form>
</Drawer>
);
}
import { Button, Col, Drawer, Form, Input, Row, Space } from "antd";
import { useEffect } from "react";
export interface ValidityFormValues {
set_value?: string | number;
}
interface ValidityDrawerProps {
open: boolean;
value?: string | number;
canEdit: boolean;
onClose: () => void;
onSubmit: (values: ValidityFormValues) => void | Promise<void>;
}
export default function ValidityDrawer({
open,
value,
canEdit,
onClose,
onSubmit,
}: ValidityDrawerProps) {
const [form] = Form.useForm<ValidityFormValues>();
useEffect(() => {
if (open) form.setFieldsValue({ set_value: value });
}, [form, open, value]);
return (
<Drawer onClose={onClose} open={open} footer={null} mask={false} keyboard={false}>
<Row align="middle" justify="center">
<Col span={20}>
<Form form={form} onFinish={onSubmit} labelCol={{ span: 9 }} wrapperCol={{ span: 26 }}>
<Form.Item label="用户等级有效期(月)" name="set_value" style={{ marginBottom: 10 }}>
<Input
autoComplete="off"
style={{ width: "100%", marginRight: 10 }}
placeholder="请输入用户等级有效期"
/>
</Form.Item>
<Form.Item wrapperCol={{ offset: 0, span: 26 }}>
<div style={{ color: "#999999", fontSize: 12 }}>
用于计算用户等级,根据当前时间之前有效期内的消费金额总和来计算 (1-24正整数)
</div>
</Form.Item>
<Form.Item wrapperCol={{ offset: 9, span: 26 }}>
{canEdit ? (
<Space size={20}>
<Button onClick={onClose}>取消</Button>
<Button type="primary" htmlType="submit">
提交
</Button>
</Space>
) : null}
</Form.Item>
</Form>
</Col>
</Row>
</Drawer>
);
}
import type { Dayjs } from "dayjs";
export interface MemberLevelFilters {
name?: string;
time?: [Dayjs | null, Dayjs | null] | null;
}
export interface MemberLevelQuery {
page: number;
page_size: number;
name?: string;
start_time?: number;
end_time?: number;
}
interface BuildMemberLevelQueryInput {
page: number;
pageSize: number;
filters: MemberLevelFilters;
}
export function buildMemberLevelQuery({
page,
pageSize,
filters,
}: BuildMemberLevelQueryInput): MemberLevelQuery {
const query: MemberLevelQuery = { page, page_size: pageSize };
if (filters.name) query.name = filters.name;
if (filters.time?.[0] && filters.time[1]) {
query.start_time = filters.time[0].startOf("day").unix();
query.end_time = filters.time[1].endOf("day").unix();
}
return query;
}
import { Navigate, useRoutes } from "react-router";
import MemberLevelListView from "./views/MemberLevelListView";
export default function MemberLevelRoutes() {
return useRoutes([
{ index: true, Component: MemberLevelListView },
{ path: "query", Component: MemberLevelListView },
{ path: "*", element: <Navigate to="/member/userlevel" /> },
]);
}
import { useCallback, useEffect, useRef, useState } from "react";
import { getMemberLevelList, type MemberLevelListResult, type MemberLevelRow } from "./api";
import { buildMemberLevelQuery, type MemberLevelFilters, type MemberLevelQuery } from "./query";
const INITIAL_PAGE = 1;
const INITIAL_PAGE_SIZE = 10;
function isCleared(previous: MemberLevelFilters, next: MemberLevelFilters) {
const keys = new Set([...Object.keys(previous), ...Object.keys(next)]) as Set<
keyof MemberLevelFilters
>;
return [...keys].some((key) => previous[key] !== next[key] && !next[key]);
}
export function useMemberLevelList() {
const [filters, setFilterState] = useState<MemberLevelFilters>({});
const [appliedFilters, setAppliedFilters] = useState<MemberLevelFilters>({});
const [page, setPage] = useState(INITIAL_PAGE);
const [pageSize, setPageSize] = useState(INITIAL_PAGE_SIZE);
const [rows, setRows] = useState<MemberLevelRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const requestId = useRef(0);
const lastQuery = useRef<MemberLevelQuery>(
buildMemberLevelQuery({ page: INITIAL_PAGE, pageSize: INITIAL_PAGE_SIZE, filters: {} }),
);
const load = useCallback(async (query: MemberLevelQuery) => {
const currentRequest = ++requestId.current;
lastQuery.current = query;
setLoading(true);
setError(null);
try {
const result: MemberLevelListResult | false = await getMemberLevelList(query);
if (currentRequest !== requestId.current) return;
if (result === false) {
setError(new Error("数据获取失败!"));
return;
}
setRows(result.rows);
setTotal(result.total);
} catch (reason) {
if (currentRequest === requestId.current) {
setError(reason instanceof Error ? reason : new Error("数据获取失败!"));
}
} finally {
if (currentRequest === requestId.current) setLoading(false);
}
}, []);
const loadFor = useCallback(
(nextPage: number, nextPageSize: number, nextFilters: MemberLevelFilters) =>
load(buildMemberLevelQuery({ page: nextPage, pageSize: nextPageSize, filters: nextFilters })),
[load],
);
useEffect(() => {
void load(lastQuery.current);
return () => {
requestId.current += 1;
};
}, [load]);
const setFilters = useCallback(
(nextFilters: MemberLevelFilters) => {
const shouldReload = isCleared(filters, nextFilters);
setFilterState(nextFilters);
if (shouldReload) {
setAppliedFilters(nextFilters);
void loadFor(page, pageSize, nextFilters);
}
},
[filters, loadFor, page, pageSize],
);
const search = useCallback(() => {
setAppliedFilters(filters);
setPage(INITIAL_PAGE);
void loadFor(INITIAL_PAGE, pageSize, filters);
}, [filters, loadFor, pageSize]);
const reset = useCallback(() => {
const emptyFilters: MemberLevelFilters = {};
setFilterState(emptyFilters);
setAppliedFilters(emptyFilters);
setPage(INITIAL_PAGE);
void loadFor(INITIAL_PAGE, pageSize, emptyFilters);
}, [loadFor, pageSize]);
const refresh = useCallback(() => {
setAppliedFilters(filters);
void loadFor(page, pageSize, filters);
}, [filters, loadFor, page, pageSize]);
const refreshUnfiltered = useCallback(() => {
void loadFor(page, pageSize, {});
}, [loadFor, page, pageSize]);
const changePage = useCallback(
(nextPage: number) => {
setPage(nextPage);
void loadFor(nextPage, pageSize, appliedFilters);
},
[appliedFilters, loadFor, pageSize],
);
const changePageSize = useCallback(
(nextPageSize: number) => {
setPage(INITIAL_PAGE);
setPageSize(nextPageSize);
void loadFor(INITIAL_PAGE, nextPageSize, appliedFilters);
},
[appliedFilters, loadFor],
);
const retry = useCallback(() => void load(lastQuery.current), [load]);
return {
filters,
setFilters,
page,
pageSize,
rows,
total,
loading,
error,
search,
reset,
refresh,
refreshUnfiltered,
changePage,
changePageSize,
retry,
};
}
import { Button, DatePicker, Input, Space } from "antd";
import type { ColumnsType } from "antd/es/table";
import { useCallback, useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList";
import {
addMemberLevel,
deleteMemberLevel,
editMemberLevel,
editMemberLevelSystem,
getMemberLevelInfo,
getMemberLevelSystem,
type MemberLevelFormValues,
type MemberLevelRow,
} from "../api";
import DeleteMemberLevelModal from "../components/DeleteMemberLevelModal";
import MemberLevelFormDrawer from "../components/MemberLevelFormDrawer";
import ValidityDrawer, { type ValidityFormValues } from "../components/ValidityDrawer";
import type { MemberLevelFilters } from "../query";
import { useMemberLevelList } from "../useMemberLevelList";
interface RootState {
user: {
operationPermissionsList?: string[];
};
}
const { RangePicker } = DatePicker;
export default function MemberLevelListView() {
const list = useMemberLevelList();
const permissions = useSelector((state: RootState) => state.user.operationPermissionsList ?? []);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [isAdd, setIsAdd] = useState(false);
const [initialValues, setInitialValues] = useState<Partial<MemberLevelFormValues> | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [validityOpen, setValidityOpen] = useState(false);
const [validityValue, setValidityValue] = useState<string | number>();
const loadValidity = useCallback(async () => {
const config = await getMemberLevelSystem();
if (config !== false) setValidityValue(config.set_value);
}, []);
useEffect(() => {
if (permissions.includes("/member/userlevel/getSystem")) void loadValidity();
}, [loadValidity, permissions]);
const handleEdit = async (id: number) => {
const level = await getMemberLevelInfo(id);
if (level === false) return;
setSelectedId(level.id);
setIsAdd(false);
setInitialValues({
name: level.name,
consumption: level.consumption == null ? "" : String(level.consumption),
});
setFormOpen(true);
};
const handleFormSubmit = async (values: MemberLevelFormValues) => {
const success = isAdd
? await addMemberLevel(values)
: selectedId !== null && (await editMemberLevel(selectedId, values));
if (!success) return;
list.refreshUnfiltered();
setFormOpen(false);
};
const handleDelete = async () => {
if (selectedId === null) return;
const success = await deleteMemberLevel(selectedId);
if (success) list.refreshUnfiltered();
setDeleteOpen(false);
};
const handleValiditySubmit = async (values: ValidityFormValues) => {
setValidityOpen(false);
const success = await editMemberLevelSystem(values.set_value);
if (success) await loadValidity();
};
const columns: ColumnsType<MemberLevelRow> = [
{ title: "ID", key: "id", align: "center", dataIndex: "id" },
{ title: "等级名称", key: "name", align: "center", dataIndex: "name" },
{ title: "达标金额", key: "consumption", align: "center", dataIndex: "consumption" },
{ title: "创建时间", key: "create_time", align: "center", dataIndex: "create_time" },
{ title: "更新时间", key: "update_time", align: "center", dataIndex: "update_time" },
{
title: "操作",
key: "handle",
dataIndex: "handle",
render: (_, record) => (
<Space>
{permissions.includes("/member/userlevel/getInfoById") ? (
<Button style={{ letterSpacing: "-2px" }} onClick={() => void handleEdit(record.id)}>
编辑
</Button>
) : null}
{permissions.includes("/member/userlevel/del") ? (
<Button
style={{ letterSpacing: "-2px" }}
onClick={() => {
setSelectedId(record.id);
setDeleteOpen(true);
}}
>
删除
</Button>
) : null}
</Space>
),
},
];
const filterItems: AppListFilterItem<MemberLevelFilters>[] = [
{
name: "name",
label: "等级名称",
render: (field) => (
<Input
autoComplete="off"
allowClear
value={field.value ?? ""}
onChange={(event) => field.onChange(event.target.value)}
placeholder="请输入等级名称"
id="name"
/>
),
},
{
name: "time",
label: "创建时间",
render: (field) => (
<RangePicker
value={field.value}
onChange={(value) => field.onChange(value)}
id="createTime"
/>
),
},
];
const toolbar = (
<Space>
{permissions.includes("/member/userlevel/add") ? (
<Button
type="primary"
ghost
onClick={() => {
setSelectedId(null);
setInitialValues(null);
setIsAdd(true);
setFormOpen(true);
}}
>
添加
</Button>
) : null}
{permissions.includes("/member/userlevel/getSystem") ? (
<Button type="primary" ghost onClick={() => setValidityOpen(true)}>
有效期设置
</Button>
) : null}
</Space>
);
return (
<>
<AppList<MemberLevelRow, MemberLevelFilters>
rowKey="id"
columns={columns}
data={{
rows: list.rows,
total: list.total,
loading: list.loading,
error: list.error,
}}
filters={{
value: list.filters,
items: filterItems,
onChange: list.setFilters,
onSearch: list.search,
onReset: list.reset,
onRefresh: list.refresh,
}}
pagination={{
page: list.page,
pageSize: list.pageSize,
pageSizeOptions: [10, 20, 30, 40],
onChange: list.changePage,
onPageSizeChange: list.changePageSize,
}}
toolbar={toolbar}
onRetry={list.retry}
/>
<MemberLevelFormDrawer
open={formOpen}
initialValues={initialValues}
onClose={() => setFormOpen(false)}
onSubmit={handleFormSubmit}
/>
<DeleteMemberLevelModal
open={deleteOpen}
onCancel={() => setDeleteOpen(false)}
onConfirm={handleDelete}
/>
<ValidityDrawer
open={validityOpen}
value={validityValue}
canEdit={permissions.includes("/member/userlevel/systemEdit")}
onClose={() => setValidityOpen(false)}
onSubmit={handleValiditySubmit}
/>
</>
);
}
...@@ -4,7 +4,7 @@ import ModuleBoundary from "@/app/ModuleBoundary"; ...@@ -4,7 +4,7 @@ import ModuleBoundary from "@/app/ModuleBoundary";
const MembersLayout = lazy(() => import("@/pages/member/index")); const MembersLayout = lazy(() => import("@/pages/member/index"));
const MemberListRoutes = lazy(() => import("./list/routes")); const MemberListRoutes = lazy(() => import("./list/routes"));
const UserLevel = lazy(() => import("@/pages/member/userlevel/index")); const MemberLevelRoutes = lazy(() => import("./level/routes"));
export default function MembersRoutes() { export default function MembersRoutes() {
const routes = useRoutes([ const routes = useRoutes([
...@@ -13,7 +13,7 @@ export default function MembersRoutes() { ...@@ -13,7 +13,7 @@ export default function MembersRoutes() {
children: [ children: [
{ index: true, element: <Navigate to="/member/list" /> }, { index: true, element: <Navigate to="/member/list" /> },
{ path: "list/*", Component: MemberListRoutes }, { path: "list/*", Component: MemberListRoutes },
{ path: "userlevel", Component: UserLevel }, { path: "userlevel/*", Component: MemberLevelRoutes },
], ],
}, },
{ path: "*", element: <Navigate to="/" /> }, { path: "*", element: <Navigate to="/" /> },
......
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论