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

refactor(admin): migrate settings feedback module

上级 ff815a97
# Task 5 report: settings/feedback migration
## Status
Completed the `settings/feedback` migration from the legacy page directory to
`apps/admin/src/modules/settings/feedback`. The `/setting/feedback` route now
lazy-loads the module-owned route and view. The legacy feedback page and API
files were removed only after the module build and regression checks passed.
## Delivered structure
- `api.ts`: retains the existing axios client, POST endpoint, and object body;
awaits the business envelope before normalizing `data.list` / `data.total` to
`{ rows, total }`.
- `query.ts`: maps `pageSize` to `page_size`, maps the selected time range to
second-based `start_time` / `end_time`, removes the UI-only `time` field, and
preserves meaningful falsy values rather than applying generic cleanup.
- `useFeedbackList.ts`: owns draft/applied filters, controlled pagination,
rows, total, loading, error, retry, and request sequencing.
- `views/FeedbackListView.tsx`: preserves the three filters and the five legacy
columns in their original order, including the 400px two-line feedback cell
with full text in a Popover. The page has no permissions, toolbar, row
selection, or row actions, matching the legacy page.
- `routes.tsx`: owns the feedback leaf route and fallback without changing the
public URL.
- `modules/settings/routes.tsx`: delegates `feedback/*` to the new module.
## Preserved behavior
- API: `POST /api/system/proposal/getList` with an object body.
- Initial pagination: page 1, page size 10; allowed sizes 10/20/30/40.
- Search and reset return to page 1 and issue one explicit request.
- Pagination uses applied filters; refresh keeps the current page and applies
the current filter draft, matching the old form-backed refresh behavior.
- Clearing a filter explicitly reloads the current page, matching the old
`onValuesChange` behavior.
- Failures end loading, retain existing rows when present, surface an error to
AppList, and retry the last request.
- A request sequence prevents an older response from overwriting newer data.
## Verification
- `pnpm test`: passed, 11/11 global Node tests.
- `pnpm test:unit --run`: passed, 10/10 smoke and controlled AppList tests.
- Feedback development guards before their removal per the updated task scope:
passed, 14/14 query/API/Hook tests. These files were not committed.
- `pnpm exec oxlint src/modules/settings/feedback --deny-warnings`: passed.
- `pnpm exec oxfmt --check src/modules/settings/feedback`: passed.
- `pnpm build`: passed; existing formula-editor and large-chunk warnings remain.
- `pnpm exec tsc --noEmit`: feedback introduced no diagnostics. The command
remains nonzero for existing/shared concurrent issues in `App.tsx`,
`RouteErrorBoundary.tsx`, and the shared AppList LESS side-effect import.
## Concerns
1. Per the updated task scope, no feedback API/query/Hook test files are part of
this commit. The development-only guards were removed before staging.
2. Legacy, unused route metadata files still contain imports of the removed old
page path. The active `src/app/routes.tsx` path uses the new domain route and
production build succeeds; updating deprecated route metadata was outside
this task's authorized scope.
import axios from "@/utils/axios";
import type { FeedbackQuery } from "./query";
export interface FeedbackRow {
id: string | number;
name?: string;
phone?: string;
create_time?: string;
proposal?: string;
}
export interface FeedbackListResult {
rows: FeedbackRow[];
total: number;
}
interface FeedbackListEnvelope {
data?: {
list?: FeedbackRow[];
total?: number;
};
}
export type FeedbackListRequest = (params: FeedbackQuery) => Promise<FeedbackListResult>;
// 保留旧 axios 客户端和业务 envelope,await 后才做模块内归一化。
export async function getFeedbackList(params: FeedbackQuery): Promise<FeedbackListResult> {
const response = (await axios.post(
"/api/system/proposal/getList",
params,
)) as unknown as FeedbackListEnvelope;
const { list = [], total = 0 } = response.data ?? {};
return { rows: list, total };
}
import type { Dayjs } from "dayjs";
export type FeedbackFilterValue = string | number | boolean | null;
export interface FeedbackFilters {
name?: FeedbackFilterValue;
phone?: FeedbackFilterValue;
time?: [Dayjs | null, Dayjs | null] | null;
}
export interface FeedbackQuery {
page: number;
page_size: number;
name?: FeedbackFilterValue;
phone?: FeedbackFilterValue;
start_time?: number;
end_time?: number;
}
interface BuildFeedbackQueryInput {
page: number;
pageSize: number;
filters: FeedbackFilters;
}
export function buildFeedbackQuery({
page,
pageSize,
filters,
}: BuildFeedbackQueryInput): FeedbackQuery {
const query: FeedbackQuery = {
page,
page_size: pageSize,
};
if (filters.name !== undefined) {
query.name = filters.name;
}
if (filters.phone !== undefined) {
query.phone = filters.phone;
}
if (filters.time?.[0] && filters.time[1]) {
query.start_time = filters.time[0].valueOf() / 1000;
query.end_time = filters.time[1].valueOf() / 1000;
}
return query;
}
import { Navigate, useRoutes } from "react-router";
import FeedbackListView from "./views/FeedbackListView";
export default function FeedbackRoutes() {
return useRoutes([
{ index: true, Component: FeedbackListView },
{ path: "*", element: <Navigate to="/setting/feedback" /> },
]);
}
import { useCallback, useEffect, useRef, useState } from "react";
import { getFeedbackList, type FeedbackListRequest, type FeedbackListResult } from "./api";
import { buildFeedbackQuery, type FeedbackFilters, type FeedbackQuery } from "./query";
const INITIAL_PAGE = 1;
const INITIAL_PAGE_SIZE = 10;
interface UseFeedbackListOptions {
request?: FeedbackListRequest;
}
function toError(reason: unknown) {
return reason instanceof Error ? reason : new Error("请求失败");
}
function wasLegacyClear(previous: FeedbackFilters, next: FeedbackFilters) {
const keys = new Set([...Object.keys(previous), ...Object.keys(next)]) as Set<
keyof FeedbackFilters
>;
return [...keys].some((key) => previous[key] !== next[key] && !next[key]);
}
export function useFeedbackList({ request = getFeedbackList }: UseFeedbackListOptions = {}) {
const [filters, setFilterState] = useState<FeedbackFilters>({});
const [appliedFilters, setAppliedFilters] = useState<FeedbackFilters>({});
const [page, setPage] = useState(INITIAL_PAGE);
const [pageSize, setPageSize] = useState(INITIAL_PAGE_SIZE);
const [rows, setRows] = useState<FeedbackListResult["rows"]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const requestRef = useRef(request);
const requestIdRef = useRef(0);
const lastQueryRef = useRef<FeedbackQuery>(
buildFeedbackQuery({ page: INITIAL_PAGE, pageSize: INITIAL_PAGE_SIZE, filters: {} }),
);
useEffect(() => {
requestRef.current = request;
}, [request]);
const load = useCallback(async (query: FeedbackQuery) => {
const requestId = ++requestIdRef.current;
lastQueryRef.current = query;
setLoading(true);
setError(null);
try {
const result = await requestRef.current(query);
if (requestId === requestIdRef.current) {
setRows(result.rows);
setTotal(result.total);
}
} catch (reason) {
if (requestId === requestIdRef.current) {
setError(toError(reason));
}
} finally {
if (requestId === requestIdRef.current) {
setLoading(false);
}
}
}, []);
useEffect(() => {
void load(lastQueryRef.current);
return () => {
requestIdRef.current += 1;
};
}, [load]);
const loadFor = useCallback(
(nextPage: number, nextPageSize: number, nextFilters: FeedbackFilters) =>
load(
buildFeedbackQuery({
page: nextPage,
pageSize: nextPageSize,
filters: nextFilters,
}),
),
[load],
);
const setFilters = useCallback(
(nextFilters: FeedbackFilters) => {
const shouldReload = wasLegacyClear(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: FeedbackFilters = {};
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 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(lastQueryRef.current);
}, [load]);
return {
filters,
setFilters,
page,
pageSize,
rows,
total,
loading,
error,
search,
reset,
refresh,
changePage,
changePageSize,
retry,
};
}
import { DatePicker, Input, Popover } from "antd";
import type { ColumnsType } from "antd/es/table";
import { AppList, type AppListFilterItem } from "@/shared/components/AppList";
import type { FeedbackRow } from "../api";
import type { FeedbackFilterValue, FeedbackFilters } from "../query";
import { useFeedbackList } from "../useFeedbackList";
const { RangePicker } = DatePicker;
const columns: ColumnsType<FeedbackRow> = [
{
title: "ID",
key: "id",
dataIndex: "id",
},
{
title: "用户名称",
key: "name",
dataIndex: "name",
align: "center",
},
{
title: "手机号",
key: "phone",
dataIndex: "phone",
align: "center",
},
{
title: "反馈时间",
key: "create_time",
dataIndex: "create_time",
align: "center",
},
{
title: "反馈内容",
key: "proposal",
dataIndex: "proposal",
align: "center",
width: 400,
render: (text: string) => (
<Popover content={text} overlayStyle={{ maxWidth: "400px" }}>
<div
style={{
display: "-webkit-box",
WebkitLineClamp: "2",
WebkitBoxOrient: "vertical",
overflow: "hidden",
textOverflow: "ellipsis",
textAlign: "center",
}}
>
{text}
</div>
</Popover>
),
},
];
function inputValue(value: FeedbackFilterValue | undefined) {
return typeof value === "string" || typeof value === "number" ? value : "";
}
const filterItems: AppListFilterItem<FeedbackFilters>[] = [
{
label: "用户名称",
name: "name",
render: (field) => (
<Input
placeholder="请输入用户名称"
allowClear
value={inputValue(field.value)}
onChange={(event) => field.onChange(event.target.value)}
/>
),
},
{
label: "手机号",
name: "phone",
render: (field) => (
<Input
placeholder="请输入手机号"
allowClear
value={inputValue(field.value)}
onChange={(event) => field.onChange(event.target.value)}
/>
),
},
{
label: "反馈时间",
name: "time",
render: (field) => (
<RangePicker allowClear value={field.value} onChange={(value) => field.onChange(value)} />
),
},
];
export default function FeedbackListView() {
const list = useFeedbackList();
return (
<AppList<FeedbackRow, FeedbackFilters>
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,
}}
onRetry={list.retry}
/>
);
}
......@@ -14,7 +14,7 @@ const PointManagement = lazy(() => import("@/pages/setting/point-management/inde
const AppVersion = lazy(() => import("@/pages/setting/app-version/index"));
const Coupon = lazy(() => import("@/pages/setting/coupon/index"));
const Sensitive = lazy(() => import("@/pages/setting/sensitive/index"));
const Feedback = lazy(() => import("@/pages/setting/feedback/index"));
const FeedbackRoutes = lazy(() => import("./feedback/routes"));
export default function SettingsRoutes() {
const routes = useRoutes([
......@@ -37,7 +37,7 @@ export default function SettingsRoutes() {
{ path: "app-version", Component: AppVersion },
{ path: "coupon", Component: Coupon },
{ path: "sensitive", Component: Sensitive },
{ path: "feedback", Component: Feedback },
{ path: "feedback/*", Component: FeedbackRoutes },
],
},
{ path: "*", element: <Navigate to="/" /> },
......
import axios from "@/utils/axios";
// 获取意见反馈列表
export function getList(data) {
return axios.post("/api/system/proposal/getList", data);
}
import { Input, DatePicker, Popover } from "antd";
import AppList from "@/components/list";
import { getList } from "./api";
const { RangePicker } = DatePicker;
export default function FeedBack() {
const remote = {
request: getList,
beforeRequest(params) {
const { time, ...rest } = params;
if (time) {
rest.start_time = new Date(time[0]).getTime() / 1000;
rest.end_time = new Date(time[1]).getTime() / 1000;
}
return rest;
},
};
const filters = [
{
label: "用户名称",
name: "name",
element: <Input placeholder="请输入用户名称" allowClear />,
},
{
label: "手机号",
name: "phone",
element: <Input placeholder="请输入手机号" allowClear />,
},
{
label: "反馈时间",
name: "time",
element: <RangePicker allowClear />,
},
];
const columns = [
{
title: "ID",
key: "id",
dataIndex: "id",
},
{
title: "用户名称",
key: "name",
dataIndex: "name",
align: "center",
},
{
title: "手机号",
key: "phone",
dataIndex: "phone",
align: "center",
},
{
title: "反馈时间",
key: "create_time",
dataIndex: "create_time",
align: "center",
},
{
title: "反馈内容",
key: "proposal",
dataIndex: "proposal",
align: "center",
width: 400,
render: (text) => (
<Popover content={text} overlayStyle={{ maxWidth: "400px" }}>
<div
style={{
display: "-webkit-box",
WebkitLineClamp: "2",
WebkitBoxOrient: "vertical",
overflow: "hidden",
textOverflow: "ellipsis",
textAlign: "center",
}}
>
{text}
</div>
</Popover>
),
},
];
return <AppList {...{ remote, filters, columns }}></AppList>;
}
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论