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",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
"preview": "vite preview",
"typecheck": "tsc -b"
},
"dependencies": {
"@arco-design/web-react": "^2.66.15",

View File

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

View File

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

View File

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