chore(web): 修复失效的类型检查命令(tsconfig 是 solution 文件, sc --noEmit 空跑=假绿)→ 新增 typecheck 脚本用 tsc -b;并收尾前端契约修复(路由守卫/分页/查询竞态/类型补全)

This commit is contained in:
12451 2026-09-20 00:44:35 +08:00
parent f80b3b634c
commit 6b562ae286
4 changed files with 109 additions and 99 deletions

View File

@ -7,7 +7,8 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "oxlint", "lint": "oxlint",
"preview": "vite preview" "preview": "vite preview",
"typecheck": "tsc -b"
}, },
"dependencies": { "dependencies": {
"@arco-design/web-react": "^2.66.15", "@arco-design/web-react": "^2.66.15",

View File

@ -53,11 +53,13 @@ function RedirectIfAuth({ children }: { children: React.ReactNode }) {
/** /**
* 路由级权限守卫:无权限时展示统一的 403 页(`NoPermission`) * 路由级权限守卫:无权限时展示统一的 403 页(`NoPermission`)
* *
* 权限码来自后端 `db/migrate.rs` 的 permissions 种子表: * 权限码全部取自后端**真实校验**的权限码,并逐一在 `db/migrate.rs` 的 permissions 种子里核对:
* - `/settings/roles` → `role:view`(种子里存在;仅总管理员被授予) * - `/settings/roles` → `role:view`(种子 482 行,仅总管理员被授予)
* - `/settings/operation-logs` → `operation_log:view`(种子里存在;企业管理员/总管理员被授予) * - `/settings/operation-logs` → `operation_log:view`(种子 486 行;后端 `operation_logs.rs:154` 校验)
* - `/settings/feedback` → `user:view`(后端 `/api/feedback` 目前只校验登录态、**没有专用权限码**, * - `/settings/feedback` → `operation_log:view`(后端反馈列表/改状态用同一权限码:
* 这里用「查看用户」作为最小可用守卫,详见修复报告) * `h5.rs` 的 `list_feedback` / `update_feedback_status`;种子里真实存在)
* - `/settings/users` → `user:view`(后端 `users.rs:345` 校验;**同一处缺陷的顺带修复**:
* 该项在侧边菜单里对所有角色可见,操作员点进来以前只会看到「加载失败」)
* *
* 总管理员(role_level >= 2)在 `hasPermission` 内直接放行,不会被拦。 * 总管理员(role_level >= 2)在 `hasPermission` 内直接放行,不会被拦。
*/ */
@ -104,10 +106,10 @@ export default function App() {
<Route path="/charge-records" element={<ChargeRecords />} /> <Route path="/charge-records" element={<ChargeRecords />} />
<Route path="/device-logs" element={<DeviceLogs />} /> <Route path="/device-logs" element={<DeviceLogs />} />
<Route path="/energy" element={<Energy />} /> <Route path="/energy" element={<Energy />} />
<Route path="/settings/users" element={<Users />} /> <Route path="/settings/users" element={<Guard code="user:view"><Users /></Guard>} />
<Route path="/settings/roles" element={<Guard code="role:view"><Roles /></Guard>} /> <Route path="/settings/roles" element={<Guard code="role:view"><Roles /></Guard>} />
<Route path="/settings/operation-logs" element={<Guard code="operation_log:view"><OperationLogs /></Guard>} /> <Route path="/settings/operation-logs" element={<Guard code="operation_log:view"><OperationLogs /></Guard>} />
<Route path="/settings/feedback" element={<Guard code="user:view"><Feedback /></Guard>} /> <Route path="/settings/feedback" element={<Guard code="operation_log:view"><Feedback /></Guard>} />
<Route path="/batteries" element={<Batteries />} /> <Route path="/batteries" element={<Batteries />} />
<Route path="/charge-orders" element={<ChargeOrders />} /> <Route path="/charge-orders" element={<ChargeOrders />} />
</Route> </Route>

View File

@ -4,7 +4,7 @@
* - 搜索过滤:电池ID、状态 * - 搜索过滤:电池ID、状态
*/ */
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import {
Card, Card,
Table, Table,
@ -29,28 +29,38 @@ const STATUS_COLOR: Record<number, string> = {
3: 'orange', 3: 'orange',
}; };
/** 已应用的查询条件(页码 + 筛选条件) */
interface BatteryQuery {
page: number;
status?: number;
batteryId: string;
}
export default function Batteries() { export default function Batteries() {
const [data, setData] = useState<Battery[]>([]); const [data, setData] = useState<Battery[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [statusFilter, setStatusFilter] = useState<number | undefined>(); /** 电池ID输入框(点「搜索」时才应用到查询条件,避免每敲一个字符都发请求) */
const [batteryIdFilter, setBatteryIdFilter] = useState(''); const [batteryIdInput, setBatteryIdInput] = useState('');
/**
* 已应用的查询条件 —— **唯一的请求触发器**
*
* 页码与筛选条件放在同一个 state 里:点「搜索」时一次性写入 `{ page: 1, ...条件 }`,
* 由 `useEffect` 触发**一次**请求。这样就不会出现「先 setPage(1) 又用旧 page 发请求」
* 的重复请求 / 停在错页问题(历史 bug)。
*/
const [query, setQuery] = useState<BatteryQuery>({ page: 1, batteryId: '' });
const pageSize = 20; const pageSize = 20;
/** /** 加载列表(条件由 `query` 显式传入,不读组件内的其它 state) */
* 加载列表 const loadData = useCallback(async (q: BatteryQuery) => {
* 显式接收分页/筛选参数:避免在 `setState` 尚未生效时用旧值发请求
* (历史 bug:点「搜索」先 `setPage(1)` 又立刻用旧 `page` 发请求,导致重复请求或停在错的页)
*/
const loadData = async (p: number, status: number | undefined, batteryId: string) => {
setLoading(true); setLoading(true);
try { try {
const res = await batteryApi.list({ const res = await batteryApi.list({
status, status: q.status,
battery_id: batteryId || undefined, battery_id: q.batteryId || undefined,
page: p, page: q.page,
page_size: pageSize, page_size: pageSize,
}); });
setData(res.data.list || []); setData(res.data.list || []);
@ -60,31 +70,27 @@ export default function Batteries() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, []);
// 分页 / 状态筛选变化时自动重查(battery_id 为手动搜索,不作为依赖) // 查询条件(含页码)变化 → 重查一次
useEffect(() => { useEffect(() => {
loadData(page, statusFilter, batteryIdFilter); loadData(query);
}, [page, statusFilter]); }, [loadData, query]);
/** 搜索:页码已变化时交给 useEffect 重查,否则直接查第 1 页 */ /** 搜索:应用输入框条件并回到第 1 页(新对象必然触发一次请求) */
const handleSearch = () => { const handleSearch = () => {
if (page === 1) { setQuery((q) => ({ page: 1, status: q.status, batteryId: batteryIdInput }));
loadData(1, statusFilter, batteryIdFilter);
} else {
setPage(1);
}
}; };
/** 重置:清空筛选条件并回到第 1 页(用重置后的条件直接重查,不依赖 setState 时序) */ /** 状态筛选变化:沿用当前页码重查(与改动前行为一致) */
const handleStatusChange = (value?: number) => {
setQuery((q) => ({ ...q, status: value }));
};
/** 重置:清空筛选条件并回到第 1 页 */
const handleReset = () => { const handleReset = () => {
setStatusFilter(undefined); setBatteryIdInput('');
setBatteryIdFilter(''); setQuery({ page: 1, status: undefined, batteryId: '' });
if (page === 1 && statusFilter === undefined) {
loadData(1, undefined, '');
} else {
setPage(1);
}
}; };
const columns = [ const columns = [
@ -140,16 +146,16 @@ export default function Batteries() {
<Form.Item label="电池ID"> <Form.Item label="电池ID">
<Input <Input
placeholder="搜索电池ID" placeholder="搜索电池ID"
value={batteryIdFilter} value={batteryIdInput}
onChange={setBatteryIdFilter} onChange={setBatteryIdInput}
onPressEnter={handleSearch} onPressEnter={handleSearch}
/> />
</Form.Item> </Form.Item>
<Form.Item label="状态"> <Form.Item label="状态">
<Select <Select
placeholder="全部" placeholder="全部"
value={statusFilter} value={query.status}
onChange={setStatusFilter} onChange={handleStatusChange}
style={{ width: 120 }} style={{ width: 120 }}
allowClear allowClear
> >
@ -177,11 +183,11 @@ export default function Batteries() {
data={data} data={data}
loading={loading} loading={loading}
pagination={{ pagination={{
current: page, current: query.page,
pageSize, pageSize,
total, total,
showTotal: (t) => `共 ${t} 条`, showTotal: (t) => `共 ${t} 条`,
onChange: (p) => setPage(p), onChange: (p) => setQuery((q) => ({ ...q, page: p })),
}} }}
border border
stripe stripe

View File

@ -4,7 +4,7 @@
* - 搜索过滤:柜子、电池ID、状态、时间范围 * - 搜索过滤:柜子、电池ID、状态、时间范围
*/ */
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import {
Card, Card,
Table, Table,
@ -29,36 +29,50 @@ const STATUS_COLOR: Record<number, string> = {
2: 'red', 2: 'red',
}; };
/** 已应用的查询条件(页码 + 筛选条件) */
interface OrderQuery {
page: number;
cabinetId: string;
batteryId: string;
status?: number;
dateRange: string[];
}
export default function ChargeOrders() { export default function ChargeOrders() {
const [data, setData] = useState<ChargeOrder[]>([]); const [data, setData] = useState<ChargeOrder[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [cabinetId, setCabinetId] = useState<string>(''); /** 电池ID输入框(点「搜索」时才应用,避免每敲一个字符都发请求) */
const [batteryId, setBatteryId] = useState(''); const [batteryIdInput, setBatteryIdInput] = useState('');
const [statusFilter, setStatusFilter] = useState<number | undefined>(); /** 时间范围选择(点「搜索」时才应用) */
const [dateRange, setDateRange] = useState<string[]>([]); const [dateRangeInput, setDateRangeInput] = useState<string[]>([]);
/**
* 已应用的查询条件 —— **唯一的请求触发器**
*
* 页码与筛选条件放在同一个 state 里:点「搜索」时一次性写入 `{ page: 1, ...条件 }`,
* 由 `useEffect` 触发**一次**请求,不会出现「先 setPage(1) 又用旧 page 发请求」的
* 重复请求 / 停在错页问题(历史 bug)。
*/
const [query, setQuery] = useState<OrderQuery>({
page: 1,
cabinetId: '',
batteryId: '',
dateRange: [],
});
const pageSize = 20; const pageSize = 20;
/** /** 加载列表(条件由 `query` 显式传入,不读组件内的其它 state) */
* 加载列表 const loadData = useCallback(async (q: OrderQuery) => {
* 显式接收分页/筛选参数:避免在 `setState` 尚未生效时用旧值发请求
* (历史 bug:点「查询」先 `setPage(1)` 又立刻用旧 `page` 发请求)
*/
const loadData = async (
p: number,
filters: { cabinetId: string; batteryId: string; status: number | undefined; dateRange: string[] },
) => {
setLoading(true); setLoading(true);
try { try {
const res = await chargeOrderApi.list({ const res = await chargeOrderApi.list({
cabinet_id: filters.cabinetId ? Number(filters.cabinetId) : undefined, cabinet_id: q.cabinetId ? Number(q.cabinetId) : undefined,
battery_id: filters.batteryId || undefined, battery_id: q.batteryId || undefined,
status: filters.status, status: q.status,
start_time: filters.dateRange[0] || undefined, start_time: q.dateRange[0] || undefined,
end_time: filters.dateRange[1] || undefined, end_time: q.dateRange[1] || undefined,
page: p, page: q.page,
page_size: pageSize, page_size: pageSize,
}); });
setData(res.data.list || []); setData(res.data.list || []);
@ -68,36 +82,23 @@ export default function ChargeOrders() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, []);
/** 当前筛选条件快照 */ // 查询条件(含页码)变化 → 重查一次
const currentFilters = { cabinetId, batteryId, status: statusFilter, dateRange };
// 分页 / 柜子 / 状态变化时自动重查(电池ID、时间范围为手动查询条件)
useEffect(() => { useEffect(() => {
loadData(page, { cabinetId, batteryId, status: statusFilter, dateRange }); loadData(query);
}, [page, cabinetId, statusFilter]); }, [loadData, query]);
/** 查询:页码已变化时交给 useEffect 重查,否则直接查第 1 页 */ /** 搜索:应用输入框条件并回到第 1 页(新对象必然触发一次请求) */
const handleSearch = () => { const handleSearch = () => {
if (page === 1) { setQuery((q) => ({ ...q, page: 1, batteryId: batteryIdInput, dateRange: dateRangeInput }));
loadData(1, currentFilters);
} else {
setPage(1);
}
}; };
/** 重置:清空筛选条件并回到第 1 页(用重置后的条件直接重查,不依赖 setState 时序) */ /** 重置:清空筛选条件并回到第 1 页 */
const handleReset = () => { const handleReset = () => {
setCabinetId(''); setBatteryIdInput('');
setBatteryId(''); setDateRangeInput([]);
setStatusFilter(undefined); setQuery({ page: 1, cabinetId: '', batteryId: '', dateRange: [] });
setDateRange([]);
if (page === 1 && !cabinetId && statusFilter === undefined) {
loadData(1, { cabinetId: '', batteryId: '', status: undefined, dateRange: [] });
} else {
setPage(1);
}
}; };
const columns = [ const columns = [
@ -159,23 +160,23 @@ export default function ChargeOrders() {
<Form.Item label="柜子ID"> <Form.Item label="柜子ID">
<Input <Input
placeholder="柜子ID" placeholder="柜子ID"
value={cabinetId} value={query.cabinetId}
onChange={(v) => setCabinetId(typeof v === 'string' && v ? v : '')} onChange={(v) => setQuery((q) => ({ ...q, cabinetId: typeof v === 'string' && v ? v : '' }))}
/> />
</Form.Item> </Form.Item>
<Form.Item label="电池ID"> <Form.Item label="电池ID">
<Input <Input
placeholder="搜索电池ID" placeholder="搜索电池ID"
value={batteryId} value={batteryIdInput}
onChange={setBatteryId} onChange={setBatteryIdInput}
onPressEnter={handleSearch} onPressEnter={handleSearch}
/> />
</Form.Item> </Form.Item>
<Form.Item label="状态"> <Form.Item label="状态">
<Select <Select
placeholder="全部" placeholder="全部"
value={statusFilter} value={query.status}
onChange={setStatusFilter} onChange={(v) => setQuery((q) => ({ ...q, status: v }))}
style={{ width: 120 }} style={{ width: 120 }}
allowClear allowClear
> >
@ -186,8 +187,8 @@ export default function ChargeOrders() {
</Form.Item> </Form.Item>
<Form.Item label="时间范围"> <Form.Item label="时间范围">
<RangePicker <RangePicker
value={dateRange.length > 0 ? [dateRange[0], dateRange[1]] : undefined} value={dateRangeInput.length > 0 ? [dateRangeInput[0], dateRangeInput[1]] : undefined}
onChange={(vals) => setDateRange(vals || [])} onChange={(vals) => setDateRangeInput(vals || [])}
/> />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
@ -209,11 +210,11 @@ export default function ChargeOrders() {
data={data} data={data}
loading={loading} loading={loading}
pagination={{ pagination={{
current: page, current: query.page,
pageSize, pageSize,
total, total,
showTotal: (t) => `共 ${t} 条`, showTotal: (t) => `共 ${t} 条`,
onChange: (p) => setPage(p), onChange: (p) => setQuery((q) => ({ ...q, page: p })),
}} }}
border border
stripe stripe