feat(assets): add verified asset history trends and compact overview
This commit is contained in:
@@ -102,6 +102,10 @@ export default function AssetsModule() {
|
||||
// 所属公司(归属主体)筛选 —— 影响全页聚合
|
||||
const [selectedSubject, setSelectedSubject] = useState<string | null>(null);
|
||||
const [subjects, setSubjects] = useState<SubjectOption[]>([]);
|
||||
const [historySubjects, setHistorySubjects] = useState<string[]>([]);
|
||||
const updateHistorySubjects = useCallback((names: string[]) => setHistorySubjects(names), []);
|
||||
const subjectOptions = useMemo(() => [...subjects, ...historySubjects.filter(name => !subjects.some(s => s.name === name))
|
||||
.map(name => ({ name, total: 0, operating: 0, inventory: 0, historicalOnly: true }))], [subjects, historySubjects]);
|
||||
const [subjectDropdownOpen, setSubjectDropdownOpen] = useState(false);
|
||||
const [subjectSearch, setSubjectSearch] = useState('');
|
||||
const subjectDropdownRef = useRef<HTMLDivElement>(null);
|
||||
@@ -485,7 +489,7 @@ export default function AssetsModule() {
|
||||
loading={loading}
|
||||
selectedSubject={selectedSubject}
|
||||
setSelectedSubject={setSelectedSubject}
|
||||
subjects={subjects}
|
||||
subjects={subjectOptions}
|
||||
subjectDropdownOpen={subjectDropdownOpen}
|
||||
setSubjectDropdownOpen={setSubjectDropdownOpen}
|
||||
subjectSearch={subjectSearch}
|
||||
@@ -504,6 +508,8 @@ export default function AssetsModule() {
|
||||
|
||||
{tabReady && activeTab === 'overview' && (
|
||||
<OverviewView
|
||||
selectedSubject={selectedSubject}
|
||||
onHistorySubjects={updateHistorySubjects}
|
||||
summary={SUMMARY}
|
||||
operatingRate={operatingRate}
|
||||
inventoryRate={inventoryRate}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { fetchJson } from '../../auth/api-client';
|
||||
const BASE = '/api/vehicles';
|
||||
|
||||
export interface SubjectOption {
|
||||
historicalOnly?: boolean;
|
||||
name: string;
|
||||
total: number;
|
||||
inventory: number;
|
||||
|
||||
@@ -170,9 +170,9 @@ export function AssetsHeader({
|
||||
<span className="truncate">{s.name}</span>
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 flex-shrink-0 tabular-nums">
|
||||
{s.total} 台
|
||||
{s.historicalOnly ? '历史主体' : <>{s.total} 台
|
||||
<span className="mx-1 text-gray-200">·</span>
|
||||
<span className="text-green-500">运营 {s.operating}</span>
|
||||
<span className="text-green-500">运营 {s.operating}</span></>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AssetSummarySection } from './overview/AssetSummarySection';
|
||||
import { FlowStatisticsCard } from './overview/FlowStatisticsCard';
|
||||
import { InventoryStatisticsSection } from './overview/InventoryStatisticsSection';
|
||||
import { SummaryMetrics } from './overview/SummaryMetrics';
|
||||
import { AssetHistorySection } from '../history/AssetHistorySection';
|
||||
import type { OverviewViewProps } from './overview/types';
|
||||
|
||||
export function OverviewView(props: OverviewViewProps) {
|
||||
@@ -15,6 +16,8 @@ export function OverviewView(props: OverviewViewProps) {
|
||||
setVehicleSelection={props.setVehicleSelection}
|
||||
/>
|
||||
|
||||
<AssetHistorySection subject={props.selectedSubject} onSubjects={props.onHistorySubjects} />
|
||||
|
||||
<FlowStatisticsCard
|
||||
flowRange={props.flowRange}
|
||||
setFlowRange={props.setFlowRange}
|
||||
|
||||
@@ -66,17 +66,17 @@ export function SummaryMetrics({
|
||||
|
||||
</div>
|
||||
|
||||
<div data-testid="asset-operation-ratio-strip" className="mb-3 rounded-2xl border border-slate-100 bg-white/85 px-4 py-3 shadow-sm">
|
||||
<div data-testid="asset-operation-ratio-strip" className="mb-3 rounded-2xl border border-slate-100 bg-white/85 px-3 py-2 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="shrink-0 text-[11px] font-black text-slate-400">运营概览</div>
|
||||
<div className="grid min-w-0 flex-1 grid-cols-2 divide-x divide-slate-100 text-center">
|
||||
<div className="px-2">
|
||||
<div className="flex flex-wrap items-baseline justify-center gap-x-2 px-2">
|
||||
<div className="text-[10px] font-black text-slate-400">运营率</div>
|
||||
<div className="mt-0.5 text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div>
|
||||
<div className="text-base font-black text-blue-600">{operatingRate.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<div className="flex flex-wrap items-baseline justify-center gap-x-2 px-2">
|
||||
<div className="text-[10px] font-black text-slate-400">库存率</div>
|
||||
<div className="mt-0.5 text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
|
||||
<div className="text-base font-black text-slate-700">{inventoryRate.toFixed(1)}%</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -67,6 +67,8 @@ export type OverviewViewProps = SummaryMetricsProps
|
||||
& AssetSummarySectionProps
|
||||
& InventoryStatisticsSectionProps
|
||||
& {
|
||||
selectedSubject: string | null;
|
||||
onHistorySubjects: (names: string[]) => void;
|
||||
allTypesExpanded: boolean;
|
||||
toggleAllAssetTypes: () => void;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useEffect, useState, type MouseEvent } from 'react';
|
||||
import { ComposedChart, Bar, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LabelList } from 'recharts';
|
||||
import { ChevronRight, RefreshCw } from 'lucide-react';
|
||||
import { HISTORY_STATE_LABELS, historyTime, shanghaiMonth, type HistoryMode, type HistoryPoint, type HistoryTrend } from '../../../shared/assets/history';
|
||||
import { fetchHistoryLive, fetchHistoryTrend } from './api';
|
||||
import { defaultHistoryPeriod, displayHistoryCounts, displayHistoryDifference, signedCount, updateLivePoint } from './presentation';
|
||||
import { HistoryDetailsDialog } from './HistoryDetailsDialog';
|
||||
import { HistorySelect } from './HistorySelect';
|
||||
import './history.css';
|
||||
|
||||
export function AssetHistorySection({ subject, onSubjects }: { subject: string | null; onSubjects?: (names: string[]) => void }) {
|
||||
const [mode, setMode] = useState<HistoryMode>('monthly');
|
||||
const [year, setYear] = useState(() => Number(shanghaiMonth(new Date()).slice(0, 4)));
|
||||
const [data, setData] = useState<HistoryTrend | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [details, setDetails] = useState<HistoryPoint | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [retry, setRetry] = useState(0);
|
||||
const closeDetails = useCallback(() => setDetails(null), []);
|
||||
|
||||
useEffect(() => {
|
||||
const abort = new AbortController();
|
||||
let current: HistoryTrend | null = null;
|
||||
let busy = false;
|
||||
let observedMonth = shanghaiMonth(new Date());
|
||||
setData(null); setSelected(null); setDetails(null); setError(''); setLoading(true);
|
||||
const load = async () => {
|
||||
busy = true;
|
||||
try {
|
||||
const result = await fetchHistoryTrend(mode, year, subject, abort.signal);
|
||||
if (abort.signal.aborted) return;
|
||||
current = result; setData(result); setSelected(old => old && result.points.some(p => p.period === old) ? old : defaultHistoryPeriod(result.points));
|
||||
onSubjects?.(result.subjects); setError('');
|
||||
} catch (e) { if (!abort.signal.aborted) setError(e instanceof Error ? e.message : '趋势加载失败'); }
|
||||
finally { busy = false; if (!abort.signal.aborted) setLoading(false); }
|
||||
};
|
||||
void load();
|
||||
const interval = setInterval(async () => {
|
||||
if (busy || !current || abort.signal.aborted) return;
|
||||
const month = shanghaiMonth(new Date());
|
||||
if (month !== observedMonth) { observedMonth = month; await load(); return; }
|
||||
if (current.points.some(p => p.state === 'CAPTURING')) { await load(); return; }
|
||||
if (!current.points.some(p => p.state === 'LIVE')) return;
|
||||
busy = true;
|
||||
try {
|
||||
const live = await fetchHistoryLive(mode, subject, abort.signal);
|
||||
if (abort.signal.aborted) return;
|
||||
if (live.state !== 'LIVE') { await load(); return; }
|
||||
current = { ...current, points: updateLivePoint(current.points, live) }; setData(current); setError('');
|
||||
} catch (e) { if (!abort.signal.aborted) setError(e instanceof Error ? e.message : '实时数据刷新失败'); }
|
||||
finally { busy = false; }
|
||||
}, 5000);
|
||||
return () => { abort.abort(); clearInterval(interval); };
|
||||
}, [mode, year, subject, retry, onSubjects]);
|
||||
|
||||
const point = data?.points.find(p => p.period === selected);
|
||||
const hasComparison = point && (['total', 'inventory', 'operating'] as const).some(key => displayHistoryDifference(point, key) != null);
|
||||
const chart = data?.points.map(p => ({ ...p, total: displayHistoryCounts(p)?.total ?? null, operating: displayHistoryCounts(p)?.operating ?? null, inventory: displayHistoryCounts(p)?.inventory ?? null })) || [];
|
||||
const selectBar = (entry: { originalDataIndex: number }, _index: number, event: MouseEvent<SVGPathElement>) => {
|
||||
event.stopPropagation();
|
||||
const item = chart[entry.originalDataIndex];
|
||||
if (item) setSelected(item.period);
|
||||
};
|
||||
return <section className="asset-history" aria-labelledby="asset-history-title" data-testid="asset-history">
|
||||
<div className="asset-history-controls"><header className="asset-history-header"><h2 id="asset-history-title">资产历史趋势</h2>
|
||||
<div className="asset-history-modes" role="group" aria-label="历史统计方式">{(['annual', 'monthly'] as const).map(value => <button type="button" key={value} aria-pressed={mode === value} onClick={() => { setMode(value); setDetails(null); }}>{value === 'annual' ? '年度' : '月度'}</button>)}</div>
|
||||
</header>
|
||||
<div className="asset-history-toolbar">
|
||||
{mode === 'monthly' && <HistorySelect label="历史年份" value={String(year)} options={(data?.years || [year]).map(y => ({ value: String(y), label: `${y}年` }))} onChange={value => { setYear(Number(value)); setDetails(null); }} />}
|
||||
{data && <HistorySelect label="统计期间" value={selected || ''} onChange={value => { setSelected(value); setDetails(null); }} options={data.points.map(p => ({ value: p.period, label: p.label, detail: p.state === 'RECONSTRUCTED' ? '估算' : p.state === 'LIVE' ? '实时' : p.state === 'ARCHIVED' ? '归档' : p.state === 'FUTURE' ? '未来' : p.state === 'MISSING' ? '缺失' : HISTORY_STATE_LABELS[p.state] }))} />}
|
||||
<span title="每月最后一天北京时间17:00采集;年度复用12月快照">月末 17:00</span>
|
||||
<button type="button" className="asset-history-refresh" aria-label="刷新资产历史趋势" disabled={loading} onClick={() => setRetry(x => x + 1)}><RefreshCw size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{loading && !data ? <p className="asset-history-empty" role="status">正在加载资产历史趋势…</p> : !data ? <p className="asset-history-empty" role="alert">{error || '暂无可用数据'} <button type="button" onClick={() => setRetry(x => x + 1)}>重试</button></p> : <>
|
||||
{error && <p className="asset-history-warning" role="alert">{error} · 保留上次查询结果 <button type="button" onClick={() => setRetry(x => x + 1)}>重试</button></p>}
|
||||
<p className="asset-history-unit">数量(台)</p>
|
||||
<div className="asset-history-chart" role="img" aria-label="运营数与库存数堆叠柱,资产总数折线;精确数值和缺失状态可通过上方统计期间选择框查看">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 480, height: 200 }}>
|
||||
<ComposedChart data={chart} margin={{ top: 10, right: 14, bottom: 0, left: 0 }} onClick={state => { const p = chart.find(p => p.label === String(state?.activeLabel)); if (p) setSelected(p.period); }}>
|
||||
<CartesianGrid stroke="#e9eff7" vertical={false} /><XAxis dataKey="label" tick={{ fontSize: 10, fill: '#64748b' }} axisLine={{ stroke: '#dbe4ef' }} tickLine={false} interval={mode === 'monthly' ? 0 : 'preserveStartEnd'} minTickGap={4} />
|
||||
<YAxis allowDecimals={false} width={44} tick={{ fontSize: 11, fill: '#64748b' }} axisLine={false} tickLine={false} />
|
||||
<Tooltip content={({ active, payload }) => {
|
||||
const item = payload?.[0]?.payload as HistoryPoint | undefined;
|
||||
const values = item && displayHistoryCounts(item);
|
||||
return active && item ? <div className="asset-history-tooltip"><strong>{item.label} · {HISTORY_STATE_LABELS[item.state]}</strong>{values ? <p>总数 {values.total ?? '待核实'} · 运营 {values.operating} · 库存 {values.inventory}</p> : <p>暂无数量</p>}</div> : null;
|
||||
}} />
|
||||
<Bar onClick={selectBar} dataKey="operating" name="运营数" stackId="assets" fill="#347df5" maxBarSize={32} isAnimationActive={false} />
|
||||
<Bar onClick={selectBar} dataKey="inventory" name="库存数" stackId="assets" fill="#9ed1fa" maxBarSize={32} isAnimationActive={false} />
|
||||
<Line dataKey="total" name="资产总数" stroke="#132e57" strokeWidth={2} connectNulls={false} dot={{ r: 3, fill: '#fff', strokeWidth: 2 }} activeDot={{ r: 5 }} isAnimationActive={false}><LabelList dataKey="total" position="top" offset={8} fontSize={10} fill="#132e57" /></Line>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="asset-history-legend" aria-hidden="true"><span><i className="operating" />运营数</span><span><i className="inventory" />库存数</span><span><i className="total" />资产总数</span></div>
|
||||
{point && <div className="asset-history-selected" aria-live="polite">
|
||||
<div className="asset-history-selected-heading"><strong>{mode === 'annual' || point.state === 'LIVE' ? point.label : `${point.label}末`}</strong><span>{HISTORY_STATE_LABELS[point.state]}</span>{point.asOf && <time dateTime={point.asOf} aria-label={`截至 ${historyTime(point.asOf)}`}>{historyTime(point.asOf)}</time>}</div>
|
||||
<div className="asset-history-counts">{([['total', '总数'], ['inventory', '库存'], ['operating', '运营']] as const).map(([key, label]) => <div key={key}><span aria-label={`${label}(台)`}>{label}(台)</span><strong>{displayHistoryCounts(point)?.[key]?.toLocaleString() ?? (point.reconstruction && key === 'total' ? '待核实' : '—')}</strong></div>)}</div>
|
||||
{hasComparison ? <div className="asset-history-comparison"><span>{point.comparisonLabel}</span><div>{(['total', 'inventory', 'operating'] as const).map(key => {
|
||||
const difference = displayHistoryDifference(point, key);
|
||||
return <span key={key} className={difference != null && difference > 0 ? 'asset-history-increase' : difference != null && difference < 0 ? 'asset-history-decrease' : ''}>{difference != null ? signedCount(difference) : '—'}</span>;
|
||||
})}</div></div> : <p>{point.comparisonLabel}</p>}
|
||||
</div>}
|
||||
{point && mode === 'monthly' && <button type="button" className="asset-history-detail-button" disabled={!point.detailAvailable} onClick={() => setDetails(point)}><span>{point.reconstruction ? '查看月度回溯明细' : '查看月度明细'}</span><ChevronRight size={18} /></button>}
|
||||
<p className="asset-history-footnote">历史库存状态快照缺失,核对中,暂不展示。</p>
|
||||
</>}
|
||||
{details && <HistoryDetailsDialog point={details} subject={subject} close={closeDetails} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, Search, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useDialogFocus } from '../components/useDialogFocus';
|
||||
import { HISTORY_STATE_LABELS, historyTime, type HistoryCategory, type HistoryDetails, type HistoryPoint } from '../../../shared/assets/history';
|
||||
import { fetchHistoryDetails } from './api';
|
||||
import { displayHistoryCounts } from './presentation';
|
||||
|
||||
export function HistoryDetailsDialog({ point, subject, close }: { point: HistoryPoint; subject: string | null; close: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [category, setCategory] = useState<HistoryCategory>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [data, setData] = useState<HistoryDetails | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [retry, setRetry] = useState(0);
|
||||
useDialogFocus(true, ref, close);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => { setQuery(search.trim()); setPage(1); }, 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
useEffect(() => {
|
||||
const abort = new AbortController();
|
||||
setLoading(true); setError(''); setData(null);
|
||||
fetchHistoryDetails(point.month, point.batchId, subject, category, query, page, abort.signal)
|
||||
.then(result => { if (!abort.signal.aborted) setData(result); })
|
||||
.catch(e => { if (!abort.signal.aborted) setError(e instanceof Error ? e.message : '明细加载失败'); })
|
||||
.finally(() => { if (!abort.signal.aborted) setLoading(false); });
|
||||
return () => abort.abort();
|
||||
}, [point.month, point.batchId, subject, category, query, page, retry]);
|
||||
const pages = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;
|
||||
return createPortal(<div className="asset-history-overlay" onMouseDown={e => { if (e.target === e.currentTarget) close(); }}>
|
||||
<div className="asset-history-dialog" role="dialog" aria-modal="true" aria-labelledby="asset-history-detail-title" tabIndex={-1} ref={ref}>
|
||||
<header><div><h2 id="asset-history-detail-title">{point.month.replace('-', '年')}月资产明细</h2>
|
||||
<p>{subject || '全部公司'} · {HISTORY_STATE_LABELS[point.state]} · {point.reconstruction ? '参考时点' : point.state === 'ARCHIVED' ? '快照' : '截至'} {historyTime(point.reconstruction ? point.scheduledFor : data?.point.asOf || point.asOf)}</p></div>
|
||||
<button type="button" onClick={close} aria-label="关闭月度明细"><X size={20} /></button></header>
|
||||
<div className="asset-history-detail-tools">
|
||||
<label>车辆分类<select aria-label="月度明细分类" value={category} onChange={e => { setCategory(e.target.value as HistoryCategory); setPage(1); }}>
|
||||
<option value="all">{point.reconstruction ? '全部推算车辆' : '全部资产'}</option><option value="operating">运营车辆</option><option value="inventory">库存车辆</option></select></label>
|
||||
<label className="asset-history-search"><Search size={16} /><input aria-label="搜索历史车牌或VIN" placeholder="搜索车牌 / VIN" value={search} onChange={e => setSearch(e.target.value)} /></label>
|
||||
</div>
|
||||
<div className="asset-history-detail-body" aria-busy={loading} aria-live="polite">
|
||||
{loading ? <p className="asset-history-empty">正在加载月度明细…</p> : error ? <p className="asset-history-empty" role="alert">{error} <button type="button" onClick={() => setRetry(x => x + 1)}>重试</button></p> : data && <>
|
||||
<p className="asset-history-detail-note">{data.point.reconstruction ? '以下为核对表中的交还记录推算车辆,归属未知项留空。历史总数待核实,不能将两类相加当作历史资产总数。' : data.point.state === 'ARCHIVED' ? '以下为当时归档的车辆及归属信息。' : '以下为最新实时明细,数量可能随业务更新。'} 当前范围:总数 {displayHistoryCounts(data.point)?.total ?? '待核实'} · 运营 {displayHistoryCounts(data.point)?.operating} · 库存 {displayHistoryCounts(data.point)?.inventory}</p>
|
||||
{data.items.length ? <><div className="asset-history-detail-table"><table><thead><tr><th>车辆</th><th>分类</th><th>公司 / 部门</th><th>负责人 / 客户</th></tr></thead>
|
||||
<tbody>{data.items.map(item => <tr key={item.id}><td><strong>{item.plateNumber || '未登记车牌'}</strong><small>{item.model}</small><small>VIN:{item.vin || '未提供'}</small>{item.reconstructionBasis && <small>推算依据:{item.reconstructionBasis}</small>}</td>
|
||||
<td>{item.disposition === 'operating' ? '运营' : '库存'}</td><td>{item.subjectOrg || '未提供'}<small>{item.departmentName || (item.reconstructionBasis ? '历史未提供' : '未分配部门')}</small></td>
|
||||
<td>{item.customerManager || (item.reconstructionBasis ? '历史未提供' : '未分配')}<small>{item.customerName || (item.reconstructionBasis ? '历史未提供' : '无客户信息')}</small><small>{item.contractNo || ''}</small></td></tr>)}</tbody></table></div>
|
||||
<div className="asset-history-mobile-vehicles">{data.items.map(item => <article key={item.id}>
|
||||
<div className="asset-history-mobile-vehicle-heading"><strong>{item.plateNumber || '未登记车牌'}</strong><span>{item.disposition === 'operating' ? '运营' : '库存'}</span></div>
|
||||
<p>{item.model}</p><small>VIN:{item.vin || '未提供'}</small>
|
||||
{item.reconstructionBasis && <small>推算依据:{item.reconstructionBasis}</small>}
|
||||
<dl><div><dt>公司 / 部门</dt><dd>{item.subjectOrg || '未提供'}<small>{item.departmentName || (item.reconstructionBasis ? '历史未提供' : '未分配部门')}</small></dd></div>
|
||||
<div><dt>负责人 / 客户</dt><dd>{item.customerManager || (item.reconstructionBasis ? '历史未提供' : '未分配')}<small>{item.customerName || (item.reconstructionBasis ? '历史未提供' : '无客户信息')}</small></dd></div></dl>
|
||||
{item.contractNo && <small>合同:{item.contractNo}</small>}
|
||||
</article>)}</div></> : <p className="asset-history-empty">当前条件下没有车辆</p>}
|
||||
</>}
|
||||
</div>
|
||||
<footer><span>{data ? `${point.reconstruction ? '推算' : '筛选'}结果 ${data.total} 台 · 第 ${page} / ${pages} 页` : '每页 30 台'}</span><div>
|
||||
<button type="button" aria-label="上一页" disabled={loading || !data || page <= 1} onClick={() => setPage(x => x - 1)}><ChevronLeft size={16} /></button>
|
||||
<button type="button" aria-label="下一页" disabled={loading || !data || page >= pages} onClick={() => setPage(x => x + 1)}><ChevronRight size={16} /></button></div></footer>
|
||||
</div>
|
||||
</div>, document.body);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Select } from 'radix-ui';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
interface HistorySelectProps {
|
||||
label: string;
|
||||
value: string;
|
||||
options: { value: string; label: string; detail?: string }[];
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function HistorySelect({ label, value, options, onChange }: HistorySelectProps) {
|
||||
return <Select.Root value={value} onValueChange={onChange}>
|
||||
<Select.Trigger className="asset-history-select" aria-label={label}>
|
||||
<Select.Value />
|
||||
<Select.Icon className="asset-history-select-icon"><ChevronDown size={14} /></Select.Icon>
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content className="asset-history-select-menu" position="popper" align="start" sideOffset={6} collisionPadding={10}>
|
||||
<Select.ScrollUpButton className="asset-history-select-scroll"><ChevronUp size={14} /></Select.ScrollUpButton>
|
||||
<Select.Viewport className="asset-history-select-options">
|
||||
{options.map(option => <Select.Item className="asset-history-select-option" key={option.value} value={option.value} textValue={option.label}>
|
||||
<Select.ItemText>{option.label}</Select.ItemText>
|
||||
{option.detail && <span className="asset-history-option-detail">{option.detail}</span>}
|
||||
<Select.ItemIndicator className="asset-history-option-check"><Check size={14} /></Select.ItemIndicator>
|
||||
</Select.Item>)}
|
||||
</Select.Viewport>
|
||||
<Select.ScrollDownButton className="asset-history-select-scroll"><ChevronDown size={14} /></Select.ScrollDownButton>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { fetchAuthenticated } from '../../../auth/api-client';
|
||||
import type { HistoryMode, HistoryTrend, HistoryPoint, HistoryDetails, HistoryCategory } from '../../../shared/assets/history';
|
||||
|
||||
async function get<T>(path: string, params: Record<string, string | number | null | undefined>, signal: AbortSignal): Promise<T> {
|
||||
const query = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) if (value != null && value !== '') query.set(key, String(value));
|
||||
const response = await fetchAuthenticated(`/api/asset-history/${path}?${query}`, { signal });
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error || '历史趋势加载失败,请重试');
|
||||
return body as T;
|
||||
}
|
||||
export function fetchHistoryTrend(mode: HistoryMode, year: number, subject: string | null, signal: AbortSignal) {
|
||||
return get<HistoryTrend>('trend', { mode, year, subject }, signal);
|
||||
}
|
||||
export function fetchHistoryLive(mode: HistoryMode, subject: string | null, signal: AbortSignal) {
|
||||
return get<HistoryPoint>('live', { mode, subject }, signal);
|
||||
}
|
||||
export function fetchHistoryDetails(month: string, batchId: string | null, subject: string | null, category: HistoryCategory, search: string, page: number, signal: AbortSignal) {
|
||||
return get<HistoryDetails>('details', { month, batchId, subject, category, search, page, pageSize: 30 }, signal);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
.asset-history { --history-blue:#1559e8; margin:8px 0 16px; padding:16px; border:1px solid #e6edf5; border-radius:20px; background:#fff; color:#142b4e; box-shadow:0 5px 24px rgb(22 53 90 / 3%); }
|
||||
.asset-history button,.asset-history-dialog button { cursor:pointer; }
|
||||
.asset-history button:disabled,.asset-history-dialog button:disabled { cursor:default; opacity:.45; }
|
||||
.asset-history button:focus-visible,.asset-history select:focus-visible,.asset-history-dialog :is(button,input,select):focus-visible { outline:2px solid #2563eb; outline-offset:3px; }
|
||||
.asset-history-header { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; }
|
||||
.asset-history-header h2 { margin:0; font-size:20px; font-weight:750; letter-spacing:-.5px; }
|
||||
.asset-history-modes { display:flex; gap:2px; background:#eef3fa; border:1px solid #e7edf6; border-radius:11px; padding:3px; }
|
||||
.asset-history-modes button { padding:7px 20px; min-height:34px; border-radius:8px; font-size:13px; font-weight:600; transition:background .15s,color .15s,box-shadow .15s; color:#64748b; }
|
||||
.asset-history-modes button[aria-pressed=true] { background:#fff; color:#1559e8; box-shadow:0 1px 4px #193c6920, inset 0 0 0 1px #dce7f5; }
|
||||
.asset-history-toolbar { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin:8px 0 4px; font-size:12px; color:#64748b; }
|
||||
.asset-history-toolbar>span { margin-left:auto; }
|
||||
.asset-history-toolbar .asset-history-annual-note { margin-left:0; margin-right:auto; }
|
||||
.asset-history-refresh { display:grid; place-items:center; padding:8px; border-radius:8px; color:#7c8da5; transition:background .15s,color .15s; }
|
||||
.asset-history-warning { margin:8px 0; font-size:12px; line-height:1.7; color:#74613a; background:#fff9ec; padding:9px 12px; border-radius:9px; }
|
||||
.asset-history-warning button,.asset-history-empty button { color:#1559e8; text-decoration:underline; padding:6px; }
|
||||
.asset-history-unit { font-size:12px; color:#64748b; margin:6px 0 0; }
|
||||
.asset-history-chart { height:200px; width:100%; min-width:0; }
|
||||
.asset-history-tooltip { background:#fff; border:1px solid #dce5ef; border-radius:10px; padding:10px 12px; box-shadow:0 4px 16px #142b4e15; font-size:12px; }
|
||||
.asset-history-tooltip p { margin-top:4px; }
|
||||
.asset-history-legend { display:flex; justify-content:center; flex-wrap:wrap; gap:22px; color:#64748b; font-size:12px; margin:6px 0; }
|
||||
.asset-history-legend span { display:flex; align-items:center; gap:7px; }
|
||||
.asset-history-legend i { display:inline-block; height:11px; width:11px; border-radius:2px; }
|
||||
.asset-history-legend .operating { background:#347df5; }.asset-history-legend .inventory { background:#9ed1fa; }
|
||||
.asset-history-legend .total { width:20px; height:2px; background:#132e57; }
|
||||
.asset-history-selected { margin-top:8px; display:grid; grid-template-columns:90px minmax(0,1fr); align-items:center; gap:0 10px; padding:10px 14px; background:linear-gradient(115deg,#f3f7fe,#edf5ff); border:1px solid #e7effb; border-radius:12px; }
|
||||
.asset-history-selected-heading { display:flex; flex-direction:column; align-items:flex-start; gap:3px; min-width:0; }
|
||||
.asset-history-selected-heading>strong { color:#1559e8; font-size:17px; font-weight:750; }
|
||||
.asset-history-selected-heading>span,.asset-history-selected-heading>time { color:#64748b; font-size:11px; }
|
||||
.asset-history-counts { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); }
|
||||
.asset-history-counts>div { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px; min-width:0; border-right:1px solid #d4e2f4; }
|
||||
.asset-history-counts>div:last-child { border:0; }
|
||||
.asset-history-counts>div>span { color:#64748b; font-size:12px; }
|
||||
.asset-history-counts strong { font-size:23px; font-weight:750; line-height:1.2; font-variant-numeric:tabular-nums; }
|
||||
.asset-history-increase { color:#bf2f30; }.asset-history-decrease { color:#14864a; }
|
||||
.asset-history-selected>p { grid-column:1 / -1; color:#64748b; font-size:11px; text-align:center; padding-top:6px; }
|
||||
.asset-history-detail-button { display:flex; width:100%; align-items:center; justify-content:space-between; min-height:44px; padding:8px 12px; margin-top:8px; border:1px solid #dce8f8; border-radius:10px; background:#f7faff; color:#18385d; font-size:14px; font-weight:600; }
|
||||
.asset-history-footnote { display:flex; align-items:center; flex-wrap:wrap; gap:6px; color:#64748b; font-size:11px; margin-top:8px; }
|
||||
.asset-history-empty { padding:32px 12px; text-align:center; font-size:13px; color:#64748b; }
|
||||
.asset-history-overlay { position:fixed; inset:0; z-index:120; background:#0c1e3b66; display:flex; justify-content:center; align-items:center; padding:24px; }
|
||||
.asset-history-dialog { display:flex; flex-direction:column; width:min(980px,100%); max-height:88dvh; border-radius:18px; background:white; color:#183454; box-shadow:0 24px 64px #142b4e30; overflow:hidden; outline:none; }
|
||||
.asset-history-dialog>header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:20px 22px; border-bottom:1px solid #e6edf5; }
|
||||
.asset-history-dialog h2 { font-size:18px; font-weight:700; }
|
||||
.asset-history-dialog header p { font-size:12px; line-height:1.7; margin-top:5px; color:#64748b; }
|
||||
.asset-history-dialog header button { padding:8px; flex-shrink:0; }
|
||||
.asset-history-detail-tools { display:flex; flex-wrap:wrap; gap:12px; padding:14px 22px; border-bottom:1px solid #e6edf5; }
|
||||
.asset-history-detail-tools label { display:flex; align-items:center; gap:8px; font-size:12px; color:#64748b; }
|
||||
.asset-history-detail-tools select { border:1px solid #dce5f0; border-radius:7px; padding:8px; color:#183454; background:white; }
|
||||
.asset-history-search { border:1px solid #dce5f0; border-radius:7px; padding:6px 10px; flex:1; min-width:170px; }
|
||||
.asset-history-search input { width:100%; min-width:0; padding:3px 0; outline:none; color:#183454; }
|
||||
.asset-history-detail-body { overflow:auto; flex:1; min-height:100px; }
|
||||
.asset-history-detail-note { font-size:12px; line-height:1.8; padding:12px 22px; color:#64748b; background:#f8fbff; }
|
||||
.asset-history-detail-table { overflow:auto; }
|
||||
.asset-history-detail-table table { width:100%; border-collapse:collapse; min-width:650px; font-size:12px; }
|
||||
.asset-history-detail-table th { text-align:left; background:#f7faff; color:#64748b; font-weight:500; padding:12px 20px; }
|
||||
.asset-history-detail-table td { vertical-align:top; padding:14px 20px; border-bottom:1px solid #edf2f8; max-width:280px; overflow-wrap:anywhere; }
|
||||
.asset-history-detail-table td strong { font-size:13px; font-weight:650; }.asset-history-detail-table small { display:block; font-size:11px; color:#64748b; margin-top:5px; }
|
||||
.asset-history-mobile-vehicles { display:none; }
|
||||
.asset-history-dialog footer { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px 22px; border-top:1px solid #e6edf5; color:#64748b; font-size:12px; }
|
||||
.asset-history-dialog footer>div { display:flex; gap:8px; }.asset-history-dialog footer button { padding:9px; border:1px solid #dce5f0; border-radius:7px; }
|
||||
@media(max-width:640px) {
|
||||
.asset-history { padding:12px; border-radius:16px; }
|
||||
.asset-history-header h2 { font-size:18px; }.asset-history-modes button { padding:7px 15px; min-height:38px; }
|
||||
.asset-history-toolbar { gap:6px; }.asset-history-toolbar>span { font-size:10px; }
|
||||
.asset-history-chart { height:170px; }
|
||||
.asset-history-selected { grid-template-columns:72px minmax(0,1fr); gap:0 6px; padding:8px 10px; }.asset-history-selected-heading { gap:2px; }.asset-history-selected-heading>span,.asset-history-selected-heading>time { font-size:10px; }
|
||||
.asset-history-counts strong { font-size:21px; }.asset-history-counts>div>span { font-size:11px; }
|
||||
.asset-history-overlay { padding:10px; }.asset-history-dialog { max-height:94dvh; border-radius:14px; }
|
||||
.asset-history-dialog>header { padding:14px; }.asset-history-dialog h2 { font-size:16px; }.asset-history-detail-tools { padding:12px 14px; }
|
||||
.asset-history-detail-note { padding:10px 14px; }.asset-history-dialog :is(input,select) { font-size:16px; }.asset-history-dialog footer { padding:12px 14px; }
|
||||
.asset-history-dialog :is(header,footer) button { min-height:44px; min-width:44px; }
|
||||
.asset-history-detail-table { display:none; }
|
||||
.asset-history-mobile-vehicles { display:block; padding:0 14px; }
|
||||
.asset-history-mobile-vehicles article { padding:16px 0; border-bottom:1px solid #e6edf5; overflow-wrap:anywhere; }
|
||||
.asset-history-mobile-vehicle-heading { display:flex; justify-content:space-between; align-items:center; gap:10px; }
|
||||
.asset-history-mobile-vehicle-heading strong { font-size:14px; font-weight:700; }.asset-history-mobile-vehicle-heading span { font-size:11px; color:#1559e8; background:#edf5ff; border-radius:5px; padding:3px 8px; }
|
||||
.asset-history-mobile-vehicles p { margin-top:6px; font-size:12px; color:#475569; }.asset-history-mobile-vehicles small { display:block; font-size:11px; color:#64748b; margin-top:5px; }
|
||||
.asset-history-mobile-vehicles dl { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:12px 0; font-size:12px; }
|
||||
.asset-history-mobile-vehicles dt { font-size:11px; color:#64748b; margin-bottom:5px; }.asset-history-mobile-vehicles dd { margin:0; }
|
||||
}
|
||||
|
||||
@media(min-width:900px) {
|
||||
.asset-history-controls { display:flex; align-items:center; flex-wrap:wrap; gap:8px 24px; }
|
||||
.asset-history-header { flex:1; gap:16px; }
|
||||
.asset-history-toolbar { margin:0; }
|
||||
.asset-history-selected { display:grid; grid-template-columns:minmax(150px,1fr) 3fr; align-items:center; gap:0 18px; }
|
||||
.asset-history-selected-heading { display:grid; grid-template-columns:auto 1fr; align-items:center; gap:2px 12px; margin:0; }
|
||||
.asset-history-selected-heading>strong { grid-row:span 2; }
|
||||
.asset-history-selected-heading>span,.asset-history-selected-heading>time { grid-column:2; }
|
||||
.asset-history-selected>p { grid-column:1 / -1; }
|
||||
}
|
||||
@media(max-width:640px) {
|
||||
.asset-history-modes button,.asset-history-refresh { min-height:44px; }
|
||||
.asset-history-refresh { min-width:44px; }
|
||||
.asset-history-toolbar { gap:4px; }
|
||||
}
|
||||
|
||||
.asset-history-comparison { grid-column:1 / -1; display:grid; grid-template-columns:90px minmax(0,1fr); gap:10px; align-items:center; margin-top:6px; padding-top:6px; border-top:1px solid #d4e2f4; font-size:11px; color:#64748b; }
|
||||
.asset-history-comparison>div { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); text-align:center; font-size:13px; font-weight:600; }
|
||||
@media(min-width:900px) {
|
||||
.asset-history-comparison { grid-template-columns:minmax(150px,1fr) 3fr; gap:18px; }
|
||||
}
|
||||
@media(max-width:640px) {
|
||||
.asset-history-comparison { grid-template-columns:72px minmax(0,1fr); gap:6px; }
|
||||
}
|
||||
|
||||
.asset-history-select { display:inline-flex; align-items:center; justify-content:space-between; gap:12px; min-width:76px; min-height:36px; padding:7px 11px; border:1px solid #dce5f1; border-radius:9px; background:#fff; color:#254365; font-size:13px; font-weight:550; box-shadow:0 1px 2px #173c6310; transition:border-color .15s,box-shadow .15s; }
|
||||
.asset-history-select[data-state=open] { border-color:#8fb4f2; box-shadow:0 0 0 3px #dbeaff80; }
|
||||
.asset-history-select-icon { color:#8b9db5; display:flex; transition:transform .15s; }
|
||||
.asset-history-select[data-state=open] .asset-history-select-icon { transform:rotate(180deg); }
|
||||
.asset-history-select-menu { z-index:160; min-width:max(132px,var(--radix-select-trigger-width)); max-height:min(320px,var(--radix-select-content-available-height)); max-width:calc(100vw - 20px); overflow:hidden; padding:4px; border:1px solid #e0e8f4; border-radius:12px; background:#fff; color:#254365; box-shadow:0 12px 32px #18365420,0 2px 6px #18365408; }
|
||||
.asset-history-select-options { padding:2px; }
|
||||
.asset-history-select-option { position:relative; display:flex; align-items:center; gap:16px; min-height:36px; padding:8px 30px 8px 10px; border-radius:7px; outline:none; font-size:13px; cursor:pointer; user-select:none; }
|
||||
.asset-history-select-option[data-state=checked] { color:#1559e8; background:#f0f6ff; font-weight:600; }
|
||||
.asset-history-select-option[data-highlighted] { background:#eaf2ff; color:#1559e8; }
|
||||
.asset-history-option-detail { margin-left:auto; color:#7b8da5; font-size:11px; font-weight:400; }
|
||||
.asset-history-option-check { position:absolute; right:9px; display:flex; color:#2563eb; }
|
||||
.asset-history-select-scroll { display:flex; align-items:center; justify-content:center; height:24px; color:#7b8da5; }
|
||||
.asset-history-detail-button>svg { padding:3px; width:24px; height:24px; border-radius:7px; background:#eaf2ff; color:#527eb7; }
|
||||
@media(hover:hover) {
|
||||
.asset-history-modes button:not([aria-pressed=true]):hover { color:#1559e8; background:#e5edf9; }
|
||||
.asset-history-select:hover { border-color:#adc5e8; }
|
||||
.asset-history-refresh:hover { color:#2563eb; background:#eef5ff; }
|
||||
.asset-history-detail-button:hover { background:#eef5ff; border-color:#bcd3f3; }
|
||||
}
|
||||
@media(max-width:640px) {
|
||||
.asset-history-select { min-height:44px; padding:8px 10px; font-size:12px; gap:10px; }
|
||||
.asset-history-select-option { min-height:44px; }
|
||||
}
|
||||
@media(prefers-reduced-motion:reduce) {
|
||||
.asset-history-modes button,.asset-history-select,.asset-history-select-icon,.asset-history-refresh { transition:none; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { defaultHistoryPeriod, displayHistoryCounts, displayHistoryDifference, signedCount, updateLivePoint } from './presentation.js';
|
||||
import { makePoint } from '../../../server/routes/asset-history/model.js';
|
||||
import { testBatch } from '../../../server/routes/asset-history/fixtures.js';
|
||||
|
||||
test('selection prefers live then latest archive; missing is never a numerical zero',()=>{
|
||||
const now=new Date('2026-10-19T00:00Z');
|
||||
const archived=makePoint('2026-09',now,testBatch('2026-09'),[]);
|
||||
const live=makePoint('2026-10',now);const missing=makePoint('2026-08',now);
|
||||
assert.equal(defaultHistoryPeriod([missing,archived,live]),'2026-10');
|
||||
assert.equal(defaultHistoryPeriod([missing,archived]),'2026-09');
|
||||
assert.equal(archived.counts!.total,0);assert.equal(missing.counts,null);
|
||||
assert.equal(signedCount(3),'+3');assert.equal(signedCount(-3),'-3');
|
||||
});
|
||||
test('estimated counts keep total unknown and live refresh cannot compare against a reconstructed cohort',()=>{
|
||||
const now=new Date('2026-10-19T00:00Z');
|
||||
const estimate={...makePoint('2026-09',now),state:'RECONSTRUCTED' as const,source:'RECONSTRUCTED' as const,
|
||||
reconstruction:{operating:3,inventory:0,unclassified:2,currentlyExited:1,sourceAsOf:'2026-10-19T14:38:33+08:00',sourceReference:'核对表',workbookSha256:'a'.repeat(64),difference:{operating:1,inventory:-1}}};
|
||||
assert.deepEqual(displayHistoryCounts(estimate),{total:null,operating:3,inventory:0});assert.equal(displayHistoryDifference(estimate,'total'),null);
|
||||
assert.equal(displayHistoryDifference(estimate,'inventory'),-1);assert.equal(defaultHistoryPeriod([estimate]),'2026-09');
|
||||
const live=makePoint('2026-10',now);live.counts={total:5,operating:2,inventory:3};
|
||||
assert.equal(updateLivePoint([estimate,live],live)[1].difference,null);
|
||||
});
|
||||
test('live refresh preserves January comparison with December outside visible year',()=>{
|
||||
const now=new Date('2026-01-19T00:00Z');const initial=makePoint('2026-01',now);
|
||||
initial.counts={total:10,operating:6,inventory:4};initial.difference={total:2,operating:1,inventory:1};
|
||||
const refreshed={...initial,counts:{total:12,operating:7,inventory:5},difference:null};
|
||||
const result=updateLivePoint([initial],refreshed)[0];
|
||||
assert.deepEqual(result.difference,{total:4,operating:2,inventory:2});assert.equal(result.comparisonLabel,'截至当前较上月末');
|
||||
});
|
||||
test('live refresh cannot mutate an archive, bridge a hole or overwrite a different period',()=>{
|
||||
const now=new Date('2026-10-19T00:00Z');const archive=makePoint('2026-09',now,testBatch('2026-09'),[]);
|
||||
const live=makePoint('2026-10',now);const missing=makePoint('2026-08',now);
|
||||
assert.equal(updateLivePoint([archive],live)[0],archive);
|
||||
assert.equal(updateLivePoint([missing,live],{...live,counts:{total:9,operating:4,inventory:5}})[1].difference,null);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { HistoryPoint } from '../../../shared/assets/history';
|
||||
|
||||
/** Accessible period buttons and chart clicks share one selection model. */
|
||||
export function defaultHistoryPeriod(points: HistoryPoint[]): string | null {
|
||||
return points.find(p => p.state === 'LIVE')?.period || [...points].reverse().find(p => p.state === 'ARCHIVED' || p.state === 'RECONSTRUCTED')?.period || points.at(-1)?.period || null;
|
||||
}
|
||||
export function updateLivePoint(points: HistoryPoint[], live: HistoryPoint): HistoryPoint[] {
|
||||
return points.map((point, index) => {
|
||||
if (point.state !== 'LIVE' || point.period !== live.period) return point;
|
||||
if (points[index - 1]?.state === 'RECONSTRUCTED') return { ...live, difference: null, comparisonLabel: '推算与实时或正式快照口径不同,暂无可比数据' };
|
||||
const baseline = points[index - 1]?.counts || (point.counts && point.difference ? {
|
||||
total: point.counts.total - point.difference.total,
|
||||
operating: point.counts.operating - point.difference.operating,
|
||||
inventory: point.counts.inventory - point.difference.inventory,
|
||||
} : null);
|
||||
return { ...live, difference: baseline && live.counts ? {
|
||||
total: live.counts.total - baseline.total,
|
||||
operating: live.counts.operating - baseline.operating,
|
||||
inventory: live.counts.inventory - baseline.inventory,
|
||||
} : null, comparisonLabel: baseline ? `截至当前较${live.period.length === 4 ? '上年末' : '上月末'}` : '暂无可比数据' };
|
||||
});
|
||||
}
|
||||
export const signedCount = (value: number) => value > 0 ? `+${value.toLocaleString()}` : value.toLocaleString();
|
||||
export function displayHistoryCounts(point: HistoryPoint) {
|
||||
return point.reconstruction ? { total: null, operating: point.reconstruction.operating, inventory: point.reconstruction.inventory } : point.counts;
|
||||
}
|
||||
export function displayHistoryDifference(point: HistoryPoint, key: 'total' | 'operating' | 'inventory'): number | null {
|
||||
return point.reconstruction ? key === 'total' ? null : point.reconstruction.difference?.[key] ?? null : point.difference?.[key] ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user