data-center.component.html 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  1. <main class="data-center" *ngIf="vm$ | async as vm">
  2. <app-page-header
  3. title="数据中台"
  4. eyebrow="数据资产"
  5. [crumbs]="crumbs">
  6. </app-page-header>
  7. <app-page-toolbar [fields]="toolbarFields(vm.dataset)">
  8. <div pageToolbarActions class="command-actions">
  9. <button class="icon-button" type="button" title="刷新数据" aria-label="刷新数据" [disabled]="refreshing" (click)="refreshAll()">
  10. <lucide-icon [img]="icons.refresh" [size]="17" [class.spinning]="refreshing"></lucide-icon>
  11. </button>
  12. <button class="secondary-button" type="button" (click)="configureSource()">
  13. <lucide-icon [img]="icons.settings" [size]="16"></lucide-icon>
  14. 接入配置
  15. </button>
  16. <button class="primary-button" type="button" (click)="openSync(vm.dataset)">
  17. <lucide-icon [img]="icons.play" [size]="16"></lucide-icon>
  18. 新建同步
  19. </button>
  20. </div>
  21. </app-page-toolbar>
  22. <section class="health-band" [class.health-green]="vm.statusTone === 'green'" [class.health-amber]="vm.statusTone === 'amber'" [class.health-red]="vm.statusTone === 'red'">
  23. <div class="health-main">
  24. <lucide-icon [img]="vm.statusTone === 'green' ? icons.check : icons.alert" [size]="20"></lucide-icon>
  25. <div>
  26. <strong>{{ vm.statusTitle }}</strong>
  27. <p>{{ vm.statusMessage }}</p>
  28. </div>
  29. </div>
  30. <div class="health-facts">
  31. <span><b>{{ vm.readyDomains }}</b>/{{ vm.coverage.length }} 业务域就绪</span>
  32. <span><b>{{ vm.qualityFailed }}</b> 项阻断</span>
  33. <span><b>{{ vm.activeJobs }}</b> 个运行任务</span>
  34. <span>快照 {{ vm.sourceAgeLabel }}</span>
  35. </div>
  36. </section>
  37. <div class="warning-row" *ngIf="vm.warnings.length">
  38. <lucide-icon [img]="icons.alert" [size]="16"></lucide-icon>
  39. <span>{{ vm.warnings.join(';') }},当前保留可用数据并显示接口异常。</span>
  40. </div>
  41. <div class="warning-row" *ngIf="actionError">
  42. <lucide-icon [img]="icons.failed" [size]="16"></lucide-icon>
  43. <span>{{ actionError }}</span>
  44. </div>
  45. <nav class="view-tabs" role="tablist" aria-label="数据中台视图">
  46. <button
  47. *ngFor="let tab of tabs"
  48. type="button"
  49. role="tab"
  50. [attr.aria-selected]="activeView === tab.id"
  51. [class.active]="activeView === tab.id"
  52. (click)="setView(tab.id)"
  53. >
  54. {{ tab.label }}
  55. </button>
  56. </nav>
  57. <section class="view-content" *ngIf="activeView === 'overview'">
  58. <div class="metric-strip" aria-label="数据总览指标">
  59. <div class="metric-item">
  60. <span>平台商品</span>
  61. <strong>{{ number(vm.ownProducts + vm.competitorProducts) }}</strong>
  62. <small>本品 {{ number(vm.ownProducts) }} · 竞品 {{ number(vm.competitorProducts) }}</small>
  63. </div>
  64. <div class="metric-item">
  65. <span>商品日明细</span>
  66. <strong>{{ number(vm.dataset.summary.metricRows) }}</strong>
  67. <small>{{ vm.dateDays }} 天经营周期</small>
  68. </div>
  69. <div class="metric-item metric-risk">
  70. <span>评价证据</span>
  71. <strong>{{ number(vm.ownReviews + vm.competitorReviews) }}</strong>
  72. <small>本品 {{ number(vm.ownReviews) }} · 竞品 {{ number(vm.competitorReviews) }}</small>
  73. </div>
  74. <div class="metric-item">
  75. <span>最近导入成功率</span>
  76. <strong>{{ vm.latestImport ? percent(vm.importSuccessRate) : '--' }}</strong>
  77. <small>{{ vm.latestImport ? number(vm.latestImport.successRows) + ' / ' + number(vm.latestImport.totalRows) + ' 行' : '暂无导入记录' }}</small>
  78. </div>
  79. </div>
  80. <section class="workspace-section">
  81. <div class="section-heading">
  82. <div>
  83. <h2>国内电商业务对象覆盖</h2>
  84. <p>按业务对象与数据粒度审视能力,聚合指标不替代订单、售后或履约明细。</p>
  85. </div>
  86. <span class="section-count">{{ vm.readyDomains }}/{{ vm.coverage.length }} 已就绪</span>
  87. </div>
  88. <div class="table-scroll">
  89. <table class="data-table coverage-table">
  90. <thead>
  91. <tr>
  92. <th>业务域</th>
  93. <th>核心对象</th>
  94. <th>标准粒度</th>
  95. <th>接入状态</th>
  96. <th>覆盖率</th>
  97. <th>当前证据</th>
  98. <th>下一缺口</th>
  99. </tr>
  100. </thead>
  101. <tbody>
  102. <tr *ngFor="let domain of vm.coverage">
  103. <td><strong>{{ domain.label }}</strong></td>
  104. <td>{{ domain.objectLabel }}</td>
  105. <td><code>{{ domain.grain }}</code></td>
  106. <td><span class="status-tag" [class]="'status-tag ' + domain.state">{{ coverageStateLabel(domain.state) }}</span></td>
  107. <td>
  108. <div class="coverage-cell">
  109. <span class="coverage-track" aria-hidden="true"><i [style.width.%]="domain.coverage * 100"></i></span>
  110. <b>{{ percent(domain.coverage) }}</b>
  111. </div>
  112. </td>
  113. <td>{{ domain.evidence }}</td>
  114. <td class="gap-cell">{{ domain.gap }}</td>
  115. </tr>
  116. </tbody>
  117. </table>
  118. </div>
  119. </section>
  120. <div class="overview-lower">
  121. <section class="workspace-section priority-section">
  122. <div class="section-heading compact-heading">
  123. <div>
  124. <h2>治理优先队列</h2>
  125. <p>先处理阻断洞察可信度的缺口,再扩展新业务域。</p>
  126. </div>
  127. <button class="text-button" type="button" (click)="setView('quality')">查看全部规则</button>
  128. </div>
  129. <div class="priority-list">
  130. <div class="priority-row" *ngFor="let rule of vm.rules">
  131. <span class="severity-mark" [class]="rule.state"></span>
  132. <div class="priority-copy">
  133. <strong>{{ rule.name }}</strong>
  134. <span>{{ rule.result }} · 门槛 {{ rule.threshold }}</span>
  135. </div>
  136. <span class="affected">影响 {{ number(rule.affected) }}</span>
  137. <button class="row-action" *ngIf="rule.action" type="button" (click)="runRuleAction(rule)">处理</button>
  138. </div>
  139. </div>
  140. </section>
  141. <section class="workspace-section run-section">
  142. <div class="section-heading compact-heading">
  143. <div>
  144. <h2>最近运行</h2>
  145. <p>数据源、导入与同步任务的最新状态。</p>
  146. </div>
  147. <button class="text-button" type="button" (click)="setView('sources')">进入运行中心</button>
  148. </div>
  149. <dl class="run-facts">
  150. <div>
  151. <dt>数据源</dt>
  152. <dd>{{ vm.sources.length ? sourceStatusLabel(vm.sources[0].status) : '未读取' }}</dd>
  153. <span>{{ vm.sources.length ? sourceKindLabel(vm.sources[0].kind) : '等待接入配置' }}</span>
  154. </div>
  155. <div>
  156. <dt>标准化导入</dt>
  157. <dd>{{ vm.latestImport ? importStatusLabel(vm.latestImport.status) : '无记录' }}</dd>
  158. <span>{{ vm.latestImport ? dateTime(vm.latestImport.completedAt) : '尚未完成导入' }}</span>
  159. </div>
  160. <div>
  161. <dt>同步队列</dt>
  162. <dd>{{ vm.jobs.length }} 个任务</dd>
  163. <span>{{ vm.activeJobs ? vm.activeJobs + ' 个正在运行' : '当前队列空闲' }}</span>
  164. </div>
  165. <div>
  166. <dt>质量门禁</dt>
  167. <dd>{{ vm.qualityPassed }} 通过 / {{ vm.qualityFailed }} 阻断</dd>
  168. <span>{{ vm.qualityWarnings }} 项需要关注</span>
  169. </div>
  170. </dl>
  171. </section>
  172. </div>
  173. </section>
  174. <section class="view-content" *ngIf="activeView === 'sources'">
  175. <section class="workspace-section">
  176. <div class="section-heading">
  177. <div>
  178. <h2>数据源</h2>
  179. <p>统一管理平台连接、凭证托管方式和最近检查时间。</p>
  180. </div>
  181. <button class="secondary-button compact" type="button" (click)="configureSource()">
  182. <lucide-icon [img]="icons.plug" [size]="15"></lucide-icon>
  183. 管理接入
  184. </button>
  185. </div>
  186. <div class="table-scroll">
  187. <table class="data-table source-table">
  188. <thead><tr><th>数据源</th><th>平台</th><th>连接状态</th><th>凭证托管</th><th>最近检查</th><th>操作</th></tr></thead>
  189. <tbody>
  190. <tr class="interactive-row" *ngFor="let source of vm.sources" tabindex="0" (click)="openSourceDetails(source)" (keydown.enter)="openSourceDetails(source)">
  191. <td><strong>{{ sourceKindLabel(source.kind) }}</strong><small class="cell-meta">{{ source.id }}</small></td>
  192. <td>{{ source.platform | uppercase }}</td>
  193. <td><span class="status-tag" [class.ready]="source.status === 'active' || source.status === 'configured'" [class.missing]="source.status === 'error'">{{ sourceStatusLabel(source.status) }}</span></td>
  194. <td>{{ credentialStorageLabel(source.credentialStorage) }}</td>
  195. <td>{{ dateTime(source.lastCheckedAt) }}</td>
  196. <td><button class="row-action" type="button" (click)="$event.stopPropagation(); openSourceDetails(source)">查看详情</button></td>
  197. </tr>
  198. <tr *ngIf="!vm.sources.length"><td colspan="6" class="empty-cell">尚未读取到数据源。</td></tr>
  199. </tbody>
  200. </table>
  201. </div>
  202. </section>
  203. <section class="workspace-section">
  204. <div class="section-heading">
  205. <div>
  206. <h2>同步任务</h2>
  207. <p>按平台商品 ID 拉取商品与评价,失败任务可重试,待执行任务可取消。</p>
  208. </div>
  209. <div class="section-tools">
  210. <select [(ngModel)]="jobStatus" aria-label="筛选同步任务状态">
  211. <option *ngFor="let status of jobStatuses" [ngValue]="status.value">{{ status.label }}</option>
  212. </select>
  213. <button class="primary-button compact" type="button" (click)="openSync(vm.dataset)">
  214. <lucide-icon [img]="icons.play" [size]="15"></lucide-icon>
  215. 新建同步
  216. </button>
  217. </div>
  218. </div>
  219. <div class="table-scroll">
  220. <table class="data-table jobs-table">
  221. <thead><tr><th>任务</th><th>范围</th><th>商品数</th><th>状态</th><th>进度</th><th>请求时间</th><th>操作</th></tr></thead>
  222. <tbody>
  223. <tr class="interactive-row" *ngFor="let job of filteredJobs(vm.jobs)" tabindex="0" (click)="openJobDetails(job)" (keydown.enter)="openJobDetails(job)">
  224. <td><strong>{{ job.id }}</strong><small class="cell-meta">尝试 {{ job.attempts }}/{{ job.maxAttempts }}</small></td>
  225. <td>{{ job.scopes.join(' + ') }}</td>
  226. <td>{{ job.productIds.length }}</td>
  227. <td><span class="status-tag" [class.ready]="job.status === 'completed'" [class.partial]="job.status === 'partial' || job.status === 'pending' || job.status === 'processing'" [class.missing]="job.status === 'failed'">{{ jobStatusLabel(job.status) }}</span></td>
  228. <td>
  229. <div class="coverage-cell compact-progress">
  230. <span class="coverage-track"><i [style.width.%]="job.progress"></i></span>
  231. <b>{{ job.progress }}%</b>
  232. </div>
  233. <small class="error-summary" *ngIf="job.errorSummary">{{ job.errorSummary }}</small>
  234. </td>
  235. <td>{{ dateTime(job.requestedAt) }}</td>
  236. <td>
  237. <div class="row-actions">
  238. <button class="icon-button small" type="button" title="查看任务详情" aria-label="查看任务详情" (click)="$event.stopPropagation(); openJobDetails(job)">
  239. <lucide-icon [img]="icons.history" [size]="15"></lucide-icon>
  240. </button>
  241. <button class="icon-button small" *ngIf="job.status === 'failed' || job.status === 'partial'" type="button" title="重试任务" aria-label="重试任务" [disabled]="busyJobs.has(job.id)" (click)="$event.stopPropagation(); retryJob(job)">
  242. <lucide-icon [img]="icons.retry" [size]="15"></lucide-icon>
  243. </button>
  244. <button class="icon-button small danger" *ngIf="job.status === 'pending'" type="button" title="取消任务" aria-label="取消任务" [disabled]="busyJobs.has(job.id)" (click)="$event.stopPropagation(); cancelJob(job)">
  245. <lucide-icon [img]="icons.close" [size]="15"></lucide-icon>
  246. </button>
  247. </div>
  248. </td>
  249. </tr>
  250. <tr *ngIf="!filteredJobs(vm.jobs).length"><td colspan="7" class="empty-cell">当前筛选下没有同步任务。</td></tr>
  251. </tbody>
  252. </table>
  253. </div>
  254. </section>
  255. <section class="workspace-section">
  256. <div class="section-heading">
  257. <div>
  258. <h2>导入批次</h2>
  259. <p>记录标准化快照来源、行级处理结果和完成时间。</p>
  260. </div>
  261. </div>
  262. <div class="table-scroll">
  263. <table class="data-table">
  264. <thead><tr><th>批次</th><th>来源</th><th>文件</th><th>处理结果</th><th>状态</th><th>完成时间</th></tr></thead>
  265. <tbody>
  266. <tr *ngFor="let batch of vm.imports">
  267. <td><strong>{{ batch.id }}</strong></td>
  268. <td>{{ sourceKindLabel(batch.sourceKind) }}</td>
  269. <td>{{ batch.sourceFile || '--' }}</td>
  270. <td>{{ number(batch.successRows) }} 成功 / {{ number(batch.failedRows) }} 失败</td>
  271. <td><span class="status-tag" [class.ready]="batch.status === 'completed'" [class.partial]="batch.status === 'partial' || batch.status === 'processing'" [class.missing]="batch.status === 'failed'">{{ importStatusLabel(batch.status) }}</span></td>
  272. <td>{{ dateTime(batch.completedAt) }}</td>
  273. </tr>
  274. <tr *ngIf="!vm.imports.length"><td colspan="6" class="empty-cell">暂无导入批次。</td></tr>
  275. </tbody>
  276. </table>
  277. </div>
  278. </section>
  279. </section>
  280. <section class="view-content" *ngIf="activeView === 'quality'">
  281. <div class="quality-summary">
  282. <div><strong>{{ vm.qualityPassed }}</strong><span>通过</span></div>
  283. <div class="quality-warning"><strong>{{ vm.qualityWarnings }}</strong><span>关注</span></div>
  284. <div class="quality-failed"><strong>{{ vm.qualityFailed }}</strong><span>阻断</span></div>
  285. <p>阻断项直接影响洞察可信度;关注项表示数据可用,但粒度、覆盖或时效尚未达到运营标准。</p>
  286. </div>
  287. <section class="workspace-section">
  288. <div class="section-heading">
  289. <div>
  290. <h2>质量门禁</h2>
  291. <p>覆盖完整性、结构一致性、新鲜度和业务可追溯性。</p>
  292. </div>
  293. </div>
  294. <div class="table-scroll">
  295. <table class="data-table quality-table">
  296. <thead><tr><th>领域</th><th>规则</th><th>当前结果</th><th>门槛</th><th>影响对象</th><th>结论</th><th>操作</th></tr></thead>
  297. <tbody>
  298. <tr *ngFor="let rule of vm.rules">
  299. <td>{{ rule.domain }}</td>
  300. <td><strong>{{ rule.name }}</strong></td>
  301. <td>{{ rule.result }}</td>
  302. <td>{{ rule.threshold }}</td>
  303. <td>{{ number(rule.affected) }}</td>
  304. <td><span class="status-tag" [class]="'status-tag ' + rule.state">{{ ruleStateLabel(rule.state) }}</span></td>
  305. <td><button class="row-action" type="button" *ngIf="rule.action" (click)="runRuleAction(rule)">处理</button><span *ngIf="!rule.action">--</span></td>
  306. </tr>
  307. </tbody>
  308. </table>
  309. </div>
  310. </section>
  311. <section class="semantic-band">
  312. <div>
  313. <lucide-icon [img]="icons.layers" [size]="19"></lucide-icon>
  314. <strong>聚合指标</strong>
  315. <p>商品日 GMV、订单数、退款额用于趋势与经营对比,不能回溯单笔交易。</p>
  316. </div>
  317. <div>
  318. <lucide-icon [img]="icons.shopping" [size]="19"></lucide-icon>
  319. <strong>完整业务实体</strong>
  320. <p>订单、子单、支付、发货、签收、售后单、库存流水需要独立主键与状态历史。</p>
  321. </div>
  322. <div>
  323. <lucide-icon [img]="icons.star" [size]="19"></lucide-icon>
  324. <strong>VOC 证据</strong>
  325. <p>评价、追评、商家回复和售后原因应保留来源、时间、商品归属与可回溯证据。</p>
  326. </div>
  327. </section>
  328. </section>
  329. <section class="view-content" *ngIf="activeView === 'dictionary'">
  330. <section class="workspace-section">
  331. <div class="section-heading dictionary-heading">
  332. <div>
  333. <h2>国内电商指标口径</h2>
  334. <p>统一指标定义、计算公式、数据粒度和来源可用性。</p>
  335. </div>
  336. <div class="section-tools dictionary-tools">
  337. <label class="search-field">
  338. <lucide-icon [img]="icons.search" [size]="15"></lucide-icon>
  339. <input [(ngModel)]="dictionarySearch" type="search" placeholder="搜索指标、定义或公式" aria-label="搜索指标口径">
  340. </label>
  341. <select [(ngModel)]="dictionaryDomain" aria-label="筛选指标领域">
  342. <option value="">全部领域</option>
  343. <option *ngFor="let domain of dictionaryDomains()" [value]="domain">{{ domain }}</option>
  344. </select>
  345. </div>
  346. </div>
  347. <div class="table-scroll">
  348. <table class="data-table dictionary-table">
  349. <thead><tr><th>领域</th><th>指标</th><th>业务定义</th><th>计算口径</th><th>粒度</th><th>来源</th><th>可用性</th></tr></thead>
  350. <tbody>
  351. <tr *ngFor="let metric of filteredDictionary()">
  352. <td>{{ metric.domain }}</td>
  353. <td><strong>{{ metric.name }}</strong><code class="metric-key">{{ metric.key }}</code></td>
  354. <td>{{ metric.definition }}</td>
  355. <td>{{ metric.formula }}</td>
  356. <td><code>{{ metric.grain }}</code></td>
  357. <td>{{ metric.source }}</td>
  358. <td><span class="status-tag" [class.ready]="metric.availability === 'available'" [class.partial]="metric.availability === 'partial'" [class.missing]="metric.availability === 'missing'">{{ availabilityLabel(metric.availability) }}</span></td>
  359. </tr>
  360. <tr *ngIf="!filteredDictionary().length"><td colspan="7" class="empty-cell">没有匹配的指标口径。</td></tr>
  361. </tbody>
  362. </table>
  363. </div>
  364. </section>
  365. </section>
  366. <button class="drawer-backdrop" type="button" aria-label="关闭侧栏" *ngIf="drawerOpen()" (click)="closeActiveDrawer()"></button>
  367. <aside *ngIf="selectedJob" class="detail-drawer open" role="dialog" aria-modal="true" aria-labelledby="job-detail-title">
  368. <ng-container *ngIf="selectedJob as job">
  369. <div class="drawer-header">
  370. <div>
  371. <h2 id="job-detail-title">同步任务详情</h2>
  372. <p>{{ job.id }}</p>
  373. </div>
  374. <button class="icon-button" type="button" title="关闭" aria-label="关闭任务详情" (click)="closeJobDetails()">
  375. <lucide-icon [img]="icons.close" [size]="18"></lucide-icon>
  376. </button>
  377. </div>
  378. <div class="drawer-body detail-body">
  379. <div class="detail-status-line">
  380. <span class="status-tag" [class.ready]="job.status === 'completed'" [class.partial]="job.status === 'partial' || job.status === 'pending' || job.status === 'processing'" [class.missing]="job.status === 'failed'">{{ jobStatusLabel(job.status) }}</span>
  381. <span>尝试 {{ job.attempts }}/{{ job.maxAttempts }}</span>
  382. </div>
  383. <section class="drawer-section progress-section" aria-label="任务进度">
  384. <div class="drawer-section-heading"><h3>任务进度</h3><strong>{{ job.progress }}%</strong></div>
  385. <span class="detail-progress-track"><i [style.width.%]="job.progress"></i></span>
  386. <p>{{ job.productIds.length }} 个商品 · {{ job.scopes.length }} 个同步范围</p>
  387. </section>
  388. <div class="detail-error" *ngIf="job.errorSummary">
  389. <lucide-icon [img]="icons.alert" [size]="17"></lucide-icon>
  390. <div><strong>错误摘要</strong><p>{{ job.errorSummary }}</p></div>
  391. </div>
  392. <section class="drawer-section">
  393. <div class="drawer-section-heading"><h3>运行信息</h3></div>
  394. <dl class="detail-list">
  395. <div><dt>平台</dt><dd>{{ job.platform | uppercase }}</dd></div>
  396. <div><dt>请求时间</dt><dd>{{ dateTime(job.requestedAt) }}</dd></div>
  397. <div><dt>开始时间</dt><dd>{{ dateTime(job.startedAt) }}</dd></div>
  398. <div><dt>完成时间</dt><dd>{{ dateTime(job.completedAt) }}</dd></div>
  399. </dl>
  400. <div class="detail-subsection">
  401. <span class="detail-label">同步范围</span>
  402. <div class="detail-chips"><span *ngFor="let scope of job.scopes">{{ scopeLabel(scope) }}</span></div>
  403. </div>
  404. <div class="detail-subsection">
  405. <span class="detail-label">平台商品 ID</span>
  406. <div class="product-id-list"><code *ngFor="let productId of job.productIds">{{ productId }}</code></div>
  407. </div>
  408. </section>
  409. <section class="drawer-section event-section">
  410. <div class="drawer-section-heading">
  411. <div><h3>事件时间线</h3><p>按服务端记录顺序展示。</p></div>
  412. <button class="icon-button small" type="button" title="刷新事件" aria-label="刷新任务事件" [disabled]="jobEventsLoading" (click)="reloadJobEvents()">
  413. <lucide-icon [img]="icons.refresh" [size]="15" [class.spinning]="jobEventsLoading"></lucide-icon>
  414. </button>
  415. </div>
  416. <div class="drawer-loading" *ngIf="jobEventsLoading"><span></span>正在读取事件</div>
  417. <div class="drawer-inline-error" *ngIf="!jobEventsLoading && jobEventsError">
  418. <p>{{ jobEventsError }}</p>
  419. <button class="text-button" type="button" (click)="reloadJobEvents()">重新加载</button>
  420. </div>
  421. <ol class="event-timeline" *ngIf="!jobEventsLoading && !jobEventsError && jobEvents.length">
  422. <li *ngFor="let event of jobEvents" [class]="'event-' + event.level">
  423. <span class="event-marker">
  424. <lucide-icon *ngIf="event.level === 'info'" [img]="icons.check" [size]="13"></lucide-icon>
  425. <lucide-icon *ngIf="event.level !== 'info'" [img]="icons.alert" [size]="13"></lucide-icon>
  426. </span>
  427. <div class="event-content">
  428. <div class="event-heading"><strong>{{ eventTypeLabel(event.type) }}</strong><time>{{ dateTime(event.createdAt) }}</time></div>
  429. <p>{{ event.message }}</p>
  430. <div class="event-details" *ngIf="eventDetailEntries(event) as details">
  431. <span *ngFor="let detail of details"><b>{{ detail.label }}</b>{{ detail.value }}</span>
  432. </div>
  433. <small>{{ eventLevelLabel(event.level) }} · {{ event.type }}</small>
  434. </div>
  435. </li>
  436. </ol>
  437. <div class="drawer-empty" *ngIf="!jobEventsLoading && !jobEventsError && !jobEvents.length">
  438. <lucide-icon [img]="icons.history" [size]="19"></lucide-icon>
  439. <p>任务尚未记录事件。</p>
  440. </div>
  441. </section>
  442. </div>
  443. <div class="drawer-footer">
  444. <button class="secondary-button" type="button" (click)="closeJobDetails()">关闭</button>
  445. <button class="secondary-button danger-text" *ngIf="job.status === 'pending'" type="button" [disabled]="busyJobs.has(job.id)" (click)="cancelJob(job)">取消任务</button>
  446. <button class="primary-button" *ngIf="job.status === 'failed' || job.status === 'partial'" type="button" [disabled]="busyJobs.has(job.id)" (click)="retryJob(job)">
  447. <lucide-icon [img]="icons.retry" [size]="16"></lucide-icon>重试任务
  448. </button>
  449. </div>
  450. </ng-container>
  451. </aside>
  452. <aside *ngIf="selectedSource" class="detail-drawer open" role="dialog" aria-modal="true" aria-labelledby="source-detail-title">
  453. <ng-container *ngIf="selectedSource as source">
  454. <div class="drawer-header">
  455. <div>
  456. <h2 id="source-detail-title">数据源详情</h2>
  457. <p>{{ sourceKindLabel(source.kind) }} · {{ source.id }}</p>
  458. </div>
  459. <button class="icon-button" type="button" title="关闭" aria-label="关闭数据源详情" (click)="closeSourceDetails()">
  460. <lucide-icon [img]="icons.close" [size]="18"></lucide-icon>
  461. </button>
  462. </div>
  463. <div class="drawer-body detail-body">
  464. <div class="detail-status-line">
  465. <span class="status-tag" [class.ready]="source.status === 'active' || source.status === 'configured'" [class.missing]="source.status === 'error'">{{ sourceStatusLabel(source.status) }}</span>
  466. <span>{{ source.platform | uppercase }} · {{ workspaceId }}</span>
  467. </div>
  468. <section class="drawer-section">
  469. <div class="drawer-section-heading"><h3>授权与连接</h3></div>
  470. <dl class="detail-list source-detail-list">
  471. <div><dt>授权托管</dt><dd>{{ credentialStorageLabel(source.credentialStorage) }}</dd></div>
  472. <div><dt>最近检查</dt><dd>{{ dateTime(source.lastCheckedAt) }}</dd></div>
  473. <div><dt>授权账号</dt><dd><span class="building-label">待建设</span></dd></div>
  474. <div><dt>授权范围</dt><dd><span class="building-label">待建设</span></dd></div>
  475. <div><dt>授权到期时间</dt><dd><span class="building-label">待建设</span></dd></div>
  476. <div><dt>店铺绑定</dt><dd><span class="building-label">待建设</span></dd></div>
  477. </dl>
  478. </section>
  479. <section class="drawer-section">
  480. <div class="drawer-section-heading"><div><h3>连接器数据域</h3><p>以当前真实同步范围为准。</p></div></div>
  481. <div class="domain-list">
  482. <div *ngFor="let domain of connectorDomains">
  483. <div><strong>{{ domain.label }}</strong><p>{{ domain.description }}</p></div>
  484. <span class="status-tag" [class.ready]="domain.state === 'supported'" [class.missing]="domain.state === 'building'">{{ domain.state === 'supported' ? '已支持' : '待建设' }}</span>
  485. </div>
  486. </div>
  487. </section>
  488. <section class="next-action-band">
  489. <lucide-icon [img]="source.status === 'error' ? icons.alert : icons.check" [size]="18"></lucide-icon>
  490. <div><span>下一步动作</span><strong>{{ sourceNextAction(source).title }}</strong><p>{{ sourceNextAction(source).description }}</p></div>
  491. </section>
  492. </div>
  493. <div class="drawer-footer">
  494. <button class="secondary-button" type="button" (click)="configureSource()">
  495. <lucide-icon [img]="icons.settings" [size]="16"></lucide-icon>管理接入
  496. </button>
  497. <button class="primary-button" *ngIf="source.status === 'active' || source.status === 'configured'" type="button" (click)="openSyncFromSource(vm.dataset)">
  498. <lucide-icon [img]="icons.play" [size]="16"></lucide-icon>新建同步
  499. </button>
  500. </div>
  501. </ng-container>
  502. </aside>
  503. <aside *ngIf="syncOpen" class="sync-drawer open" role="dialog" aria-modal="true" aria-labelledby="sync-drawer-title">
  504. <div class="drawer-header">
  505. <div>
  506. <h2 id="sync-drawer-title">新建平台同步</h2>
  507. <p>{{ platform | uppercase }} · {{ workspaceId }}</p>
  508. </div>
  509. <button class="icon-button" type="button" title="关闭" aria-label="关闭" [disabled]="syncing" (click)="closeSync()">
  510. <lucide-icon [img]="icons.close" [size]="18"></lucide-icon>
  511. </button>
  512. </div>
  513. <div class="drawer-body">
  514. <label class="field-label" for="sync-products">平台商品 ID</label>
  515. <textarea id="sync-products" [(ngModel)]="syncProductIds" rows="8" placeholder="每行一个商品 ID,最多 100 个"></textarea>
  516. <small class="field-help">支持换行、逗号或空格分隔;系统会自动去重。</small>
  517. <fieldset class="scope-fieldset">
  518. <legend>同步范围</legend>
  519. <label><input type="checkbox" [(ngModel)]="syncProductScope"><span>商品主数据</span></label>
  520. <label><input type="checkbox" [(ngModel)]="syncReviewScope"><span>商品评价</span></label>
  521. </fieldset>
  522. <div class="sync-note">
  523. <lucide-icon [img]="icons.clock" [size]="17"></lucide-icon>
  524. <p>任务进入后台队列后可在“接入与同步”查看进度。商品与评价会分别记录结果,部分成功的任务可重试。</p>
  525. </div>
  526. <p class="form-error" *ngIf="syncError">{{ syncError }}</p>
  527. </div>
  528. <div class="drawer-footer">
  529. <button class="secondary-button" type="button" [disabled]="syncing" (click)="closeSync()">取消</button>
  530. <button class="primary-button" type="button" [disabled]="syncing" (click)="submitSync()">
  531. <lucide-icon [img]="icons.play" [size]="16"></lucide-icon>
  532. {{ syncing ? '正在创建' : '创建任务' }}
  533. </button>
  534. </div>
  535. </aside>
  536. </main>