const { useEffect, useMemo, useState } = React; const CONFIG = window.ADMIN_CONFIG || {}; const STORAGE_KEY = 'ur_admin_auth_v1'; const DEFAULT_ADMIN_HASH = 'bc1daab2688c7bffd8f764426ac81028e8b4288f07522aa9dbf61555fae6c849'; const NAV_ITEMS = [ { key: 'stats', title: '统计', copy: '总览供应、地区分布与同步状态', emoji: '◫' }, { key: 'logs', title: '查看日志', copy: '审计每一轮 refresh 的结果与变化', emoji: '◭' }, ]; function getSession() { try { return JSON.parse(sessionStorage.getItem(STORAGE_KEY) || 'null'); } catch (error) { return null; } } function setSession(nextValue) { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(nextValue)); } function clearSession() { sessionStorage.removeItem(STORAGE_KEY); } function compactNumber(value) { if (!Number.isFinite(value)) return '0'; return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: value > 100 ? 0 : 1 }).format(value); } function formatPercent(value) { if (!Number.isFinite(value)) return '0%'; return `${(value * 100).toFixed(value >= 0.1 ? 1 : 2)}%`; } function formatDateTime(value) { if (!value) return '未提供'; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }).format(date); } function formatRelative(value) { if (!value) return '未提供'; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; const diffMinutes = Math.round((date.getTime() - Date.now()) / 60000); const formatter = new Intl.RelativeTimeFormat('zh-CN', { numeric: 'auto' }); if (Math.abs(diffMinutes) < 60) return formatter.format(diffMinutes, 'minute'); const diffHours = Math.round(diffMinutes / 60); if (Math.abs(diffHours) < 24) return formatter.format(diffHours, 'hour'); const diffDays = Math.round(diffHours / 24); return formatter.format(diffDays, 'day'); } function buildQuery(params = {}) { const search = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { if (value === undefined || value === null || value === '') return; search.set(key, String(value)); }); const query = search.toString(); return query ? `?${query}` : ''; } async function apiFetch(path, params = {}) { const base = CONFIG.apiBase || 'https://api.repilot.jp'; const token = CONFIG.apiToken || ''; const url = base + path + buildQuery(params); const response = await fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (!response.ok) { const text = await response.text(); throw new Error(text || `API ${response.status}`); } return response.json(); } async function sha256(value) { const data = new TextEncoder().encode(value); const hashBuffer = await crypto.subtle.digest('SHA-256', data); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map((item) => item.toString(16).padStart(2, '0')).join(''); } function toneForStatus(status) { if (status === 'success') return 'success'; if (status === 'failed') return 'danger'; return 'neutral'; } function labelForStatus(status) { if (status === 'success') return '成功'; if (status === 'failed') return '失败'; if (status === 'running') return '进行中'; return status || '未知'; } function summarizeChanges(run) { const summary = run?.summary || {}; return { listedRooms: summary.listedRoomCount || 0, delistedRooms: summary.delistedRoomCount || 0, listedProperties: summary.listedPropertyCount || 0, delistedProperties: summary.delistedPropertyCount || 0, activeRoomDelta: summary.activeRoomDelta || 0, }; } function entryLabel(entry) { if (!entry) return '未命名记录'; return entry.displayName || entry.propertyName || entry.roomName || entry.propertyKey || entry.roomKey || entry.canonicalRoomId || '未命名记录'; } function renderTrigger(trigger) { if (trigger === 'manual') return '手动'; if (trigger === 'scheduled') return '定时'; return trigger || '未标注'; } function getDefaultSetupActive() { const users = CONFIG.adminUsers || []; return users.length === 1 && users[0].username === 'admin' && users[0].passwordHash === DEFAULT_ADMIN_HASH; } function App() { const [session, setSessionState] = useState(getSession()); const [view, setView] = useState('stats'); const [statsState, setStatsState] = useState({ loading: false, error: '', data: null }); const [logsState, setLogsState] = useState({ loading: false, error: '', data: null, filters: { regionKey: '', trigger: '', changedOnly: false, since: '', until: '', }, }); const authed = Boolean(session?.username); useEffect(() => { if (!authed) return; refreshStats(); refreshLogs(logsState.filters); }, [authed]); async function refreshStats() { setStatsState((current) => ({ ...current, loading: true, error: '' })); try { const [health, summary, regions, latestLog, allLogs] = await Promise.all([ apiFetch('/health'), apiFetch('/api/v1/summary'), apiFetch('/api/v1/regions'), apiFetch('/api/v1/refresh-logs/latest'), apiFetch('/api/v1/refresh-logs'), ]); setStatsState({ loading: false, error: '', data: { health, summary, regions: regions?.data || [], latestLog, logs: allLogs?.data || [], }, }); } catch (error) { setStatsState({ loading: false, error: error.message || '加载统计失败', data: null }); } } async function refreshLogs(filters) { setLogsState((current) => ({ ...current, loading: true, error: '' })); try { const params = { regionKey: filters.regionKey, trigger: filters.trigger, changedOnly: filters.changedOnly ? 1 : '', since: filters.since ? new Date(filters.since).toISOString() : '', until: filters.until ? new Date(filters.until).toISOString() : '', }; const response = await apiFetch('/api/v1/refresh-logs', params); setLogsState((current) => ({ ...current, loading: false, error: '', data: response?.data || [], })); } catch (error) { setLogsState((current) => ({ ...current, loading: false, error: error.message || '加载日志失败', data: [], })); } } function handleLogin(nextSession) { setSession(nextSession); setSessionState(nextSession); } function handleLogout() { clearSession(); setSessionState(null); } if (!authed) { return ; } return (
refreshLogs(logsState.filters)} refreshing={view === 'stats' ? statsState.loading : logsState.loading} /> {view === 'stats' ? ( ) : ( setLogsState((current) => ({ ...current, filters }))} onApplyFilters={refreshLogs} /> )}
); } function Sidebar({ activeView, onChangeView, onLogout, latestLog }) { return ( ); } function Topbar({ view, username, latestLog, health, onRefresh, refreshing }) { const failed = latestLog?.status === 'failed' || Boolean(health?.lastRefreshError); return (

{view === 'stats' ? 'UR 运营统计台' : 'Refresh Logs 审计台'}

{view === 'stats' ? '聚焦当前 UR 全局供应、地区分布和同步健康度。' : '筛选每次 refresh 的执行结果,定位新增、下架和异常原因。'}

{failed ? '最近一次刷新异常' : '最近一次刷新正常'} 管理员 {username}
); } function StatsPage({ state }) { const data = state.data; const derived = useMemo(() => { if (!data) return null; const summary = data.summary?.summary || {}; const regions = data.regions || []; const areas = data.summary?.areas || []; const latest = data.latestLog; const totalCatalogProperties = summary.totalCatalogProperties || 0; const totalProperties = summary.totalProperties || 0; const totalAvailableRooms = summary.totalAvailableRooms || 0; const totalRegions = summary.totalRegions || regions.length || 0; const totalAreasWithVacancy = summary.totalAreasWithVacancy || 0; const activeRegions = regions.filter((item) => (item.availableRoomCount || 0) > 0); const silentRegions = regions.filter((item) => (item.availableRoomCount || 0) === 0); const topRegions = [...regions] .sort((left, right) => (right.availableRoomCount || 0) - (left.availableRoomCount || 0)) .slice(0, 8); const topAreas = [...areas] .sort((left, right) => (right.availableRoomCount || 0) - (left.availableRoomCount || 0)) .slice(0, 6); const largestRegion = topRegions[0]; const topThreeShare = totalAvailableRooms ? topRegions.slice(0, 3).reduce((sum, item) => sum + (item.availableRoomCount || 0), 0) / totalAvailableRooms : 0; return { totalCatalogProperties, totalProperties, totalAvailableRooms, totalRegions, totalAreasWithVacancy, activeRegions, silentRegions, topRegions, topAreas, largestRegion, vacancyCoverage: totalCatalogProperties ? totalProperties / totalCatalogProperties : 0, avgRoomsPerProperty: totalProperties ? totalAvailableRooms / totalProperties : 0, floorplanCoverage: totalAvailableRooms ? (summary.totalCachedFloorplanImages || 0) / totalAvailableRooms : 0, topThreeShare, latest, summary, health: data.health, }; }, [data]); if (state.loading && !data) { return
正在加载统计数据…
; } if (state.error) { return
{state.error}
; } if (!derived) { return
暂时没有可展示的数据。
; } const latestLabel = derived.latest ? `${labelForStatus(derived.latest.status)} · ${renderTrigger(derived.latest.trigger)}` : '暂无最近刷新记录'; return (
{getDefaultSetupActive() ? (
当前后台使用的是前端门禁版登录,且仍保留默认账号配置。正式上线前请先修改 `admin/config.js` 中的管理员账号,并尽快把认证迁移到服务端或接入受保护网关。
) : null}

一眼看清当前 UR 的供应结构与同步健康度

当前共跟踪 {compactNumber(derived.totalCatalogProperties)} 个物业目录,其中 {compactNumber(derived.totalProperties)} 个物业存在空房,合计 {compactNumber(derived.totalAvailableRooms)} 套可租房源。重点供应集中在 {derived.largestRegion?.regionName || '暂无地区'} ,最近一次 refresh 状态为 {latestLabel}
最后完成刷新
{formatDateTime(derived.health?.lastRefreshFinishedAt)}
下次计划刷新
{formatDateTime(derived.health?.nextScheduledAt)}
时间感知
{formatRelative(derived.health?.nextScheduledAt)}

地区供应排行

按当前空房套数排序,快速看到供给最集中的地区。
{derived.topRegions.length} 个地区
{derived.topRegions.map((item) => ( ))}

分区热点

从 area 维度看哪几个细分区域当前最有供给。
Top {derived.topAreas.length}
{derived.topAreas.map((item) => ( ))}

同步健康

后台服务可用性、最近 refresh 结果和错误提示。

值得关注的补充统计

除了总量之外,更适合运营判断供给结构和集中度。
当前无空房的地区
{derived.silentRegions.length ? derived.silentRegions.map((item) => ( {item.regionName} )) : 暂无}
); } function LogsPage({ state, regions, onChangeFilters, onApplyFilters }) { const data = state.data || []; const summary = useMemo(() => { const changedRuns = data.filter((run) => run?.summary?.hasChanges); const successRuns = data.filter((run) => run?.status === 'success'); const failedRuns = data.filter((run) => run?.status === 'failed'); return { total: data.length, changed: changedRuns.length, success: successRuns.length, failed: failedRuns.length, }; }, [data]); return (
onChangeFilters({ ...state.filters, since: event.target.value })} />
onChangeFilters({ ...state.filters, until: event.target.value })} />
{state.error ?
{state.error}
: null} {!state.loading && !data.length ? (
当前筛选条件下没有 refresh 日志。
) : null}
{data.map((run) => ( ))}
); } function RunCard({ run }) { const counts = summarizeChanges(run); const regions = run?.changes?.regions || []; const listedRooms = run?.changes?.rooms?.listed || []; const delistedRooms = run?.changes?.rooms?.delisted || []; const listedProperties = run?.changes?.properties?.listed || []; const delistedProperties = run?.changes?.properties?.delisted || []; return (
{labelForStatus(run.status)} {renderTrigger(run.trigger)}

{formatDateTime(run.startedAt)}

Run ID {run.runId} 更新时间 {formatDateTime(run.updatedAt)} {run.summary?.hasChanges ? '本轮有变化' : '本轮无变化'}
{regions.length ? regions.slice(0, 5).map((item) => ( {item.regionName || item.regionKey || item} )) : 未标注地区}
{run.error?.message ? (
错误信息: {run.error.message}
) : null}
); } function ChangeBlock({ title, items }) { return (
{title}
{items.length ? (
{items.slice(0, 4).map((item, index) => (
{entryLabel(item)}
))} {items.length > 4 ? (
还有 {items.length - 4} 条未展开
) : null}
) : (
无记录
)}
); } function MetricCard({ label, value, copy, pill }) { return (
{label}
{value}
{copy}
{pill}
); } function FactCard({ title, value, copy }) { return (
{title}
{value}
{copy}
); } function LeaderboardRow({ name, sub, value, total }) { const ratio = total ? Math.max(6, ((value || 0) / total) * 100) : 0; return (
{name}
{sub}
{compactNumber(value)} 套
); } function SummaryCard({ title, value, copy }) { return (
{title}
{value}
{copy}
); } function RunStat({ label, value }) { const tone = value > 0 ? 'var(--accent-2)' : 'var(--ink)'; return (
{label}
{value > 0 ? `+${compactNumber(value)}` : compactNumber(value)}
); } function LoginScreen({ onLogin }) { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(''); async function handleSubmit(event) { event.preventDefault(); setSubmitting(true); setError(''); try { const users = CONFIG.adminUsers || []; const matched = users.find((item) => item.username === username.trim()); if (!matched) { throw new Error('用户名或密码不正确'); } const passwordHash = await sha256(password); if (passwordHash !== matched.passwordHash) { throw new Error('用户名或密码不正确'); } onLogin({ username: matched.username, loggedAt: new Date().toISOString(), }); } catch (nextError) { setError(nextError.message || '登录失败'); } finally { setSubmitting(false); } } return (
Private Console

UR Admin Console

独立于前台站点的后台入口,用于查看 refresh 审计日志、全局物业供应统计,以及当前同步健康状态。

可查看
Refresh Logs
可查看
物业 / 空房统计
当前方式
前端门禁登录

管理员登录

输入用户名和密码后进入后台。

setUsername(event.target.value)} placeholder="请输入用户名" />
setPassword(event.target.value)} placeholder="请输入密码" />
{getDefaultSetupActive() ? (
当前仍在使用默认账号配置。开发联调可先使用默认账号,正式部署前请修改 `admin/config.js`
) : null} {error ?
{error}
: null}
说明:当前仓库是纯静态站,因此这里实现的是前端登录门禁。若要做到真正的安全隔离,建议把登录校验和 API token 一起迁移到服务端。
); } ReactDOM.createRoot(document.getElementById('root')).render();