feat(platform): add filtered vehicle coverage summary
This commit is contained in:
@@ -29,6 +29,7 @@ func (h *Handler) routes() {
|
||||
h.mux.HandleFunc("GET /api/vehicles", h.handleVehicles)
|
||||
h.mux.HandleFunc("GET /api/vehicles/resolve", h.handleVehicleResolve)
|
||||
h.mux.HandleFunc("GET /api/vehicles/coverage", h.handleVehicleCoverage)
|
||||
h.mux.HandleFunc("GET /api/vehicles/coverage/summary", h.handleVehicleCoverageSummary)
|
||||
h.mux.HandleFunc("GET /api/vehicle-service", h.handleVehicleDetail)
|
||||
h.mux.HandleFunc("GET /api/vehicle-service/summary", h.handleVehicleServiceSummary)
|
||||
h.mux.HandleFunc("GET /api/vehicle-service/overview", h.handleVehicleServiceOverview)
|
||||
@@ -72,6 +73,11 @@ func (h *Handler) handleVehicleCoverage(w http.ResponseWriter, r *http.Request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleVehicleCoverageSummary(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.VehicleCoverageSummary(r.Context(), r.URL.Query())
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleVehicleServiceSummary(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.VehicleServiceSummary(r.Context())
|
||||
h.write(w, r, data, err)
|
||||
|
||||
@@ -109,6 +109,28 @@ func TestHandlerVehicleCoverageFiltersServiceStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerVehicleCoverageSummary(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/vehicles/coverage/summary?coverage=multi", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Data VehicleCoverageSummary `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if body.Data.TotalVehicles != 1 || body.Data.MultiSourceVehicles != 1 {
|
||||
t.Fatalf("coverage summary should honor filters, got %+v body=%s", body.Data, rec.Body.String())
|
||||
}
|
||||
if body.Data.OnlineVehicles != 1 || body.Data.UnboundVehicles != 0 {
|
||||
t.Fatalf("coverage summary should expose filtered online and binding totals, got %+v", body.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerVehicleDetail(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -141,6 +141,32 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V
|
||||
return page(items, query), nil
|
||||
}
|
||||
|
||||
func (m *MockStore) VehicleCoverageSummary(ctx context.Context, query url.Values) (VehicleCoverageSummary, error) {
|
||||
allQuery := copyValues(query)
|
||||
allQuery.Set("limit", "100000")
|
||||
allQuery.Set("offset", "0")
|
||||
coverage, err := m.VehicleCoverage(ctx, allQuery)
|
||||
if err != nil {
|
||||
return VehicleCoverageSummary{}, err
|
||||
}
|
||||
summary := VehicleCoverageSummary{TotalVehicles: coverage.Total}
|
||||
for _, row := range coverage.Items {
|
||||
if row.Online {
|
||||
summary.OnlineVehicles++
|
||||
}
|
||||
if row.SourceCount == 1 {
|
||||
summary.SingleSourceVehicles++
|
||||
}
|
||||
if row.SourceCount > 1 {
|
||||
summary.MultiSourceVehicles++
|
||||
}
|
||||
if row.BindingStatus != "bound" {
|
||||
summary.UnboundVehicles++
|
||||
}
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) VehicleServiceSummary(ctx context.Context) (VehicleServiceSummary, error) {
|
||||
coverage, err := m.VehicleCoverage(ctx, url.Values{"limit": {"10000"}})
|
||||
if err != nil {
|
||||
@@ -658,6 +684,14 @@ func keepServiceStatus(status *VehicleServiceStatus, raw string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func copyValues(values url.Values) url.Values {
|
||||
next := url.Values{}
|
||||
for key, items := range values {
|
||||
next[key] = append([]string(nil), items...)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func parsePositive(raw string, fallback int) int {
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 0 {
|
||||
|
||||
@@ -63,6 +63,14 @@ type VehicleCoverageRow struct {
|
||||
ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"`
|
||||
}
|
||||
|
||||
type VehicleCoverageSummary struct {
|
||||
TotalVehicles int `json:"totalVehicles"`
|
||||
OnlineVehicles int `json:"onlineVehicles"`
|
||||
SingleSourceVehicles int `json:"singleSourceVehicles"`
|
||||
MultiSourceVehicles int `json:"multiSourceVehicles"`
|
||||
UnboundVehicles int `json:"unboundVehicles"`
|
||||
}
|
||||
|
||||
type VehicleIdentityResolution struct {
|
||||
LookupKey string `json:"lookupKey"`
|
||||
Resolved bool `json:"resolved"`
|
||||
|
||||
@@ -142,6 +142,77 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
|
||||
}
|
||||
}
|
||||
|
||||
func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
|
||||
args := []any{}
|
||||
where := []string{"s.vin IS NOT NULL", "s.vin <> ''"}
|
||||
having := []string{}
|
||||
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
|
||||
where = append(where, "(s.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + keyword + "%"
|
||||
args = append(args, like, like, like, like, like, like)
|
||||
}
|
||||
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where = append(where, "s.protocol = ?")
|
||||
args = append(args, protocol)
|
||||
}
|
||||
switch strings.TrimSpace(query.Get("coverage")) {
|
||||
case "single":
|
||||
having = append(having, "COUNT(DISTINCT s.protocol) = 1")
|
||||
case "multi":
|
||||
having = append(having, "COUNT(DISTINCT s.protocol) > 1")
|
||||
}
|
||||
switch strings.TrimSpace(query.Get("online")) {
|
||||
case "online":
|
||||
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) > 0")
|
||||
case "offline":
|
||||
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) = 0")
|
||||
}
|
||||
switch strings.TrimSpace(query.Get("bindingStatus")) {
|
||||
case "bound":
|
||||
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
|
||||
case "unbound":
|
||||
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 0")
|
||||
}
|
||||
switch strings.TrimSpace(query.Get("serviceStatus")) {
|
||||
case "identity_required":
|
||||
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 0")
|
||||
case "offline":
|
||||
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
|
||||
having = append(having, "COUNT(DISTINCT s.protocol) > 0")
|
||||
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) = 0")
|
||||
case "degraded":
|
||||
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
|
||||
having = append(having, "COUNT(DISTINCT s.protocol) > 0")
|
||||
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) > 0")
|
||||
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) < COUNT(DISTINCT s.protocol)")
|
||||
case "healthy":
|
||||
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
|
||||
having = append(having, "COUNT(DISTINCT s.protocol) > 0")
|
||||
having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) = COUNT(DISTINCT s.protocol)")
|
||||
}
|
||||
havingSQL := ""
|
||||
if len(having) > 0 {
|
||||
havingSQL = ` HAVING ` + strings.Join(having, " AND ") + ` `
|
||||
}
|
||||
groupSQL := `SELECT s.vin, ` +
|
||||
`COUNT(DISTINCT s.protocol) AS source_count, ` +
|
||||
`COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) AS online_source_count, ` +
|
||||
`MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) AS bound ` +
|
||||
`FROM vehicle_realtime_snapshot s ` +
|
||||
`LEFT JOIN vehicle_identity_binding b ON b.vin = s.vin ` +
|
||||
`WHERE ` + strings.Join(where, " AND ") + ` ` +
|
||||
`GROUP BY s.vin ` + havingSQL
|
||||
return SQLQuery{
|
||||
Text: `SELECT COUNT(*) AS total_vehicles, ` +
|
||||
`COALESCE(SUM(CASE WHEN online_source_count > 0 THEN 1 ELSE 0 END), 0) AS online_vehicles, ` +
|
||||
`COALESCE(SUM(CASE WHEN source_count = 1 THEN 1 ELSE 0 END), 0) AS single_source_vehicles, ` +
|
||||
`COALESCE(SUM(CASE WHEN source_count > 1 THEN 1 ELSE 0 END), 0) AS multi_source_vehicles, ` +
|
||||
`COALESCE(SUM(CASE WHEN bound = 0 THEN 1 ELSE 0 END), 0) AS unbound_vehicles ` +
|
||||
`FROM (` + groupSQL + `) vehicle_coverage_summary`,
|
||||
Args: args,
|
||||
}
|
||||
}
|
||||
|
||||
func buildRealtimeLocationSQL(query url.Values) SQLQuery {
|
||||
limit := parsePositive(query.Get("limit"), 20)
|
||||
offset := parsePositive(query.Get("offset"), 0)
|
||||
|
||||
@@ -256,6 +256,21 @@ func (s *ProductionStore) VehicleCoverage(ctx context.Context, query url.Values)
|
||||
return Page[VehicleCoverageRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) VehicleCoverageSummary(ctx context.Context, query url.Values) (VehicleCoverageSummary, error) {
|
||||
built := buildVehicleCoverageSummarySQL(query)
|
||||
var summary VehicleCoverageSummary
|
||||
if err := s.db.QueryRowContext(ctx, built.Text, built.Args...).Scan(
|
||||
&summary.TotalVehicles,
|
||||
&summary.OnlineVehicles,
|
||||
&summary.SingleSourceVehicles,
|
||||
&summary.MultiSourceVehicles,
|
||||
&summary.UnboundVehicles,
|
||||
); err != nil {
|
||||
return VehicleCoverageSummary{}, err
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) {
|
||||
built := buildVehicleRealtimeSQL(query)
|
||||
total := 0
|
||||
|
||||
@@ -83,6 +83,30 @@ func TestBuildVehicleCoverageSQLFiltersServiceStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleCoverageSummarySQL(t *testing.T) {
|
||||
query := url.Values{"keyword": {"粤A"}, "coverage": {"multi"}, "online": {"online"}, "bindingStatus": {"bound"}, "serviceStatus": {"healthy"}}
|
||||
built := buildVehicleCoverageSummarySQL(query)
|
||||
for _, want := range []string{
|
||||
"vehicle_realtime_snapshot",
|
||||
"vehicle_identity_binding",
|
||||
"vehicle_coverage_summary",
|
||||
"SUM(CASE WHEN online_source_count > 0 THEN 1 ELSE 0 END)",
|
||||
"SUM(CASE WHEN source_count > 1 THEN 1 ELSE 0 END)",
|
||||
"HAVING",
|
||||
"COUNT(DISTINCT s.protocol) > 1",
|
||||
} {
|
||||
if !strings.Contains(built.Text, want) {
|
||||
t.Fatalf("summary SQL missing %q: %s", want, built.Text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(built.Text, "LIMIT") || strings.Contains(built.Text, "OFFSET") {
|
||||
t.Fatalf("summary SQL should not paginate: %s", built.Text)
|
||||
}
|
||||
if len(built.Args) != 6 || built.Args[0] != "%粤A%" {
|
||||
t.Fatalf("args = %#v", built.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRealtimeLocationSQL(t *testing.T) {
|
||||
query := url.Values{"vin": {"粤A"}, "protocol": {"GB32960"}, "limit": {"10"}, "offset": {"20"}}
|
||||
built := buildRealtimeLocationSQL(query)
|
||||
|
||||
@@ -12,6 +12,7 @@ type Store interface {
|
||||
DashboardSummary(context.Context) (DashboardSummary, error)
|
||||
Vehicles(context.Context, url.Values) (Page[VehicleRow], error)
|
||||
VehicleCoverage(context.Context, url.Values) (Page[VehicleCoverageRow], error)
|
||||
VehicleCoverageSummary(context.Context, url.Values) (VehicleCoverageSummary, error)
|
||||
VehicleServiceSummary(context.Context) (VehicleServiceSummary, error)
|
||||
VehicleRealtime(context.Context, url.Values) (Page[VehicleRealtimeRow], error)
|
||||
RealtimeLocations(context.Context, url.Values) (Page[RealtimeLocationRow], error)
|
||||
@@ -89,6 +90,10 @@ func (s *Service) VehicleCoverage(ctx context.Context, query url.Values) (Page[V
|
||||
return s.store.VehicleCoverage(ctx, query)
|
||||
}
|
||||
|
||||
func (s *Service) VehicleCoverageSummary(ctx context.Context, query url.Values) (VehicleCoverageSummary, error) {
|
||||
return s.store.VehicleCoverageSummary(ctx, query)
|
||||
}
|
||||
|
||||
func (s *Service) VehicleServiceSummary(ctx context.Context) (VehicleServiceSummary, error) {
|
||||
return s.store.VehicleServiceSummary(ctx)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
RealtimeLocationRow,
|
||||
VehicleRealtimeRow,
|
||||
VehicleCoverageRow,
|
||||
VehicleCoverageSummary,
|
||||
VehicleDetail,
|
||||
VehicleIdentityResolution,
|
||||
VehicleServiceOverview,
|
||||
@@ -83,6 +84,7 @@ export const api = {
|
||||
vehicles: (params = new URLSearchParams()) => request<Page<VehicleRow>>(`/api/vehicles?${params.toString()}`),
|
||||
vehicleResolve: (params = new URLSearchParams()) => request<VehicleIdentityResolution>(`/api/vehicles/resolve?${params.toString()}`),
|
||||
vehicleCoverage: (params = new URLSearchParams()) => request<Page<VehicleCoverageRow>>(`/api/vehicles/coverage?${params.toString()}`),
|
||||
vehicleCoverageSummary: (params = new URLSearchParams()) => request<VehicleCoverageSummary>(`/api/vehicles/coverage/summary?${params.toString()}`),
|
||||
vehicleDetail: (params = new URLSearchParams()) => request<VehicleDetail>(`/api/vehicle-service?${params.toString()}`),
|
||||
vehicleServiceSummary: () => request<VehicleServiceSummary>('/api/vehicle-service/summary'),
|
||||
vehicleServiceOverview: (params = new URLSearchParams()) => request<VehicleServiceOverview>(`/api/vehicle-service/overview?${params.toString()}`),
|
||||
|
||||
@@ -60,6 +60,14 @@ export interface VehicleCoverageRow {
|
||||
serviceStatus?: VehicleServiceStatus;
|
||||
}
|
||||
|
||||
export interface VehicleCoverageSummary {
|
||||
totalVehicles: number;
|
||||
onlineVehicles: number;
|
||||
singleSourceVehicles: number;
|
||||
multiSourceVehicles: number;
|
||||
unboundVehicles: number;
|
||||
}
|
||||
|
||||
export interface VehicleIdentityResolution {
|
||||
lookupKey: string;
|
||||
resolved: boolean;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button, Card, Form, Select, Space, Table, Tag, Toast } from '@douyinfe/semi-ui';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { VehicleCoverageRow } from '../api/types';
|
||||
import type { VehicleCoverageRow, VehicleCoverageSummary } from '../api/types';
|
||||
import { DataEmpty } from '../components/DataEmpty';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { StatusTag } from '../components/StatusTag';
|
||||
@@ -30,20 +30,18 @@ function vehicleServiceStatus(row: VehicleCoverageRow) {
|
||||
|
||||
export function Vehicles({ onOpenVehicle, initialFilters = {} }: { onOpenVehicle: (vin: string) => void; initialFilters?: Record<string, string> }) {
|
||||
const [rows, setRows] = useState<VehicleCoverageRow[]>([]);
|
||||
const [summary, setSummary] = useState<VehicleCoverageSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState<Record<string, string>>(initialFilters);
|
||||
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 });
|
||||
const resultSummary = useMemo(() => {
|
||||
const onlineRows = rows.filter((row) => row.online).length;
|
||||
const multiSourceRows = rows.filter((row) => row.sourceCount > 1).length;
|
||||
const unboundRows = rows.filter((row) => row.bindingStatus !== 'bound').length;
|
||||
return [
|
||||
{ label: '过滤车辆', value: pagination.total.toLocaleString() },
|
||||
{ label: '本页在线', value: onlineRows.toLocaleString() },
|
||||
{ label: '本页多源', value: multiSourceRows.toLocaleString() },
|
||||
{ label: '本页待绑定', value: unboundRows.toLocaleString() }
|
||||
{ label: '过滤车辆', value: (summary?.totalVehicles ?? pagination.total).toLocaleString() },
|
||||
{ label: '在线车辆', value: (summary?.onlineVehicles ?? 0).toLocaleString() },
|
||||
{ label: '多源车辆', value: (summary?.multiSourceVehicles ?? 0).toLocaleString() },
|
||||
{ label: '待绑定', value: (summary?.unboundVehicles ?? 0).toLocaleString() }
|
||||
];
|
||||
}, [pagination.total, rows]);
|
||||
}, [pagination.total, summary]);
|
||||
|
||||
const load = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
|
||||
setLoading(true);
|
||||
@@ -54,9 +52,13 @@ export function Vehicles({ onOpenVehicle, initialFilters = {} }: { onOpenVehicle
|
||||
if (values?.online) params.set('online', values.online);
|
||||
if (values?.bindingStatus) params.set('bindingStatus', values.bindingStatus);
|
||||
if (values?.serviceStatus) params.set('serviceStatus', values.serviceStatus);
|
||||
api.vehicleCoverage(params)
|
||||
.then((nextPage) => {
|
||||
const summaryParams = new URLSearchParams(params);
|
||||
summaryParams.delete('limit');
|
||||
summaryParams.delete('offset');
|
||||
Promise.all([api.vehicleCoverage(params), api.vehicleCoverageSummary(summaryParams)])
|
||||
.then(([nextPage, nextSummary]) => {
|
||||
setRows(nextPage.items);
|
||||
setSummary(nextSummary);
|
||||
setPagination({ currentPage: page, pageSize, total: nextPage.total });
|
||||
})
|
||||
.catch((error: Error) => Toast.error(error.message))
|
||||
|
||||
@@ -191,6 +191,22 @@ test('shows vehicle service result summary on vehicle list filters', async () =>
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
if (path.includes('/api/vehicles/coverage/summary')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
totalVehicles: 181,
|
||||
onlineVehicles: 73,
|
||||
singleSourceVehicles: 0,
|
||||
multiSourceVehicles: 181,
|
||||
unboundVehicles: 9
|
||||
},
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
if (path.includes('/api/vehicles/coverage')) {
|
||||
return {
|
||||
ok: true,
|
||||
@@ -238,10 +254,11 @@ test('shows vehicle service result summary on vehicle list filters', async () =>
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText('当前车辆结果')).toBeInTheDocument();
|
||||
expect(screen.getByText('181')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('181').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText('过滤车辆')).toBeInTheDocument();
|
||||
expect(screen.getByText('本页在线')).toBeInTheDocument();
|
||||
expect(screen.getByText('本页多源')).toBeInTheDocument();
|
||||
expect(screen.getByText('73')).toBeInTheDocument();
|
||||
expect(screen.getByText('在线车辆')).toBeInTheDocument();
|
||||
expect(screen.getByText('待绑定')).toBeInTheDocument();
|
||||
expect(screen.getByText('VIN-MULTI-001')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user